Image editor mobile layout update (#613)

* Image editor mobile layout update

* Minor viewport fix
zio/stable
Ollie H 2023-05-15 14:54:14 -07:00 committed by GitHub
parent aa786068cf
commit e2055dfb78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 219 additions and 256 deletions

View File

@ -52,16 +52,14 @@ export class GalleryModel {
} }
async edit(image: ImageModel) { async edit(image: ImageModel) {
if (!isNative) { if (isNative) {
this.crop(image)
} else {
this.rootStore.shell.openModal({ this.rootStore.shell.openModal({
name: 'edit-image', name: 'edit-image',
image, image,
gallery: this, gallery: this,
}) })
return
} else {
this.crop(image)
} }
} }

View File

@ -13,12 +13,12 @@ import {compressAndResizeImageForPost} from 'lib/media/manip'
// Cases to consider: ExternalEmbed // Cases to consider: ExternalEmbed
export interface ImageManipulationAttributes { export interface ImageManipulationAttributes {
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
rotate?: number rotate?: number
scale?: number scale?: number
position?: Position position?: Position
flipHorizontal?: boolean flipHorizontal?: boolean
flipVertical?: boolean flipVertical?: boolean
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
} }
export class ImageModel implements RNImage { export class ImageModel implements RNImage {
@ -34,14 +34,14 @@ export class ImageModel implements RNImage {
scaledHeight: number = POST_IMG_MAX.height scaledHeight: number = POST_IMG_MAX.height
// Web manipulation // Web manipulation
aspectRatio?: ImageManipulationAttributes['aspectRatio'] prev?: RNImage
position?: Position = undefined attributes: ImageManipulationAttributes = {
prev?: RNImage = undefined aspectRatio: '1:1',
rotation?: number = 0 scale: 1,
scale?: number = 1 flipHorizontal: false,
flipHorizontal?: boolean = false flipVertical: false,
flipVertical?: boolean = false rotate: 0,
}
prevAttributes: ImageManipulationAttributes = {} prevAttributes: ImageManipulationAttributes = {}
constructor(public rootStore: RootStoreModel, image: RNImage) { constructor(public rootStore: RootStoreModel, image: RNImage) {
@ -65,6 +65,25 @@ export class ImageModel implements RNImage {
// : MAX_IMAGE_SIZE_IN_BYTES / this.size // : MAX_IMAGE_SIZE_IN_BYTES / this.size
// } // }
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
this.attributes.aspectRatio = aspectRatio
}
setRotate(degrees: number) {
this.attributes.rotate = degrees
this.manipulate({})
}
flipVertical() {
this.attributes.flipVertical = !this.attributes.flipVertical
this.manipulate({})
}
flipHorizontal() {
this.attributes.flipHorizontal = !this.attributes.flipHorizontal
this.manipulate({})
}
get ratioMultipliers() { get ratioMultipliers() {
return { return {
'4:3': 4 / 3, '4:3': 4 / 3,
@ -162,33 +181,19 @@ export class ImageModel implements RNImage {
crop?: ActionCrop['crop'] crop?: ActionCrop['crop']
} & ImageManipulationAttributes, } & ImageManipulationAttributes,
) { ) {
const {aspectRatio, crop, flipHorizontal, flipVertical, rotate, scale} = const {aspectRatio, crop, position, scale} = attributes
attributes
const modifiers = [] const modifiers = []
if (flipHorizontal !== undefined) { if (this.attributes.flipHorizontal) {
this.flipHorizontal = flipHorizontal
}
if (flipVertical !== undefined) {
this.flipVertical = flipVertical
}
if (this.flipHorizontal) {
modifiers.push({flip: FlipType.Horizontal}) modifiers.push({flip: FlipType.Horizontal})
} }
if (this.flipVertical) { if (this.attributes.flipVertical) {
modifiers.push({flip: FlipType.Vertical}) modifiers.push({flip: FlipType.Vertical})
} }
// TODO: Fix rotation -- currently not functional if (this.attributes.rotate !== undefined) {
if (rotate !== undefined) { modifiers.push({rotate: this.attributes.rotate})
this.rotation = rotate
}
if (this.rotation !== undefined) {
modifiers.push({rotate: this.rotation})
} }
if (crop !== undefined) { if (crop !== undefined) {
@ -203,18 +208,21 @@ export class ImageModel implements RNImage {
} }
if (scale !== undefined) { if (scale !== undefined) {
this.scale = scale this.attributes.scale = scale
}
if (position !== undefined) {
this.attributes.position = position
} }
if (aspectRatio !== undefined) { if (aspectRatio !== undefined) {
this.aspectRatio = aspectRatio this.attributes.aspectRatio = aspectRatio
} }
const ratioMultiplier = this.ratioMultipliers[this.aspectRatio ?? '1:1'] const ratioMultiplier =
this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1']
// TODO: Ollie - should support up to 2000 but smaller images that scale const MAX_SIDE = 2000
// up need an updated compression factor calculation. Use 1000 for now.
const MAX_SIDE = 1000
const result = await ImageManipulator.manipulateAsync( const result = await ImageManipulator.manipulateAsync(
this.path, this.path,
@ -223,7 +231,7 @@ export class ImageModel implements RNImage {
{resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}}, {resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}},
], ],
{ {
compress: 0.7, // TODO: revisit compression calculation compress: 0.9,
format: SaveFormat.JPEG, format: SaveFormat.JPEG,
}, },
) )
@ -238,16 +246,12 @@ export class ImageModel implements RNImage {
}) })
} }
resetCompressed() {
this.manipulate({})
}
previous() { previous() {
this.compressed = this.prev this.compressed = this.prev
this.attributes = this.prevAttributes
const {flipHorizontal, flipVertical, rotate, position, scale} =
this.prevAttributes
this.scale = scale
this.rotation = rotate
this.flipHorizontal = flipHorizontal
this.flipVertical = flipVertical
this.position = position
} }
} }

