From b0ebb6c9d17f9f6f78bf13fd2a0ba89d83a7c2a8 Mon Sep 17 00:00:00 2001 From: Ollie H Date: Tue, 9 May 2023 12:55:44 -0700 Subject: [PATCH] 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 --- package.json | 1 + src/lib/type-assertions.ts | 3 + src/state/models/media/gallery.ts | 30 +- src/state/models/media/image.ts | 177 +++++++++- src/state/models/ui/shell.ts | 8 + src/view/com/composer/photos/Gallery.tsx | 8 +- src/view/com/modals/AltImage.tsx | 1 - src/view/com/modals/EditImage.tsx | 418 +++++++++++++++++++++++ src/view/com/modals/Modal.web.tsx | 5 +- yarn.lock | 7 + 10 files changed, 642 insertions(+), 16 deletions(-) create mode 100644 src/lib/type-assertions.ts create mode 100644 src/view/com/modals/EditImage.tsx diff --git a/package.json b/package.json index 56b0366d..3f35f9bd 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "expo-dev-client": "~2.1.1", "expo-device": "~5.2.1", "expo-image": "^1.2.1", + "expo-image-manipulator": "^11.1.1", "expo-image-picker": "~14.1.1", "expo-localization": "~14.1.1", "expo-media-library": "~15.2.3", diff --git a/src/lib/type-assertions.ts b/src/lib/type-assertions.ts new file mode 100644 index 00000000..6b5db512 --- /dev/null +++ b/src/lib/type-assertions.ts @@ -0,0 +1,3 @@ +export const getKeys = Object.keys as ( + obj: T, +) => Array diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts index 97b1ac1d..86bf8a31 100644 --- a/src/state/models/media/gallery.ts +++ b/src/state/models/media/gallery.ts @@ -5,6 +5,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker' import {openPicker} from 'lib/media/picker' import {getImageDim} from 'lib/media/manip' import {getDataUriSize} from 'lib/media/util' +import {isNative} from 'platform/detection' export class GalleryModel { images: ImageModel[] = [] @@ -37,7 +38,12 @@ export class GalleryModel { // Temporarily enforce uniqueness but can eventually also use index if (!this.images.some(i => i.path === image_.path)) { const image = new ImageModel(this.rootStore, image_) - await image.compress() + + if (!isNative) { + await image.manipulate({}) + } else { + await image.compress() + } runInAction(() => { this.images.push(image) @@ -45,6 +51,20 @@ export class GalleryModel { } } + async edit(image: ImageModel) { + if (!isNative) { + this.rootStore.shell.openModal({ + name: 'edit-image', + image, + gallery: this, + }) + + return + } else { + this.crop(image) + } + } + async paste(uri: string) { if (this.size >= 4) { return @@ -65,8 +85,8 @@ export class GalleryModel { }) } - setAltText(image: ImageModel) { - image.setAltText() + setAltText(image: ImageModel, altText: string) { + image.setAltText(altText) } crop(image: ImageModel) { @@ -78,6 +98,10 @@ export class GalleryModel { this.images.splice(index, 1) } + async previous(image: ImageModel) { + image.previous() + } + async pick() { const images = await openPicker(this.rootStore, { multiple: true, diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index dcd47665..ff464a5a 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -1,13 +1,26 @@ import {Image as RNImage} from 'react-native-image-crop-picker' import {RootStoreModel} from 'state/index' -import {compressAndResizeImageForPost} from 'lib/media/manip' import {makeAutoObservable, runInAction} from 'mobx' -import {openCropper} from 'lib/media/picker' import {POST_IMG_MAX} from 'lib/constants' -import {scaleDownDimensions} from 'lib/media/util' +import * as ImageManipulator from 'expo-image-manipulator' +import {getDataUriSize, scaleDownDimensions} from 'lib/media/util' +import {openCropper} from 'lib/media/picker' +import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator' +import {Position} from 'react-avatar-editor' +import {compressAndResizeImageForPost} from 'lib/media/manip' // TODO: EXIF embed // Cases to consider: ExternalEmbed + +export interface ImageManipulationAttributes { + rotate?: number + scale?: number + position?: Position + flipHorizontal?: boolean + flipVertical?: boolean + aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' +} + export class ImageModel implements RNImage { path: string mime = 'image/jpeg' @@ -20,6 +33,17 @@ export class ImageModel implements RNImage { scaledWidth: number = POST_IMG_MAX.width scaledHeight: number = POST_IMG_MAX.height + // Web manipulation + aspectRatio?: ImageManipulationAttributes['aspectRatio'] + position?: Position = undefined + prev?: RNImage = undefined + rotation?: number = 0 + scale?: number = 1 + flipHorizontal?: boolean = false + flipVertical?: boolean = false + + prevAttributes: ImageManipulationAttributes = {} + constructor(public rootStore: RootStoreModel, image: RNImage) { makeAutoObservable(this, { rootStore: false, @@ -32,12 +56,55 @@ export class ImageModel implements RNImage { this.calcScaledDimensions() } + // TODO: Revisit compression factor due to updated sizing with zoom + // get compressionFactor() { + // const MAX_IMAGE_SIZE_IN_BYTES = 976560 + + // return this.size < MAX_IMAGE_SIZE_IN_BYTES + // ? 1 + // : MAX_IMAGE_SIZE_IN_BYTES / this.size + // } + + get ratioMultipliers() { + return { + '4:3': 4 / 3, + '1:1': 1, + '3:4': 3 / 4, + None: this.width / this.height, + } + } + + getDisplayDimensions( + as: ImageManipulationAttributes['aspectRatio'] = '1:1', + maxSide: number, + ) { + const ratioMultiplier = this.ratioMultipliers[as] + + if (ratioMultiplier === 1) { + return { + height: maxSide, + width: maxSide, + } + } + + if (ratioMultiplier < 1) { + return { + width: maxSide * ratioMultiplier, + height: maxSide, + } + } + + return { + width: maxSide, + height: maxSide / ratioMultiplier, + } + } + calcScaledDimensions() { const {width, height} = scaleDownDimensions( {width: this.width, height: this.height}, POST_IMG_MAX, ) - this.scaledWidth = width this.scaledHeight = height } @@ -46,6 +113,7 @@ export class ImageModel implements RNImage { this.altText = altText } + // Only for mobile async crop() { try { const cropped = await openCropper(this.rootStore, { @@ -55,15 +123,13 @@ export class ImageModel implements RNImage { width: this.scaledWidth, height: this.scaledHeight, }) - runInAction(() => { this.cropped = cropped + this.compress() }) } catch (err) { this.rootStore.log.error('Failed to crop photo', err) } - - this.compress() } async compress() { @@ -74,6 +140,8 @@ export class ImageModel implements RNImage { : {width: this.width, height: this.height}, POST_IMG_MAX, ) + + // TODO: Revisit this - currently iOS uses this as well const compressed = await compressAndResizeImageForPost({ ...(this.cropped === undefined ? this : this.cropped), width, @@ -87,4 +155,99 @@ export class ImageModel implements RNImage { this.rootStore.log.error('Failed to compress photo', err) } } + + // Web manipulation + async manipulate( + attributes: { + crop?: ActionCrop['crop'] + } & ImageManipulationAttributes, + ) { + const {aspectRatio, crop, flipHorizontal, flipVertical, rotate, scale} = + attributes + const modifiers = [] + + if (flipHorizontal !== undefined) { + this.flipHorizontal = flipHorizontal + } + + if (flipVertical !== undefined) { + this.flipVertical = flipVertical + } + + if (this.flipHorizontal) { + modifiers.push({flip: FlipType.Horizontal}) + } + + if (this.flipVertical) { + modifiers.push({flip: FlipType.Vertical}) + } + + // TODO: Fix rotation -- currently not functional + if (rotate !== undefined) { + this.rotation = rotate + } + + if (this.rotation !== undefined) { + modifiers.push({rotate: this.rotation}) + } + + if (crop !== undefined) { + modifiers.push({ + crop: { + originX: crop.originX * this.width, + originY: crop.originY * this.height, + height: crop.height * this.height, + width: crop.width * this.width, + }, + }) + } + + if (scale !== undefined) { + this.scale = scale + } + + if (aspectRatio !== undefined) { + this.aspectRatio = aspectRatio + } + + const ratioMultiplier = this.ratioMultipliers[this.aspectRatio ?? '1:1'] + + // TODO: Ollie - should support up to 2000 but smaller images that scale + // up need an updated compression factor calculation. Use 1000 for now. + const MAX_SIDE = 1000 + + const result = await ImageManipulator.manipulateAsync( + this.path, + [ + ...modifiers, + {resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}}, + ], + { + compress: 0.7, // TODO: revisit compression calculation + format: SaveFormat.JPEG, + }, + ) + + runInAction(() => { + this.compressed = { + mime: 'image/jpeg', + path: result.uri, + size: getDataUriSize(result.uri), + ...result, + } + }) + } + + previous() { + this.compressed = this.prev + + const {flipHorizontal, flipVertical, rotate, position, scale} = + this.prevAttributes + + this.scale = scale + this.rotation = rotate + this.flipHorizontal = flipHorizontal + this.flipVertical = flipVertical + this.position = position + } } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 4a55c23a..67f8e16d 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -5,6 +5,7 @@ import {ProfileModel} from '../content/profile' import {isObj, hasProp} from 'lib/type-guards' import {Image as RNImage} from 'react-native-image-crop-picker' import {ImageModel} from '../media/image' +import {GalleryModel} from '../media/gallery' export interface ConfirmModal { name: 'confirm' @@ -37,6 +38,12 @@ export interface ReportAccountModal { did: string } +export interface EditImageModal { + name: 'edit-image' + image: ImageModel + gallery: GalleryModel +} + export interface CropImageModal { name: 'crop-image' uri: string @@ -102,6 +109,7 @@ export type Modal = // Posts | AltTextImageModal | CropImageModal + | EditImageModal | ServerInputModal | RepostModal diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 1aa0aef7..accd9680 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -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) { { handleEditPhoto(image) }} diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx index 0359359c..07270d55 100644 --- a/src/view/com/modals/AltImage.tsx +++ b/src/view/com/modals/AltImage.tsx @@ -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]) diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx new file mode 100644 index 00000000..4a5d9bfd --- /dev/null +++ b/src/view/com/modals/EditImage.tsx @@ -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( + image.aspectRatio ?? 'None', + ) + const [flipHorizontal, setFlipHorizontal] = useState( + image.flipHorizontal ?? false, + ) + const [flipVertical, setFlipVertical] = useState( + image.flipVertical ?? false, + ) + + // TODO: doesn't seem to be working correctly with crop + // const [rotation, setRotation] = useState(image.rotation ?? 0) + const [scale, setScale] = useState(image.scale ?? 1) + const [position, setPosition] = useState() + const [isEditing, setIsEditing] = useState(false) + const editorRef = useRef(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 ( + + Edit image + + + + + + setScale(Array.isArray(v) ? v[0] : v) + } + minimumValue={1} + maximumValue={3} + /> + + + {getKeys(ratios).map(ratio => { + const {hint, Icon, ...props} = ratios[ratio] + const labelIconSize = getLabelIconSize(ratio) + const isSelected = aspectRatio === ratio + + return ( + { + onPressRatio(ratio) + }} + accessibilityLabel={ratio} + accessibilityHint={hint}> + + + + {ratio} + + + ) + })} + + + + {adjustments.map(({label, hint, name, onPress}) => ( + + + + ))} + + + + + 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" + /> + + + + + Cancel + + + + + + Done + + + + + + ) +}) + +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', + }, +}) diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 9dcc8fa7..c9f2c495 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -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 = } else if (modal.name === 'alt-text-image') { element = + } else if (modal.name === 'edit-image') { + element = } else { return null } diff --git a/yarn.lock b/yarn.lock index bf556972..8c9583e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8713,6 +8713,13 @@ expo-image-loader@~4.1.0: resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-4.1.1.tgz#efadbb17de1861106864820194900f336dd641b6" integrity sha512-ciEHVokU0f6w0eTxdRxLCio6tskMsjxWIoV92+/ZD37qePUJYMfEphPhu1sruyvMBNR8/j5iyOvPFVGTfO8oxA== +expo-image-manipulator@^11.1.1: + version "11.1.1" + resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-11.1.1.tgz#bb54df80e98abc9798876e3f70596a5b880168c9" + integrity sha512-W9LfJK/IL7EhhkkC1JQnEX/1S9B09rcGasJiQjXc2s1bEsrQnqXvXEv7shUW8b/L8rE+ynf+XvvDE+YIDL7oFg== + dependencies: + expo-image-loader "~4.1.0" + expo-image-picker@~14.1.1: version "14.1.1" resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-14.1.1.tgz#181f1348ba6a43df7b87cee4a601d45c79b7c2d7"