From b9516202fa17325a3d54e54372ddd56149be129c Mon Sep 17 00:00:00 2001 From: Mary <148872143+mary-ext@users.noreply.github.com> Date: Tue, 24 Sep 2024 23:27:40 +0700 Subject: [PATCH] Revamp image editor (#5462) * new image editor * Rm react-avatar-editor --------- Co-authored-by: Dan Abramov --- package.json | 3 +- src/lib/media/picker.web.tsx | 4 +- src/lib/media/types.ts | 5 +- src/state/modals/index.tsx | 2 + .../com/composer/photos/EditImageDialog.tsx | 14 ++ .../composer/photos/EditImageDialog.web.tsx | 105 ++++++++ src/view/com/composer/photos/Gallery.tsx | 34 +-- src/view/com/modals/CropImage.web.tsx | 145 +++++++++++ src/view/com/modals/Modal.web.tsx | 2 +- .../com/modals/crop-image/CropImage.web.tsx | 228 ------------------ .../com/modals/crop-image/cropImageUtil.ts | 13 - src/view/com/util/UserAvatar.tsx | 18 +- src/view/com/util/UserBanner.tsx | 15 +- yarn.lock | 23 +- 14 files changed, 318 insertions(+), 293 deletions(-) create mode 100644 src/view/com/composer/photos/EditImageDialog.tsx create mode 100644 src/view/com/composer/photos/EditImageDialog.web.tsx create mode 100644 src/view/com/modals/CropImage.web.tsx delete mode 100644 src/view/com/modals/crop-image/CropImage.web.tsx delete mode 100644 src/view/com/modals/crop-image/cropImageUtil.ts diff --git a/package.json b/package.json index 117fc0b190..e1c0f99d8e 100644 --- a/package.json +++ b/package.json @@ -167,9 +167,9 @@ "postinstall-postinstall": "^2.1.0", "psl": "^1.9.0", "react": "18.2.0", - "react-avatar-editor": "^13.0.0", "react-compiler-runtime": "file:./lib/react-compiler-runtime", "react-dom": "^18.2.0", + "react-image-crop": "^11.0.7", "react-keyed-flatten-children": "^3.0.0", "react-native": "0.74.1", "react-native-compressor": "^1.8.24", @@ -236,7 +236,6 @@ "@types/lodash.set": "^4.3.7", "@types/lodash.shuffle": "^4.2.7", "@types/psl": "^1.1.1", - "@types/react-avatar-editor": "^13.0.0", "@types/react-dom": "^18.2.18", "@types/react-responsive": "^8.0.5", "@types/react-test-renderer": "^17.0.1", diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index 8782e14570..a53ffc9614 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -18,9 +18,11 @@ export async function openCropper(opts: CropperOptions): Promise { name: 'crop-image', uri: opts.path, dimensions: - opts.height && opts.width + opts.width && opts.height ? {width: opts.width, height: opts.height} : undefined, + aspect: opts.webAspectRatio, + circular: opts.webCircularCrop, onSelect: (img?: RNImage) => { if (img) { resolve(img) diff --git a/src/lib/media/types.ts b/src/lib/media/types.ts index e6f442759f..ec94256ea1 100644 --- a/src/lib/media/types.ts +++ b/src/lib/media/types.ts @@ -18,4 +18,7 @@ export interface CameraOpts { cropperCircleOverlay?: boolean } -export type CropperOptions = Parameters[0] +export type CropperOptions = Parameters[0] & { + webAspectRatio?: number + webCircularCrop?: boolean +} diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 9bc96cf5e4..5be21dfd39 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -39,6 +39,8 @@ export interface CropImageModal { name: 'crop-image' uri: string dimensions?: {width: number; height: number} + aspect?: number + circular?: boolean onSelect: (img?: RNImage) => void } diff --git a/src/view/com/composer/photos/EditImageDialog.tsx b/src/view/com/composer/photos/EditImageDialog.tsx new file mode 100644 index 0000000000..4263587fd4 --- /dev/null +++ b/src/view/com/composer/photos/EditImageDialog.tsx @@ -0,0 +1,14 @@ +import React from 'react' + +import {ComposerImage} from '#/state/gallery' +import * as Dialog from '#/components/Dialog' + +export type EditImageDialogProps = { + control: Dialog.DialogOuterProps['control'] + image: ComposerImage + onChange: (next: ComposerImage) => void +} + +export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => { + return null +} diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx new file mode 100644 index 0000000000..0afb83ed96 --- /dev/null +++ b/src/view/com/composer/photos/EditImageDialog.web.tsx @@ -0,0 +1,105 @@ +import 'react-image-crop/dist/ReactCrop.css' + +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import ReactCrop, {PercentCrop} from 'react-image-crop' + +import { + ImageSource, + ImageTransformation, + manipulateImage, +} from '#/state/gallery' +import {atoms as a} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Text} from '#/components/Typography' +import {EditImageDialogProps} from './EditImageDialog' + +export const EditImageDialog = (props: EditImageDialogProps) => { + return ( + + + + ) +} + +const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { + const {_} = useLingui() + + const source = image.source + + const initialCrop = getInitialCrop(source, image.manips) + const [crop, setCrop] = React.useState(initialCrop) + + const isEmpty = !crop || (crop.width || crop.height) === 0 + const isNew = initialCrop ? true : !isEmpty + + const onPressSubmit = React.useCallback(async () => { + const result = await manipulateImage(image, { + crop: + crop && (crop.width || crop.height) !== 0 + ? { + originX: (crop.x * source.width) / 100, + originY: (crop.y * source.height) / 100, + width: (crop.width * source.width) / 100, + height: (crop.height * source.height) / 100, + } + : undefined, + }) + + onChange(result) + control.close() + }, [crop, image, source, control, onChange]) + + return ( + + + + + Edit image + + + + setCrop(percentCrop)} + className="ReactCrop--no-animate"> + + + + + + + + + ) +} + +const getInitialCrop = ( + source: ImageSource, + manips: ImageTransformation | undefined, +): PercentCrop | undefined => { + const initialArea = manips?.crop + + if (initialArea) { + return { + unit: '%', + x: (initialArea.originX / source.width) * 100, + y: (initialArea.originY / source.height) * 100, + width: (initialArea.width / source.width) * 100, + height: (initialArea.height / source.height) * 100, + } + } +} diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 83c1e3c809..369f08d745 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -21,6 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery' import {Text} from '#/view/com/util/text/Text' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' +import {EditImageDialog} from './EditImageDialog' import {ImageAltTextDialog} from './ImageAltTextDialog' const IMAGE_GAP = 8 @@ -144,12 +145,15 @@ const GalleryItem = ({ const t = useTheme() const altTextControl = Dialog.useDialogControl() + const editControl = Dialog.useDialogControl() const onImageEdit = () => { if (isNative) { cropImage(image).then(next => { onChange(next) }) + } else { + editControl.open() } } @@ -185,21 +189,15 @@ const GalleryItem = ({ - {isNative && ( - - - - )} + + + + + ) } diff --git a/src/view/com/modals/CropImage.web.tsx b/src/view/com/modals/CropImage.web.tsx new file mode 100644 index 0000000000..41ca306573 --- /dev/null +++ b/src/view/com/modals/CropImage.web.tsx @@ -0,0 +1,145 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {Image as RNImage} from 'react-native-image-crop-picker' +import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' +import {LinearGradient} from 'expo-linear-gradient' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import ReactCrop, {PercentCrop} from 'react-image-crop' + +import {usePalette} from '#/lib/hooks/usePalette' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {getDataUriSize} from '#/lib/media/util' +import {gradients, s} from '#/lib/styles' +import {useModalControls} from '#/state/modals' +import {Text} from '#/view/com/util/text/Text' + +export const snapPoints = ['0%'] + +export function Component({ + uri, + aspect, + circular, + onSelect, +}: { + uri: string + aspect?: number + circular?: boolean + onSelect: (img?: RNImage) => void +}) { + const pal = usePalette('default') + const {_} = useLingui() + + const {closeModal} = useModalControls() + const {isMobile} = useWebMediaQueries() + + const imageRef = React.useRef(null) + const [crop, setCrop] = React.useState() + + const isEmpty = !crop || (crop.width || crop.height) === 0 + + const onPressCancel = () => { + onSelect(undefined) + closeModal() + } + const onPressDone = async () => { + const img = imageRef.current! + + const result = await manipulateAsync( + uri, + isEmpty + ? [] + : [ + { + crop: { + originX: (crop.x * img.naturalWidth) / 100, + originY: (crop.y * img.naturalHeight) / 100, + width: (crop.width * img.naturalWidth) / 100, + height: (crop.height * img.naturalHeight) / 100, + }, + }, + ], + { + base64: true, + format: SaveFormat.JPEG, + }, + ) + + onSelect({ + path: result.uri, + mime: 'image/jpeg', + size: result.base64 !== undefined ? getDataUriSize(result.base64) : 0, + width: result.width, + height: result.height, + }) + + closeModal() + } + + return ( + + + setCrop(percentCrop)} + circularCrop={circular}> + + + + + + + Cancel + + + + + + + Done + + + + + + ) +} + +const styles = StyleSheet.create({ + cropper: { + marginLeft: 'auto', + marginRight: 'auto', + borderWidth: 1, + borderRadius: 4, + overflow: 'hidden', + alignItems: 'center', + }, + ctrls: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 10, + }, + btns: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 10, + }, + btn: { + borderRadius: 4, + paddingVertical: 8, + paddingHorizontal: 24, + }, +}) diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index c1024751f8..a2acc23bb9 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -12,7 +12,7 @@ import * as ChangeEmailModal from './ChangeEmail' import * as ChangeHandleModal from './ChangeHandle' import * as ChangePasswordModal from './ChangePassword' import * as CreateOrEditListModal from './CreateOrEditList' -import * as CropImageModal from './crop-image/CropImage.web' +import * as CropImageModal from './CropImage.web' import * as DeleteAccountModal from './DeleteAccount' import * as EditProfileModal from './EditProfile' import * as InviteCodesModal from './InviteCodes' diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx deleted file mode 100644 index 10cae2f174..0000000000 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {Image as RNImage} from 'react-native-image-crop-picker' -import {LinearGradient} from 'expo-linear-gradient' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {Slider} from '@miblanchard/react-native-slider' -import ImageEditor from 'react-avatar-editor' - -import {useModalControls} from '#/state/modals' -import {usePalette} from 'lib/hooks/usePalette' -import {RectTallIcon, RectWideIcon, SquareIcon} from 'lib/icons' -import {Dimensions} from 'lib/media/types' -import {getDataUriSize} from 'lib/media/util' -import {gradients, s} from 'lib/styles' -import {Text} from 'view/com/util/text/Text' -import {calculateDimensions} from './cropImageUtil' - -enum AspectRatio { - Square = 'square', - Wide = 'wide', - Tall = 'tall', - Custom = 'custom', -} - -const DIMS: Record = { - [AspectRatio.Square]: {width: 1000, height: 1000}, - [AspectRatio.Wide]: {width: 1000, height: 750}, - [AspectRatio.Tall]: {width: 750, height: 1000}, -} - -export const snapPoints = ['0%'] - -export function Component({ - uri, - dimensions, - onSelect, -}: { - uri: string - dimensions?: Dimensions - onSelect: (img?: RNImage) => void -}) { - const {closeModal} = useModalControls() - const pal = usePalette('default') - const {_} = useLingui() - const defaultAspectStyle = dimensions - ? AspectRatio.Custom - : AspectRatio.Square - const [as, setAs] = React.useState(defaultAspectStyle) - const [scale, setScale] = React.useState(1) - const editorRef = React.useRef(null) - const imageEditorWidth = dimensions ? dimensions.width : DIMS[as].width - const imageEditorHeight = dimensions ? dimensions.height : DIMS[as].height - - const doSetAs = (v: AspectRatio) => () => setAs(v) - - const onPressCancel = () => { - onSelect(undefined) - closeModal() - } - const onPressDone = () => { - const canvas = editorRef.current?.getImageScaledToCanvas() - if (canvas) { - const dataUri = canvas.toDataURL('image/jpeg') - onSelect({ - path: dataUri, - mime: 'image/jpeg', - size: getDataUriSize(dataUri), - width: imageEditorWidth, - height: imageEditorHeight, - }) - } else { - onSelect(undefined) - } - closeModal() - } - - let cropperStyle - if (as === AspectRatio.Square) { - cropperStyle = styles.cropperSquare - } else if (as === AspectRatio.Wide) { - cropperStyle = styles.cropperWide - } else if (as === AspectRatio.Tall) { - cropperStyle = styles.cropperTall - } else if (as === AspectRatio.Custom) { - const cropperDimensions = calculateDimensions( - 550, - imageEditorHeight, - imageEditorWidth, - ) - cropperStyle = { - width: cropperDimensions.width, - height: cropperDimensions.height, - } - } - - return ( - - - - - - - setScale(Array.isArray(v) ? v[0] : v) - } - minimumValue={1} - maximumValue={3} - containerStyle={styles.slider} - /> - {as === AspectRatio.Custom ? null : ( - <> - - - - - - - - - - - )} - - - - - Cancel - - - - - - - Done - - - - - - ) -} - -const styles = StyleSheet.create({ - cropper: { - marginLeft: 'auto', - marginRight: 'auto', - borderWidth: 1, - borderRadius: 4, - overflow: 'hidden', - }, - cropperSquare: { - width: 400, - height: 400, - }, - cropperWide: { - width: 400, - height: 300, - }, - cropperTall: { - width: 300, - height: 400, - }, - imageEditor: { - maxWidth: '100%', - }, - ctrls: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 10, - }, - slider: { - flex: 1, - marginRight: 10, - }, - btns: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 10, - }, - btn: { - borderRadius: 4, - paddingVertical: 8, - paddingHorizontal: 24, - }, -}) diff --git a/src/view/com/modals/crop-image/cropImageUtil.ts b/src/view/com/modals/crop-image/cropImageUtil.ts deleted file mode 100644 index 303d15ba5b..0000000000 --- a/src/view/com/modals/crop-image/cropImageUtil.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const calculateDimensions = ( - maxWidth: number, - originalHeight: number, - originalWidth: number, -) => { - const aspectRatio = originalWidth / originalHeight - const newHeight = maxWidth / aspectRatio - const newWidth = maxWidth - return { - width: newWidth, - height: newHeight, - } -} diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index b2f56c1385..76d9d1503e 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -8,17 +8,17 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {logger} from '#/logger' -import {usePalette} from 'lib/hooks/usePalette' +import {usePalette} from '#/lib/hooks/usePalette' import { useCameraPermission, usePhotoLibraryPermission, -} from 'lib/hooks/usePermissions' -import {makeProfileLink} from 'lib/routes/links' -import {colors} from 'lib/styles' -import {isAndroid, isNative, isWeb} from 'platform/detection' -import {precacheProfile} from 'state/queries/profile' -import {HighPriorityImage} from 'view/com/util/images/Image' +} from '#/lib/hooks/usePermissions' +import {makeProfileLink} from '#/lib/routes/links' +import {colors} from '#/lib/styles' +import {logger} from '#/logger' +import {isAndroid, isNative, isWeb} from '#/platform/detection' +import {precacheProfile} from '#/state/queries/profile' +import {HighPriorityImage} from '#/view/com/util/images/Image' import {tokens, useTheme} from '#/alf' import { Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, @@ -321,6 +321,8 @@ let EditableUserAvatar = ({ height: 1000, width: 1000, path: item.path, + webAspectRatio: 1, + webCircularCrop: true, }) onSelectNewAvatar(croppedImage) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 93ea32750d..13f4081fce 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -6,16 +6,16 @@ import {ModerationUI} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {logger} from '#/logger' -import {usePalette} from 'lib/hooks/usePalette' +import {usePalette} from '#/lib/hooks/usePalette' import { useCameraPermission, usePhotoLibraryPermission, -} from 'lib/hooks/usePermissions' -import {colors} from 'lib/styles' -import {useTheme} from 'lib/ThemeContext' -import {isAndroid, isNative} from 'platform/detection' -import {EventStopper} from 'view/com/util/EventStopper' +} from '#/lib/hooks/usePermissions' +import {colors} from '#/lib/styles' +import {useTheme} from '#/lib/ThemeContext' +import {logger} from '#/logger' +import {isAndroid, isNative} from '#/platform/detection' +import {EventStopper} from '#/view/com/util/EventStopper' import {tokens, useTheme as useAlfTheme} from '#/alf' import { Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, @@ -72,6 +72,7 @@ export function UserBanner({ path: items[0].path, width: 3000, height: 1000, + webAspectRatio: 3, }), ) } catch (e: any) { diff --git a/yarn.lock b/yarn.lock index 860b49daec..f3d6ae5fd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2570,7 +2570,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.22.5" -"@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.12.1", "@babel/plugin-transform-runtime@^7.16.4": +"@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.16.4": version "7.22.10" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.22.10.tgz#89eda6daf1d3af6f36fb368766553054c8d7cd46" integrity sha512-RchI7HePu1eu0CYNKHHHQdfenZcM4nz8rew5B1VWqeRKdcwW5aQ5HeG9eTUbWiAS1UrmHVLmoxTWHt3iLD/NhA== @@ -8262,13 +8262,6 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== -"@types/react-avatar-editor@^13.0.0": - version "13.0.0" - resolved "https://registry.yarnpkg.com/@types/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#5963e16c931746c47e478d669dd72d388b427393" - integrity sha512-5ymOayy6mfT35xTqzni7UjXvCNEg8/pH4pI5RenITp9PBc02KGTYjSV1WboXiQDYSh5KomLT0ngBLEAIhV1QoQ== - dependencies: - "@types/react" "*" - "@types/react-dom@^18.2.18": version "18.2.18" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.18.tgz#16946e6cd43971256d874bc3d0a72074bb8571dd" @@ -18935,15 +18928,6 @@ react-app-polyfill@^3.0.0: regenerator-runtime "^0.13.9" whatwg-fetch "^3.6.2" -react-avatar-editor@^13.0.0: - version "13.0.0" - resolved "https://registry.yarnpkg.com/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#55013625ee9ae715c1fe2dc553b8079994d8a5f2" - integrity sha512-0xw63MbRRQdDy7YI1IXU9+7tTFxYEFLV8CABvryYOGjZmXRTH2/UA0mafe57ns62uaEFX181kA4XlGlxCaeXKA== - dependencies: - "@babel/plugin-transform-runtime" "^7.12.1" - "@babel/runtime" "^7.12.5" - prop-types "^15.7.2" - "react-compiler-runtime@file:./lib/react-compiler-runtime": version "0.0.1" @@ -19003,6 +18987,11 @@ react-freeze@^1.0.0: resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.3.tgz#5e3ca90e682fed1d73a7cb50c2c7402b3e85618d" integrity sha512-ZnXwLQnGzrDpHBHiC56TXFXvmolPeMjTn1UOm610M4EXGzbEDR7oOIyS2ZiItgbs6eZc4oU/a0hpk8PrcKvv5g== +react-image-crop@^11.0.7: + version "11.0.7" + resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-11.0.7.tgz#25f3d37ccbb65a05d19d23b4740a5912835c741e" + integrity sha512-ZciKWHDYzmm366JDL18CbrVyjnjH0ojufGDmScfS4ZUqLHg4nm6ATY+K62C75W4ZRNt4Ii+tX0bSjNk9LQ2xzQ== + "react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.0.0, react-is@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"