View File

@ -18,148 +18,114 @@ import {Slider} from '@miblanchard/react-native-slider'
import {MaterialIcons} from '@expo/vector-icons' import {MaterialIcons} from '@expo/vector-icons'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {getKeys} from 'lib/type-assertions' import {getKeys} from 'lib/type-assertions'
import {isDesktopWeb} from 'platform/detection'
export const snapPoints = ['80%'] export const snapPoints = ['80%']
const RATIOS = {
'4:3': {
Icon: RectWideIcon,
},
'1:1': {
Icon: SquareIcon,
},
'3:4': {
Icon: RectTallIcon,
},
None: {
label: 'None',
Icon: MaterialIcons,
name: 'do-not-disturb-alt',
},
} as const
type AspectRatio = keyof typeof RATIOS
interface Props { interface Props {
image: ImageModel image: ImageModel
gallery: GalleryModel gallery: GalleryModel
} }
// This is only used for desktop web
export const Component = observer(function ({image, gallery}: Props) { export const Component = observer(function ({image, gallery}: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores()
const {shell} = store
const theme = useTheme() const theme = useTheme()
const winDim = useWindowDimensions() const store = useStores()
const windowDimensions = useWindowDimensions()
const [altText, setAltText] = useState(image.altText) const {
const [aspectRatio, setAspectRatio] = useState<AspectRatio>( aspectRatio,
image.aspectRatio ?? 'None', // rotate = 0
) } = image.attributes
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 editorRef = useRef<ImageEditor>(null)
const [scale, setScale] = useState<number>(image.attributes.scale ?? 1)
const imgEditorStyles = useMemo(() => { const [position, setPosition] = useState<Position | undefined>(
const dim = Math.min(425, winDim.width - 24) image.attributes.position,
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],
) )
const [altText, setAltText] = useState('')
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(() => { const onFlipHorizontal = useCallback(() => {
setFlipHorizontal(!flipHorizontal) image.flipHorizontal()
image.manipulate({flipHorizontal}) }, [image])
}, [flipHorizontal, image])
const onFlipVertical = useCallback(() => { const onFlipVertical = useCallback(() => {
setFlipVertical(!flipVertical) image.flipVertical()
image.manipulate({flipVertical}) }, [image])
}, [flipVertical, image])
// const onSetRotate = useCallback(
// (direction: 'left' | 'right') => {
// const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360
// image.setRotate(rotation)
// },
// [rotate, image],
// )
const onSetRatio = useCallback(
(ratio: AspectRatio) => {
image.setRatio(ratio)
},
[image],
)
const adjustments = useMemo( const adjustments = useMemo(
() => () => [
[ // {
// { // name: 'rotate-left' as const,
// name: 'rotate-left', // label: 'Rotate left',
// label: 'Rotate left', // onPress: () => {
// hint: 'Rotate image left', // onSetRotate('left')
// onPress: () => { // },
// const rotate = (rotation - 90) % 360 // },
// setRotation(rotate) // {
// image.manipulate({rotate}) // name: 'rotate-right' as const,
// }, // label: 'Rotate right',
// }, // onPress: () => {
// { // onSetRotate('right')
// name: 'rotate-right', // },
// label: 'Rotate right', // },
// hint: 'Rotate image right', {
// onPress: () => { name: 'flip' as const,
// const rotate = (rotation + 90) % 360 label: 'Flip horizontal',
// setRotation(rotate) onPress: onFlipHorizontal,
// image.manipulate({rotate}) },
// }, {
// }, name: 'flip' as const,
{ label: 'Flip vertically',
name: 'flip', onPress: onFlipVertical,
label: 'Flip horizontal', },
hint: 'Flip image horizontally', ],
onPress: onFlipHorizontal,
},
{
name: 'flip',
label: 'Flip vertically',
hint: 'Flip image vertically',
onPress: onFlipVertical,
},
] as const,
[onFlipHorizontal, onFlipVertical], [onFlipHorizontal, onFlipVertical],
) )
useEffect(() => { useEffect(() => {
image.prev = image.compressed image.prev = image.compressed
setIsEditing(true) image.prevAttributes = image.attributes
image.resetCompressed()
}, [image]) }, [image])
const onCloseModal = useCallback(() => { const onCloseModal = useCallback(() => {
shell.closeModal() store.shell.closeModal()
setIsEditing(false) }, [store.shell])
}, [shell])
const onPressCancel = useCallback(async () => { const onPressCancel = useCallback(async () => {
await gallery.previous(image) await gallery.previous(image)
@ -184,25 +150,12 @@ export const Component = observer(function ({image, gallery}: Props) {
...(position !== undefined ? {position} : {}), ...(position !== undefined ? {position} : {}),
} }
: {}), : {}),
...manipulationAttributes,
aspectRatio,
}) })
image.prevAttributes = manipulationAttributes image.prev = image.compressed
image.prevAttributes = image.attributes
onCloseModal() onCloseModal()
}, [ }, [altText, image, position, scale, onCloseModal])
altText,
aspectRatio,
image,
manipulationAttributes,
position,
scale,
onCloseModal,
])
const onPressRatio = useCallback((as: AspectRatio) => {
setAspectRatio(as)
}, [])
const getLabelIconSize = useCallback((as: AspectRatio) => { const getLabelIconSize = useCallback((as: AspectRatio) => {
switch (as) { switch (as) {
@ -220,40 +173,55 @@ export const Component = observer(function ({image, gallery}: Props) {
return null return null
} }
const {width, height} = image.getDisplayDimensions( const computedWidth =
aspectRatio, windowDimensions.width > 500 ? 410 : windowDimensions.width - 80
imgEditorStyles.width, const sideLength = isDesktopWeb ? 300 : computedWidth
)
const dimensions = image.getDisplayDimensions(aspectRatio, sideLength)
const imgContainerStyles = {width: sideLength, height: sideLength}
const imgControlStyles = {
alignItems: 'center' as const,
flexDirection: isDesktopWeb ? ('row' as const) : ('column' as const),
gap: isDesktopWeb ? 5 : 0,
}
return ( return (
<View testID="editImageModal" style={[pal.view, styles.container, s.flex1]}> <View testID="editImageModal" style={[pal.view, styles.container, s.flex1]}>
<Text style={[styles.title, pal.text]}>Edit image</Text> <Text style={[styles.title, pal.text]}>Edit image</Text>
<View> <View style={[styles.gap18, s.flexRow]}>
<View style={[styles.imgContainer, imgEditorStyles, pal.borderDark]}> <View>
<ImageEditor <View
ref={editorRef} style={[styles.imgContainer, pal.borderDark, imgContainerStyles]}>
style={styles.imgEditor} <ImageEditor
image={isEditing ? image.compressed.path : image.path} ref={editorRef}
width={width} style={styles.imgEditor}
height={height} image={image.compressed.path}
scale={scale} scale={scale}
border={0} border={0}
position={position} position={position}
onPositionChange={setPosition} onPositionChange={setPosition}
{...dimensions}
/>
</View>
<Slider
value={scale}
onValueChange={(v: number | number[]) =>
setScale(Array.isArray(v) ? v[0] : v)
}
minimumValue={1}
maximumValue={3}
/> />
</View> </View>
<Slider <View>
value={scale} {isDesktopWeb ? (
onValueChange={(v: number | number[]) => <Text type="sm-bold" style={pal.text}>
setScale(Array.isArray(v) ? v[0] : v) Ratios
} </Text>
minimumValue={1} ) : null}
maximumValue={3} <View style={imgControlStyles}>
/> {getKeys(RATIOS).map(ratio => {
<View style={[s.flexRow, styles.gap18]}> const {Icon, ...props} = RATIOS[ratio]
<View style={styles.imgControls}>
{getKeys(ratios).map(ratio => {
const {hint, Icon, ...props} = ratios[ratio]
const labelIconSize = getLabelIconSize(ratio) const labelIconSize = getLabelIconSize(ratio)
const isSelected = aspectRatio === ratio const isSelected = aspectRatio === ratio
@ -261,10 +229,10 @@ export const Component = observer(function ({image, gallery}: Props) {
<Pressable <Pressable
key={ratio} key={ratio}
onPress={() => { onPress={() => {
onPressRatio(ratio) onSetRatio(ratio)
}} }}
accessibilityLabel={ratio} accessibilityLabel={ratio}
accessibilityHint={hint}> accessibilityHint="">
<Icon <Icon
size={labelIconSize} size={labelIconSize}
style={[styles.imgControl, isSelected ? s.blue3 : pal.text]} style={[styles.imgControl, isSelected ? s.blue3 : pal.text]}
@ -281,18 +249,22 @@ export const Component = observer(function ({image, gallery}: Props) {
) )
})} })}
</View> </View>
<View style={[styles.verticalSep, pal.border]} /> {isDesktopWeb ? (
<View style={styles.imgControls}> <Text type="sm-bold" style={[pal.text, styles.subsection]}>
{adjustments.map(({label, hint, name, onPress}) => ( Transformations
</Text>
) : null}
<View style={imgControlStyles}>
{adjustments.map(({label, name, onPress}) => (
<Pressable <Pressable
key={label} key={label}
onPress={onPress} onPress={onPress}
accessibilityLabel={label} accessibilityLabel={label}
accessibilityHint={hint} accessibilityHint=""
style={styles.flipBtn}> style={styles.flipBtn}>
<MaterialIcons <MaterialIcons
name={name} name={name}
size={label.startsWith('Flip') ? 22 : 24} size={label?.startsWith('Flip') ? 22 : 24}
style={[ style={[
pal.text, pal.text,
label === 'Flip vertically' label === 'Flip vertically'
@ -305,7 +277,10 @@ export const Component = observer(function ({image, gallery}: Props) {
</View> </View>
</View> </View>
</View> </View>
<View style={[styles.gap18]}> <View style={[styles.gap18, styles.bottomSection, pal.border]}>
<Text type="sm-bold" style={pal.text} nativeID="alt-text">
Accessibility
</Text>
<TextInput <TextInput
testID="altTextImageInput" testID="altTextImageInput"
style={[styles.textArea, pal.border, pal.text]} style={[styles.textArea, pal.border, pal.text]}
@ -313,11 +288,9 @@ export const Component = observer(function ({image, gallery}: Props) {
multiline multiline
value={altText} value={altText}
onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
placeholder="Image description" accessibilityLabel="Alt text"
placeholderTextColor={pal.colors.textLight} accessibilityHint=""
accessibilityLabel="Image alt text" accessibilityLabelledBy="alt-text"
accessibilityHint="Sets image alt text for screenreaders"
accessibilityLabelledBy="imageAltText"
/> />
</View> </View>
<View style={styles.btns}> <View style={styles.btns}>
@ -345,30 +318,16 @@ export const Component = observer(function ({image, gallery}: Props) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
gap: 18, gap: 18,
paddingVertical: 18, paddingHorizontal: isDesktopWeb ? undefined : 16,
paddingHorizontal: 12,
height: '100%', height: '100%',
width: '100%', width: '100%',
}, },
gap18: { subsection: {marginTop: 12},
gap: 18, gap18: {gap: 18},
},
title: { title: {
fontWeight: 'bold', fontWeight: 'bold',
fontSize: 24, fontSize: 24,
}, },
textArea: {
borderWidth: 1,
borderRadius: 6,
paddingTop: 10,
paddingHorizontal: 12,
fontSize: 16,
height: 100,
textAlignVertical: 'top',
},
btns: { btns: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -379,28 +338,12 @@ const styles = StyleSheet.create({
paddingVertical: 8, paddingVertical: 8,
paddingHorizontal: 24, paddingHorizontal: 24,
}, },
verticalSep: {
borderLeftWidth: 1,
},
imgControls: {
flexDirection: 'row',
gap: 5,
},
imgControl: { imgControl: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
height: 40, height: 40,
}, },
flipVertical: {
transform: [{rotate: '90deg'}],
},
flipBtn: {
paddingHorizontal: 4,
paddingVertical: 8,
},
imgEditor: { imgEditor: {
maxWidth: '100%', maxWidth: '100%',
}, },
@ -408,11 +351,29 @@ const styles = StyleSheet.create({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
height: 425,
width: 425,
borderWidth: 1, borderWidth: 1,
borderRadius: 8,
borderStyle: 'solid', borderStyle: 'solid',
overflow: 'hidden', marginBottom: 4,
},
flipVertical: {
transform: [{rotate: '90deg'}],
},
flipBtn: {
paddingHorizontal: 4,
paddingVertical: 8,
},
textArea: {
borderWidth: 1,
borderRadius: 6,
paddingTop: 10,
paddingHorizontal: 12,
fontSize: 16,
height: 100,
textAlignVertical: 'top',
maxHeight: isDesktopWeb ? undefined : 50,
},
bottomSection: {
borderTopWidth: 1,
paddingTop: 18,
}, },
}) })