1
0
mirror of https://github.com/tooot-app/app synced 2025-06-05 22:19:13 +02:00

Able to edit image as attachment

This commit is contained in:
Zhiyuan Zheng
2020-12-06 16:06:38 +01:00
parent 0a86c2d627
commit add331ef0e
5 changed files with 337 additions and 157 deletions

View File

@ -1,3 +1,137 @@
type AttachmentImage = {
// Base
id: string
type: 'image'
url: string
preview_url: string
// Others
remote_url?: string
text_url?: string
meta?: {
original?: { width: number; height: number; size: string; aspect: number }
small?: { width: number; height: number; size: string; aspect: number }
focus?: { x: number; y: number }
}
description?: string
blurhash?: string
}
type AttachmentVideo = {
// Base
id: string
type: 'video'
url: string
preview_url: string
// Others
remote_url?: string
text_url?: string
meta?: {
length: string
duration: number
fps: number
size: string
width: number
height: number
aspect: number
audio_encode: string
audio_bitrate: string
audio_channels: string
original: {
width: number
height: number
frame_rate: string
duration: number
bitrate: number
}
small: {
width: number
height: number
size: string
aspect: number
}
}
description?: string
blurhash?: string
}
type AttachmentGifv = {
// Base
id: string
type: 'gifv'
url: string
preview_url: string
// Others
remote_url?: string
text_url?: string
meta?: {
length: string
duration: number
fps: number
size: string
width: number
height: number
aspect: number
original: {
width: number
height: number
frame_rate: string
duration: number
bitrate: number
}
small: {
width: number
height: number
size: string
aspect: number
}
}
description?: string
blurhash?: string
}
type AttachmentAudio = {
// Base
id: string
type: 'audio'
url: string
preview_url: string
// Others
remote_url?: string
text_url?: string
meta?: {
length: string
duration: number
audio_encode: string
audio_bitrate: string
audio_channels: string
original: {
duration: number
bitrate: number
}
}
description?: string
blurhash?: string
}
type AttachmentUnknown = {
// Base
id: string
type: 'unknown'
url: string
preview_url: string
// Others
remote_url?: string
text_url?: string
meta?: any
description?: string
blurhash?: string
}
declare namespace Mastodon { declare namespace Mastodon {
type Account = { type Account = {
// Base // Base
@ -38,83 +172,12 @@ declare namespace Mastodon {
vapid_key?: string vapid_key?: string
} }
type Attachment = { type Attachment =
// Base | AttachmentImage
id: string | AttachmentVideo
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio' | AttachmentGifv
url: string | AttachmentAudio
preview_url?: string | AttachmentUnknown
// Others
remote_url?: string
text_url?: string
meta?: {
original?: { width: number; height: number; size: string; aspect: number }
small?: { width: number; height: number; size: string; aspect: number }
focus?:
| { x: number; y: number }
| {
length: string
duration: number
fps: number
size: string
width: number
height: number
aspect: number
audio_encode: string
audio_bitrate: string
audio_channels: string
original: {
width: number
height: number
frame_rate: string
duration: number
bitrate: number
}
small: {
width: number
height: number
size: string
aspect: number
}
}
| {
length: string
duration: number
fps: number
size: string
width: number
height: number
aspect: number
original: {
width: number
height: number
frame_rate: string
duration: number
bitrate: number
}
small: {
width: number
height: number
size: string
aspect: number
}
}
| {
length: string
duration: number
audio_encode: string
audio_bitrate: string
audio_channels: string
original: {
duration: number
bitrate: number
}
}
}
description?: string
blurhash?: string
}
type Card = { type Card = {
// Base // Base

View File

@ -84,6 +84,10 @@ export type PostAction =
type: 'attachmentUploadProgress' type: 'attachmentUploadProgress'
payload: PostState['attachmentUploadProgress'] payload: PostState['attachmentUploadProgress']
} }
| {
type: 'attachmentEdit'
payload: Mastodon.Attachment & { local_url?: string }
}
| { | {
type: 'visibility' type: 'visibility'
payload: PostState['visibility'] payload: PostState['visibility']
@ -133,6 +137,13 @@ const postReducer = (state: PostState, action: PostAction): PostState => {
return { ...state, attachments: action.payload } return { ...state, attachments: action.payload }
case 'attachmentUploadProgress': case 'attachmentUploadProgress':
return { ...state, attachmentUploadProgress: action.payload } return { ...state, attachmentUploadProgress: action.payload }
case 'attachmentEdit':
return {
...state,
attachments: state.attachments.map(attachment =>
attachment.id === action.payload.id ? action.payload : attachment
)
}
case 'visibility': case 'visibility':
return { ...state, visibility: action.payload } return { ...state, visibility: action.payload }
default: default:

View File

@ -84,7 +84,8 @@ const ComposeAttachments: React.FC<Props> = ({ postState, postDispatch }) => {
icon: 'edit', icon: 'edit',
onPress: () => onPress: () =>
navigation.navigate('Screen-Shared-Compose-EditAttachment', { navigation.navigate('Screen-Shared-Compose-EditAttachment', {
attachment: item attachment: item,
postDispatch
}) })
})} })}
</View> </View>

View File

@ -1,11 +1,17 @@
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import React, { useCallback, useMemo, useRef, useState } from 'react' import React, {
Dispatch,
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { import {
Alert,
Animated, Animated,
Dimensions, Dimensions,
Image, Image,
KeyboardAvoidingView, KeyboardAvoidingView,
PanResponder,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
@ -20,6 +26,8 @@ import { HeaderLeft, HeaderRight } from 'src/components/Header'
import { StyleConstants } from 'src/utils/styles/constants' import { StyleConstants } from 'src/utils/styles/constants'
import { useTheme } from 'src/utils/styles/ThemeManager' import { useTheme } from 'src/utils/styles/ThemeManager'
import { PanGestureHandler } from 'react-native-gesture-handler' import { PanGestureHandler } from 'react-native-gesture-handler'
import { PostAction } from '../Compose'
import client from 'src/api/client'
const Stack = createNativeStackNavigator() const Stack = createNativeStackNavigator()
@ -27,28 +35,63 @@ export interface Props {
route: { route: {
params: { params: {
attachment: Mastodon.Attachment & { local_url: string } attachment: Mastodon.Attachment & { local_url: string }
postDispatch: Dispatch<PostAction>
} }
} }
} }
const ComposeEditAttachment: React.FC<Props> = ({ const ComposeEditAttachment: React.FC<Props> = ({
route: { route: {
params: { attachment } params: { attachment, postDispatch }
} }
}) => { }) => {
const imageDimensionis = {
width: Dimensions.get('screen').width,
height: Dimensions.get('screen').width / attachment.meta?.original?.aspect!
}
const navigation = useNavigation() const navigation = useNavigation()
const { theme } = useTheme() const { theme } = useTheme()
const [altText, setAltText] = useState<string | undefined>() const [altText, setAltText] = useState<string | undefined>(
attachment.description
)
const focus = useRef({ x: 0, y: 0 })
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', () => {
let needUpdate = false
if (altText) {
attachment.description = altText
needUpdate = true
}
if (focus.current.x !== 0 || focus.current.y !== 0) {
attachment.meta!.focus = {
x: focus.current.x > 1 ? 1 : focus.current.x,
y: focus.current.y > 1 ? 1 : focus.current.y
}
needUpdate = true
}
if (needUpdate) {
postDispatch({ type: 'attachmentEdit', payload: attachment })
}
})
return unsubscribe
}, [navigation, altText])
const imageFocus = useCallback(() => { const imageFocus = useCallback(() => {
const imageDimensionis = { const panFocus = useRef(
width: Dimensions.get('screen').width, new Animated.ValueXY(
height: attachment.meta.focus?.x && attachment.meta.focus?.y
Dimensions.get('screen').width / attachment.meta?.original?.aspect! ? {
} x: (attachment.meta.focus.x * imageDimensionis.width) / 2,
y: (-attachment.meta.focus.y * imageDimensionis.height) / 2
const panFocus = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current }
: { x: 0, y: 0 }
)
).current
const panX = panFocus.x.interpolate({ const panX = panFocus.x.interpolate({
inputRange: [-imageDimensionis.width / 2, imageDimensionis.width / 2], inputRange: [-imageDimensionis.width / 2, imageDimensionis.width / 2],
outputRange: [-imageDimensionis.width / 2, imageDimensionis.width / 2], outputRange: [-imageDimensionis.width / 2, imageDimensionis.width / 2],
@ -59,6 +102,12 @@ const ComposeEditAttachment: React.FC<Props> = ({
outputRange: [-imageDimensionis.height / 2, imageDimensionis.height / 2], outputRange: [-imageDimensionis.height / 2, imageDimensionis.height / 2],
extrapolate: 'clamp' extrapolate: 'clamp'
}) })
panFocus.addListener(e => {
focus.current = {
x: e.x / (imageDimensionis.width / 2),
y: -e.y / (imageDimensionis.height / 2)
}
})
const handleGesture = Animated.event( const handleGesture = Animated.event(
[{ nativeEvent: { translationX: panFocus.x, translationY: panFocus.y } }], [{ nativeEvent: { translationX: panFocus.x, translationY: panFocus.y } }],
{ useNativeDriver: true } { useNativeDriver: true }
@ -68,62 +117,66 @@ const ComposeEditAttachment: React.FC<Props> = ({
}, []) }, [])
return ( return (
<View style={{ overflow: 'hidden' }}> <>
<Image <View style={{ overflow: 'hidden' }}>
style={{ <Image
width: imageDimensionis.width, style={{
height: imageDimensionis.height width: imageDimensionis.width,
}} height: imageDimensionis.height
source={{ }}
uri: attachment.local_url || attachment.preview_url source={{
}} uri: attachment.local_url || attachment.preview_url
/> }}
<PanGestureHandler />
onGestureEvent={handleGesture} <PanGestureHandler
onHandlerStateChange={onHandlerStateChange} onGestureEvent={handleGesture}
> onHandlerStateChange={onHandlerStateChange}
<Animated.View
style={[
{
position: 'absolute',
top: -500 + imageDimensionis.height / 2,
left: -500 + imageDimensionis.width / 2,
transform: [{ translateX: panX }, { translateY: panY }]
}
]}
> >
<Svg width='1000px' height='1000px' viewBox='0 0 1000 1000'> <Animated.View
<G> style={[
<G id='Mask'> {
<Path position: 'absolute',
d={ top: -1000 + imageDimensionis.height / 2,
'M1000,0 L1000,1000 L0,1000 L0,0 L1000,0 Z M500,467 C481.774603,467 467,481.774603 467,500 C467,518.225397 481.774603,533 500,533 C518.225397,533 533,518.225397 533,500 C533,481.774603 518.225397,467 500,467 Z' left: -1000 + imageDimensionis.width / 2,
} transform: [{ translateX: panX }, { translateY: panY }]
fillOpacity='0.35' }
fill='#000000' ]}
/> >
<G transform='translate(467.000000, 467.000000)'> <Svg width='2000' height='2000' viewBox='0 0 2000 2000'>
<Circle <G>
stroke='#FFFFFF' <G id='Mask'>
strokeWidth='2' <Path
cx='33' d={
cy='33' 'M2000,0 L2000,2000 L0,2000 L0,0 L2000,0 Z M1000,967 C981.774603,967 967,981.774603 967,1000 C967,1018.2254 981.774603,1033 1000,1033 C1018.2254,1033 1033,1018.2254 1033,1000 C1033,981.774603 1018.2254,967 1000,967 Z'
r='33' }
fill={theme.backgroundOverlay}
/> />
<Circle fill='#FFFFFF' cx='33' cy='33' r='2' /> <G transform='translate(967, 967)'>
<Circle
stroke={theme.background}
strokeWidth='2'
cx='33'
cy='33'
r='33'
/>
<Circle fill={theme.background} cx='33' cy='33' r='2' />
</G>
</G> </G>
</G> </G>
</G> </Svg>
</Svg> </Animated.View>
</Animated.View> </PanGestureHandler>
</PanGestureHandler> </View>
</View> <Text style={[styles.imageFocusText, { color: theme.primary }]}>
</Text>
</>
) )
}, []) }, [])
const altTextInput = useCallback(() => { const altTextInput = useCallback(() => {
return ( return (
<> <View style={styles.altTextContainer}>
<Text style={[styles.altTextInputHeading, { color: theme.primary }]}> <Text style={[styles.altTextInputHeading, { color: theme.primary }]}>
</Text> </Text>
@ -139,13 +192,14 @@ const ComposeEditAttachment: React.FC<Props> = ({
} }
placeholderTextColor={theme.secondary} placeholderTextColor={theme.secondary}
scrollEnabled scrollEnabled
value={altText}
/> />
<Text style={[styles.altTextLength, { color: theme.secondary }]}> <Text style={[styles.altTextLength, { color: theme.secondary }]}>
{1500 - (altText?.length || 0)} {altText?.length || 0} / 1500
</Text> </Text>
</> </View>
) )
}, [altText?.length]) }, [altText])
return ( return (
<KeyboardAvoidingView behavior='padding' style={{ flex: 1 }}> <KeyboardAvoidingView behavior='padding' style={{ flex: 1 }}>
@ -158,25 +212,76 @@ const ComposeEditAttachment: React.FC<Props> = ({
headerLeft: () => ( headerLeft: () => (
<HeaderLeft text='取消' onPress={() => navigation.goBack()} /> <HeaderLeft text='取消' onPress={() => navigation.goBack()} />
), ),
headerRight: () => <HeaderRight text='应用' onPress={() => {}} /> headerRight: () => (
<HeaderRight
text='应用'
onPress={() => {
const formData = new FormData()
if (altText) {
formData.append('description', altText)
}
if (focus.current.x !== 0 || focus.current.y !== 0) {
formData.append(
'focus',
`${focus.current.x},${focus.current.y}`
)
}
client({
method: 'put',
instance: 'local',
url: `media/${attachment.id}`,
...(formData && { body: formData })
})
.then(
res => {
if (res.body.id === attachment.id) {
Alert.alert('修改成功', '', [
{
text: '好的',
onPress: () => {
navigation.goBack()
}
}
])
} else {
Alert.alert('修改失败', '', [
{
text: '返回重试'
}
])
}
},
error => {
Alert.alert('修改失败', error.body, [
{
text: '返回重试'
}
])
}
)
.catch(() => {
Alert.alert('修改失败', '', [
{
text: '返回重试'
}
])
})
}}
/>
)
}} }}
> >
{() => { {() => {
switch (attachment.type) { switch (attachment.type) {
case 'image': case 'image':
return ( return (
<View style={{ flex: 1 }}> <ScrollView style={{ flex: 1 }}>
{imageFocus()} {imageFocus()}
<Text {altTextInput()}
style={[ </ScrollView>
styles.imageFocusText,
{ color: theme.primary }
]}
>
</Text>
<View style={styles.editContainer}>{altTextInput()}</View>
</View>
) )
} }
return null return null
@ -189,14 +294,14 @@ const ComposeEditAttachment: React.FC<Props> = ({
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
editContainer: { padding: StyleConstants.Spacing.Global.PagePadding },
imageFocusText: { imageFocusText: {
fontSize: StyleConstants.Font.Size.M fontSize: StyleConstants.Font.Size.M,
padding: StyleConstants.Spacing.Global.PagePadding
}, },
altTextContainer: { padding: StyleConstants.Spacing.Global.PagePadding },
altTextInputHeading: { altTextInputHeading: {
fontSize: StyleConstants.Font.Size.M, fontSize: StyleConstants.Font.Size.M,
fontWeight: StyleConstants.Font.Weight.Bold, fontWeight: StyleConstants.Font.Weight.Bold
marginTop: StyleConstants.Spacing.XL
}, },
altTextInput: { altTextInput: {
height: 200, height: 200,
@ -215,4 +320,4 @@ const styles = StyleSheet.create({
} }
}) })
export default ComposeEditAttachment export default React.memo(ComposeEditAttachment, () => true)

View File

@ -60,7 +60,7 @@ const sharedScreens = (Stack: any) => {
name='Screen-Shared-Compose-EditAttachment' name='Screen-Shared-Compose-EditAttachment'
component={ComposeEditAttachment} component={ComposeEditAttachment}
options={{ options={{
stackPresentation: 'fullScreenModal' stackPresentation: 'modal',
}} }}
/>, />,
<Stack.Screen <Stack.Screen