Update web image editor (#588)
* Update web image editor * Delete type-assertions.ts * Re-add getKeys * Uncomment rotation code * Revert "Uncomment rotation code" This reverts commit 6269f3b928c2e5cacaf5d0ff5323fe975ee48eab. * Shuffle dependencies and update mobile resolution * Update ImageEditor modal layout for mobile * Avoid accidental closes of the EditImage modal --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com>
This commit is contained in:
parent
8f6b5d3df9
commit
b0ebb6c9d1
10 changed files with 642 additions and 16 deletions
|
@ -50,7 +50,7 @@ export const Gallery = observer(function ({gallery}: Props) {
|
|||
|
||||
const handleEditPhoto = useCallback(
|
||||
(image: ImageModel) => {
|
||||
gallery.crop(image)
|
||||
gallery.edit(image)
|
||||
},
|
||||
[gallery],
|
||||
)
|
||||
|
@ -121,10 +121,10 @@ export const Gallery = observer(function ({gallery}: Props) {
|
|||
</TouchableOpacity>
|
||||
<View style={imageControlsSubgroupStyle}>
|
||||
<TouchableOpacity
|
||||
testID="cropPhotoButton"
|
||||
testID="editPhotoButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Crop image"
|
||||
accessibilityHint="Opens modal for cropping image"
|
||||
accessibilityLabel="Edit image"
|
||||
accessibilityHint=""
|
||||
onPress={() => {
|
||||
handleEditPhoto(image)
|
||||
}}
|
||||
|
|
|
@ -24,7 +24,6 @@ export function Component({image}: Props) {
|
|||
const [altText, setAltText] = useState(image.altText)
|
||||
|
||||
const onPressSave = useCallback(() => {
|
||||
setAltText(altText)
|
||||
image.setAltText(altText)
|
||||
store.shell.closeModal()
|
||||
}, [store, image, altText])
|
||||
|
|
418
src/view/com/modals/EditImage.tsx
Normal file
418
src/view/com/modals/EditImage.tsx
Normal file
|
@ -0,0 +1,418 @@
|
|||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
import {gradients, s} from 'lib/styles'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {Text} from '../util/text/Text'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {useStores} from 'state/index'
|
||||
import ImageEditor, {Position} from 'react-avatar-editor'
|
||||
import {TextInput} from './util'
|
||||
import {enforceLen} from 'lib/strings/helpers'
|
||||
import {MAX_ALT_TEXT} from 'lib/constants'
|
||||
import {GalleryModel} from 'state/models/media/gallery'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons'
|
||||
import {Slider} from '@miblanchard/react-native-slider'
|
||||
import {MaterialIcons} from '@expo/vector-icons'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {getKeys} from 'lib/type-assertions'
|
||||
|
||||
export const snapPoints = ['80%']
|
||||
|
||||
interface Props {
|
||||
image: ImageModel
|
||||
gallery: GalleryModel
|
||||
}
|
||||
|
||||
// This is only used for desktop web
|
||||
export const Component = observer(function ({image, gallery}: Props) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const {shell} = store
|
||||
const theme = useTheme()
|
||||
const winDim = useWindowDimensions()
|
||||
|
||||
const [altText, setAltText] = useState(image.altText)
|
||||
const [aspectRatio, setAspectRatio] = useState<AspectRatio>(
|
||||
image.aspectRatio ?? 'None',
|
||||
)
|
||||
const [flipHorizontal, setFlipHorizontal] = useState<boolean>(
|
||||
image.flipHorizontal ?? false,
|
||||
)
|
||||
const [flipVertical, setFlipVertical] = useState<boolean>(
|
||||
image.flipVertical ?? false,
|
||||
)
|
||||
|
||||
// TODO: doesn't seem to be working correctly with crop
|
||||
// const [rotation, setRotation] = useState(image.rotation ?? 0)
|
||||
const [scale, setScale] = useState<number>(image.scale ?? 1)
|
||||
const [position, setPosition] = useState<Position>()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const editorRef = useRef<ImageEditor>(null)
|
||||
|
||||
const imgEditorStyles = useMemo(() => {
|
||||
const dim = Math.min(425, winDim.width - 24)
|
||||
return {width: dim, height: dim}
|
||||
}, [winDim.width])
|
||||
|
||||
const manipulationAttributes = useMemo(
|
||||
() => ({
|
||||
// TODO: doesn't seem to be working correctly with crop
|
||||
// ...(rotation !== undefined ? {rotate: rotation} : {}),
|
||||
...(flipHorizontal !== undefined ? {flipHorizontal} : {}),
|
||||
...(flipVertical !== undefined ? {flipVertical} : {}),
|
||||
}),
|
||||
[flipHorizontal, flipVertical],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const manipulateImage = async () => {
|
||||
await image.manipulate(manipulationAttributes)
|
||||
}
|
||||
|
||||
manipulateImage()
|
||||
}, [image, manipulationAttributes])
|
||||
|
||||
const ratios = useMemo(
|
||||
() =>
|
||||
({
|
||||
'4:3': {
|
||||
hint: 'Sets image aspect ratio to wide',
|
||||
Icon: RectWideIcon,
|
||||
},
|
||||
'1:1': {
|
||||
hint: 'Sets image aspect ratio to square',
|
||||
Icon: SquareIcon,
|
||||
},
|
||||
'3:4': {
|
||||
hint: 'Sets image aspect ratio to tall',
|
||||
Icon: RectTallIcon,
|
||||
},
|
||||
None: {
|
||||
label: 'None',
|
||||
hint: 'Sets image aspect ratio to tall',
|
||||
Icon: MaterialIcons,
|
||||
name: 'do-not-disturb-alt',
|
||||
},
|
||||
} as const),
|
||||
[],
|
||||
)
|
||||
|
||||
type AspectRatio = keyof typeof ratios
|
||||
|
||||
const onFlipHorizontal = useCallback(() => {
|
||||
setFlipHorizontal(!flipHorizontal)
|
||||
image.manipulate({flipHorizontal})
|
||||
}, [flipHorizontal, image])
|
||||
|
||||
const onFlipVertical = useCallback(() => {
|
||||
setFlipVertical(!flipVertical)
|
||||
image.manipulate({flipVertical})
|
||||
}, [flipVertical, image])
|
||||
|
||||
const adjustments = useMemo(
|
||||
() =>
|
||||
[
|
||||
// {
|
||||
// name: 'rotate-left',
|
||||
// label: 'Rotate left',
|
||||
// hint: 'Rotate image left',
|
||||
// onPress: () => {
|
||||
// const rotate = (rotation - 90) % 360
|
||||
// setRotation(rotate)
|
||||
// image.manipulate({rotate})
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'rotate-right',
|
||||
// label: 'Rotate right',
|
||||
// hint: 'Rotate image right',
|
||||
// onPress: () => {
|
||||
// const rotate = (rotation + 90) % 360
|
||||
// setRotation(rotate)
|
||||
// image.manipulate({rotate})
|
||||
// },
|
||||
// },
|
||||
{
|
||||
name: 'flip',
|
||||
label: 'Flip horizontal',
|
||||
hint: 'Flip image horizontally',
|
||||
onPress: onFlipHorizontal,
|
||||
},
|
||||
{
|
||||
name: 'flip',
|
||||
label: 'Flip vertically',
|
||||
hint: 'Flip image vertically',
|
||||
onPress: onFlipVertical,
|
||||
},
|
||||
] as const,
|
||||
[onFlipHorizontal, onFlipVertical],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
image.prev = image.compressed
|
||||
setIsEditing(true)
|
||||
}, [image])
|
||||
|
||||
const onCloseModal = useCallback(() => {
|
||||
shell.closeModal()
|
||||
setIsEditing(false)
|
||||
}, [shell])
|
||||
|
||||
const onPressCancel = useCallback(async () => {
|
||||
await gallery.previous(image)
|
||||
onCloseModal()
|
||||
}, [onCloseModal, gallery, image])
|
||||
|
||||
const onPressSave = useCallback(async () => {
|
||||
image.setAltText(altText)
|
||||
|
||||
const crop = editorRef.current?.getCroppingRect()
|
||||
|
||||
await image.manipulate({
|
||||
...(crop !== undefined
|
||||
? {
|
||||
crop: {
|
||||
originX: crop.x,
|
||||
originY: crop.y,
|
||||
width: crop.width,
|
||||
height: crop.height,
|
||||
},
|
||||
...(scale !== 1 ? {scale} : {}),
|
||||
...(position !== undefined ? {position} : {}),
|
||||
}
|
||||
: {}),
|
||||
...manipulationAttributes,
|
||||
aspectRatio,
|
||||
})
|
||||
|
||||
image.prevAttributes = manipulationAttributes
|
||||
onCloseModal()
|
||||
}, [
|
||||
altText,
|
||||
aspectRatio,
|
||||
image,
|
||||
manipulationAttributes,
|
||||
position,
|
||||
scale,
|
||||
onCloseModal,
|
||||
])
|
||||
|
||||
const onPressRatio = useCallback((as: AspectRatio) => {
|
||||
setAspectRatio(as)
|
||||
}, [])
|
||||
|
||||
const getLabelIconSize = useCallback((as: AspectRatio) => {
|
||||
switch (as) {
|
||||
case 'None':
|
||||
return 22
|
||||
case '1:1':
|
||||
return 32
|
||||
default:
|
||||
return 26
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Prevents preliminary flash when transformations are being applied
|
||||
if (image.compressed === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const {width, height} = image.getDisplayDimensions(
|
||||
aspectRatio,
|
||||
imgEditorStyles.width,
|
||||
)
|
||||
|
||||
return (
|
||||
<View testID="editImageModal" style={[pal.view, styles.container, s.flex1]}>
|
||||
<Text style={[styles.title, pal.text]}>Edit image</Text>
|
||||
<View>
|
||||
<View style={[styles.imgContainer, imgEditorStyles, pal.borderDark]}>
|
||||
<ImageEditor
|
||||
ref={editorRef}
|
||||
style={styles.imgEditor}
|
||||
image={isEditing ? image.compressed.path : image.path}
|
||||
width={width}
|
||||
height={height}
|
||||
scale={scale}
|
||||
border={0}
|
||||
position={position}
|
||||
onPositionChange={setPosition}
|
||||
/>
|
||||
</View>
|
||||
<Slider
|
||||
value={scale}
|
||||
onValueChange={(v: number | number[]) =>
|
||||
setScale(Array.isArray(v) ? v[0] : v)
|
||||
}
|
||||
minimumValue={1}
|
||||
maximumValue={3}
|
||||
/>
|
||||
<View style={[s.flexRow, styles.gap18]}>
|
||||
<View style={styles.imgControls}>
|
||||
{getKeys(ratios).map(ratio => {
|
||||
const {hint, Icon, ...props} = ratios[ratio]
|
||||
const labelIconSize = getLabelIconSize(ratio)
|
||||
const isSelected = aspectRatio === ratio
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={ratio}
|
||||
onPress={() => {
|
||||
onPressRatio(ratio)
|
||||
}}
|
||||
accessibilityLabel={ratio}
|
||||
accessibilityHint={hint}>
|
||||
<Icon
|
||||
size={labelIconSize}
|
||||
style={[styles.imgControl, isSelected ? s.blue3 : pal.text]}
|
||||
color={(isSelected ? s.blue3 : pal.text).color}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<Text
|
||||
type={isSelected ? 'xs-bold' : 'xs-medium'}
|
||||
style={[isSelected ? s.blue3 : pal.text, s.textCenter]}>
|
||||
{ratio}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
<View style={[styles.verticalSep, pal.border]} />
|
||||
<View style={styles.imgControls}>
|
||||
{adjustments.map(({label, hint, name, onPress}) => (
|
||||
<Pressable
|
||||
key={label}
|
||||
onPress={onPress}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={hint}
|
||||
style={styles.flipBtn}>
|
||||
<MaterialIcons
|
||||
name={name}
|
||||
size={label.startsWith('Flip') ? 22 : 24}
|
||||
style={[
|
||||
pal.text,
|
||||
label === 'Flip vertically'
|
||||
? styles.flipVertical
|
||||
: undefined,
|
||||
]}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.gap18]}>
|
||||
<TextInput
|
||||
testID="altTextImageInput"
|
||||
style={[styles.textArea, pal.border, pal.text]}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
multiline
|
||||
value={altText}
|
||||
onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
|
||||
placeholder="Image description"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
accessibilityLabel="Image alt text"
|
||||
accessibilityHint="Sets image alt text for screenreaders"
|
||||
accessibilityLabelledBy="imageAltText"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.btns}>
|
||||
<Pressable onPress={onPressCancel} accessibilityRole="button">
|
||||
<Text type="xl" style={pal.link}>
|
||||
Cancel
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={onPressSave} accessibilityRole="button">
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[styles.btn]}>
|
||||
<Text type="xl-medium" style={s.white}>
|
||||
Done
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 18,
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 12,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
},
|
||||
gap18: {
|
||||
gap: 18,
|
||||
},
|
||||
|
||||
title: {
|
||||
fontWeight: 'bold',
|
||||
fontSize: 24,
|
||||
},
|
||||
|
||||
textArea: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
paddingTop: 10,
|
||||
paddingHorizontal: 12,
|
||||
fontSize: 16,
|
||||
height: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
|
||||
btns: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
btn: {
|
||||
borderRadius: 4,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
|
||||
verticalSep: {
|
||||
borderLeftWidth: 1,
|
||||
},
|
||||
|
||||
imgControls: {
|
||||
flexDirection: 'row',
|
||||
gap: 5,
|
||||
},
|
||||
imgControl: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 40,
|
||||
},
|
||||
flipVertical: {
|
||||
transform: [{rotate: '90deg'}],
|
||||
},
|
||||
flipBtn: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
imgEditor: {
|
||||
maxWidth: '100%',
|
||||
},
|
||||
imgContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 425,
|
||||
width: 425,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
borderStyle: 'solid',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
})
|
|
@ -15,6 +15,7 @@ import * as DeleteAccountModal from './DeleteAccount'
|
|||
import * as RepostModal from './Repost'
|
||||
import * as CropImageModal from './crop-image/CropImage.web'
|
||||
import * as AltTextImageModal from './AltImage'
|
||||
import * as EditImageModal from './EditImage'
|
||||
import * as ChangeHandleModal from './ChangeHandle'
|
||||
import * as WaitlistModal from './Waitlist'
|
||||
import * as InviteCodesModal from './InviteCodes'
|
||||
|
@ -47,7 +48,7 @@ function Modal({modal}: {modal: ModalIface}) {
|
|||
}
|
||||
|
||||
const onPressMask = () => {
|
||||
if (modal.name === 'crop-image') {
|
||||
if (modal.name === 'crop-image' || modal.name === 'edit-image') {
|
||||
return // dont close on mask presses during crop
|
||||
}
|
||||
store.shell.closeModal()
|
||||
|
@ -88,6 +89,8 @@ function Modal({modal}: {modal: ModalIface}) {
|
|||
element = <ContentLanguagesSettingsModal.Component />
|
||||
} else if (modal.name === 'alt-text-image') {
|
||||
element = <AltTextImageModal.Component {...modal} />
|
||||
} else if (modal.name === 'edit-image') {
|
||||
element = <EditImageModal.Component {...modal} />
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue