From ba854ecc9a481a7ab4ee708eccbd25fb0e25d7b0 Mon Sep 17 00:00:00 2001 From: Mary Date: Tue, 7 May 2024 16:03:08 +0700 Subject: [PATCH 01/20] refactor: redo composer image state management --- src/lib/api/index.ts | 53 +--- src/state/gallery.ts | 285 ++++++++++++++++++ src/state/modals/index.tsx | 10 +- src/view/com/composer/Composer.tsx | 66 ++-- src/view/com/composer/ExternalEmbed.tsx | 2 +- src/view/com/composer/photos/Gallery.tsx | 278 ++++++++--------- .../com/composer/photos/OpenCameraBtn.tsx | 13 +- .../com/composer/photos/SelectPhotoBtn.tsx | 21 +- src/view/com/composer/useExternalLinkFetch.ts | 8 +- src/view/com/modals/AltImage.tsx | 24 +- src/view/shell/Composer.tsx | 19 +- 11 files changed, 538 insertions(+), 241 deletions(-) create mode 100644 src/state/gallery.ts diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index bc50f9cb3c..7285d5bc64 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -13,20 +13,19 @@ import { import {AtUri} from '@atproto/api' import {logger} from '#/logger' +import {ComposerImage, compressImage} from '#/state/gallery' import {ThreadgateSetting} from '#/state/queries/threadgate' import {isNetworkError} from 'lib/strings/errors' import {shortenLinks} from 'lib/strings/rich-text-manip' -import {isNative, isWeb} from 'platform/detection' -import {ImageModel} from 'state/models/media/image' +import {isWeb} from 'platform/detection' import {LinkMeta} from '../link-meta/link-meta' -import {safeDeleteAsync} from '../media/manip' export interface ExternalEmbedDraft { uri: string isLoading: boolean meta?: LinkMeta embed?: AppBskyEmbedRecord.Main - localThumb?: ImageModel + localThumb?: ComposerImage } export async function uploadBlob( @@ -56,7 +55,7 @@ interface PostOpts { cid: string } extLink?: ExternalEmbedDraft - images?: ImageModel[] + images?: ComposerImage[] labels?: string[] threadgate?: ThreadgateSetting[] onStateChange?: (state: string) => void @@ -113,18 +112,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) { const images: AppBskyEmbedImages.Image[] = [] for (const image of opts.images) { opts.onStateChange?.(`Uploading image #${images.length + 1}...`) + logger.debug(`Compressing image`) - await image.compress() - const path = image.compressed?.path ?? image.path - const {width, height} = image.compressed || image + const {path, width, height, mime} = await compressImage(image) + logger.debug(`Uploading image`) - const res = await uploadBlob(agent, path, 'image/jpeg') - if (isNative) { - safeDeleteAsync(path) - } + const res = await uploadBlob(agent, path, mime) + images.push({ image: res.data.blob, - alt: image.altText ?? '', + alt: image.alt, aspectRatio: {width, height}, }) } @@ -154,32 +151,10 @@ export async function post(agent: BskyAgent, opts: PostOpts) { let thumb if (opts.extLink.localThumb) { opts.onStateChange?.('Uploading link thumbnail...') - let encoding - if (opts.extLink.localThumb.mime) { - encoding = opts.extLink.localThumb.mime - } else if (opts.extLink.localThumb.path.endsWith('.png')) { - encoding = 'image/png' - } else if ( - opts.extLink.localThumb.path.endsWith('.jpeg') || - opts.extLink.localThumb.path.endsWith('.jpg') - ) { - encoding = 'image/jpeg' - } else { - logger.warn('Unexpected image format for thumbnail, skipping', { - thumbnail: opts.extLink.localThumb.path, - }) - } - if (encoding) { - const thumbUploadRes = await uploadBlob( - agent, - opts.extLink.localThumb.path, - encoding, - ) - thumb = thumbUploadRes.data.blob - if (isNative) { - safeDeleteAsync(opts.extLink.localThumb.path) - } - } + const {path, mime} = opts.extLink.localThumb.source + const res = await uploadBlob(agent, path, mime) + + thumb = res.data.blob } if (opts.quote) { diff --git a/src/state/gallery.ts b/src/state/gallery.ts new file mode 100644 index 0000000000..ef7dfc3278 --- /dev/null +++ b/src/state/gallery.ts @@ -0,0 +1,285 @@ +import uuid from 'react-native-uuid' +import { + cacheDirectory, + deleteAsync, + makeDirectoryAsync, + moveAsync, +} from 'expo-file-system' +import { + Action, + ActionCrop, + ActionExtent, + ActionFlip, + ActionResize, + ActionRotate, + manipulateAsync, + SaveFormat, +} from 'expo-image-manipulator' + +import {POST_IMG_MAX} from '#/lib/constants' +import {getImageDim} from '#/lib/media/manip' +import {openCropper} from '#/lib/media/picker' +import {getDataUriSize} from '#/lib/media/util' +import {isIOS, isNative} from '#/platform/detection' + +export type ImageTransformation = Partial< + ActionCrop & ActionExtent & ActionFlip & ActionResize & ActionRotate +> + +export type ImageSource = { + path: string + width: number + height: number + mime: string +} + +type ComposerImageBase = { + alt: string + source: ImageSource + compressed?: ImageSource +} +type ComposerImageWithoutTransformation = ComposerImageBase & { + transformed?: undefined +} +type ComposerImageWithTransformation = ComposerImageBase & { + transformed: ImageSource + transformations?: ImageTransformation +} + +export type ComposerImage = + | ComposerImageWithoutTransformation + | ComposerImageWithTransformation + +const imageCacheDirectory = isNative + ? joinPath(cacheDirectory!, 'bsky-composer') + : null + +export async function createComposerImage( + raw: ImageSource, +): Promise { + return { + alt: '', + source: { + path: await moveIfNecessary(raw.path), + width: raw.width, + height: raw.height, + mime: raw.mime, + }, + } +} + +export function createInitialImages( + uris: {uri: string; width: number; height: number}[] | undefined, +): ComposerImageWithoutTransformation[] { + if (uris === undefined) { + return [] + } + + return uris.map(({uri, width, height}) => { + return { + alt: '', + source: { + path: uri, + width: width, + height: height, + mime: 'image/jpeg', + }, + } + }) +} + +export async function pasteImage( + uri: string, +): Promise { + const {width, height} = await getImageDim(uri) + + return { + alt: '', + source: { + path: uri, + width: width, + height: height, + mime: 'image/jpeg', + }, + } +} + +export async function cropImage(img: ComposerImage): Promise { + if (!isNative) { + return img + } + + // NOTE + // on ios, react-native-image-crop-picker gives really bad quality + // without specifying width and height. on android, however, the + // crop stretches incorrectly if you do specify it. these are + // both separate bugs in the library. we deal with that by + // providing width & height for ios only + // -prf + + const source = img.source + const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) + + // @todo: we're always passing the original image here, does image-cropper + // allows for setting initial crop dimensions? -mary + try { + const cropped = await openCropper({ + mediaType: 'photo', + path: source.path, + freeStyleCropEnabled: true, + ...(isIOS ? {width: w, height: h} : {}), + }) + + return { + alt: img.alt, + source: source, + transformed: { + path: await moveIfNecessary(cropped.path), + width: cropped.width, + height: cropped.height, + mime: cropped.mime, + }, + } + } catch (e) { + if (e instanceof Error && e.message.includes('User cancelled')) { + return img + } + + throw e + } +} + +export async function manipulateImage( + img: ComposerImage, + trans: ImageTransformation, +): Promise { + const rawActions: (Action | undefined)[] = [ + trans.extent && {extent: trans.extent}, + trans.flip && {flip: trans.flip}, + trans.rotate !== undefined ? {rotate: trans.rotate} : undefined, + trans.resize && {resize: trans.resize}, + trans.crop && {crop: trans.crop}, + ] + + const actions = rawActions.filter((a): a is Action => a !== undefined) + + if (actions.length === 0) { + if (img.transformed === undefined) { + return img + } + + return {alt: img.alt, source: img.source} + } + + const source = img.source + const result = await manipulateAsync(source.path, actions, { + format: SaveFormat.PNG, + }) + + return { + alt: img.alt, + source: img.source, + transformed: { + path: await moveIfNecessary(result.uri), + width: result.width, + height: result.height, + mime: 'image/png', + }, + transformations: trans, + } +} + +export function resetImageManipulation( + img: ComposerImage, +): ComposerImageWithoutTransformation { + if (img.transformed !== undefined) { + return {alt: img.alt, source: img.source} + } + + return img +} + +export async function compressImage(img: ComposerImage): Promise { + const source = img.transformed || img.source + + const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) + + for (let i = 10; i > 0; i--) { + // Float precision + const factor = i / 10 + + const res = await manipulateAsync( + source.path, + [{resize: {width: w, height: h}}], + { + compress: factor, + format: SaveFormat.JPEG, + base64: true, + }, + ) + + const base64 = res.base64 + + if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) { + return { + path: await moveIfNecessary(res.uri), + width: res.width, + height: res.height, + mime: 'image/jpeg', + } + } + + if (imageCacheDirectory) { + await deleteAsync(res.uri) + } + } + + throw new Error(`Unable to compress image`) +} + +async function moveIfNecessary(from: string) { + if (cacheDirectory && from.startsWith(cacheDirectory!)) { + const to = joinPath(imageCacheDirectory!, uuid.v4() + '') + await moveAsync({from, to}) + + return to + } + + return from +} + +/** Purge files that were created to accomodate image manipulation */ +export async function purgeTemporaryImageFiles() { + if (imageCacheDirectory) { + await deleteAsync(imageCacheDirectory, {idempotent: true}) + await makeDirectoryAsync(imageCacheDirectory) + } +} + +function joinPath(a: string, b: string) { + if (a.endsWith('/')) { + if (b.startsWith('/')) { + return a.slice(0, -1) + b + } + return a + b + } else if (b.startsWith('/')) { + return a + b + } + return a + '/' + b +} + +function containImageRes( + w: number, + h: number, + {width: maxW, height: maxH}: {width: number; height: number}, +): [width: number, height: number] { + let scale = 1 + + if (w > maxW || h > maxH) { + scale = w < h ? maxW / w : maxH / h + w = Math.floor(w * scale) + h = Math.floor(h * scale) + } + + return [w, h] +} diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index cf82bcd075..b35ae57e0e 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -3,8 +3,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker' import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {GalleryModel} from '#/state/models/media/gallery' -import {ImageModel} from '#/state/models/media/image' +import {ComposerImage} from '../gallery' import {ThreadgateSetting} from '../queries/threadgate' export interface EditProfileModal { @@ -40,8 +39,8 @@ export interface ListAddRemoveUsersModal { export interface EditImageModal { name: 'edit-image' - image: ImageModel - gallery: GalleryModel + image: ComposerImage + onChange: (next: ComposerImage) => void } export interface CropImageModal { @@ -53,7 +52,8 @@ export interface CropImageModal { export interface AltTextImageModal { name: 'alt-text-image' - image: ImageModel + image: ComposerImage + onChange: (next: ComposerImage) => void } export interface DeleteAccountModal { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 61c339024b..be685799ba 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -16,7 +16,6 @@ import {RichText} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {observer} from 'mobx-react-lite' import { createGIFDescription, @@ -26,6 +25,7 @@ import {LikelyType} from '#/lib/link-meta/link-meta' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {emitPostCreated} from '#/state/events' +import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery' import {useModals} from '#/state/modals' import {useRequireAltTextEnabled} from '#/state/preferences' import { @@ -50,7 +50,6 @@ import {shortenLinks} from 'lib/strings/rich-text-manip' import {colors, gradients, s} from 'lib/styles' import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection' import {useDialogStateControlContext} from 'state/dialogs' -import {GalleryModel} from 'state/models/media/gallery' import {ComposerOpts} from 'state/shell/composer' import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' import {atoms as a} from '#/alf' @@ -77,8 +76,10 @@ import {TextInput, TextInputRef} from './text-input/TextInput' import {ThreadgateBtn} from './threadgate/ThreadgateBtn' import {useExternalLinkFetch} from './useExternalLinkFetch' +const MAX_IMAGES = 4 + type Props = ComposerOpts -export const ComposePost = observer(function ComposePost({ +export const ComposePost = ({ replyTo, onPost, quote: initQuote, @@ -86,7 +87,7 @@ export const ComposePost = observer(function ComposePost({ openPicker, text: initText, imageUris: initImageUris, -}: Props) { +}: Props) => { const {currentAccount} = useSession() const {getAgent} = useAgent() const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) @@ -123,6 +124,7 @@ export const ComposePost = observer(function ComposePost({ const graphemeLength = useMemo(() => { return shortenLinks(richtext).graphemeLength }, [richtext]) + const [quote, setQuote] = useState( initQuote, ) @@ -130,10 +132,11 @@ export const ComposePost = observer(function ComposePost({ const [extGif, setExtGif] = useState() const [labels, setLabels] = useState([]) const [threadgate, setThreadgate] = useState([]) - const gallery = useMemo( - () => new GalleryModel(initImageUris), - [initImageUris], + + const [images, setImages] = useState(() => + createInitialImages(initImageUris), ) + const onClose = useCallback(() => { closeComposer() }, [closeComposer]) @@ -149,7 +152,7 @@ export const ComposePost = observer(function ComposePost({ ) const onPressCancel = useCallback(() => { - if (graphemeLength > 0 || !gallery.isEmpty) { + if (graphemeLength > 0 || images.length !== 0) { closeAllDialogs() if (Keyboard) { Keyboard.dismiss() @@ -160,7 +163,7 @@ export const ComposePost = observer(function ComposePost({ } }, [ graphemeLength, - gallery.isEmpty, + images.length, closeAllDialogs, discardPromptControl, onClose, @@ -207,18 +210,27 @@ export const ComposePost = observer(function ComposePost({ [extLink, setExtLink], ) + const onImageAdd = useCallback( + (next: ComposerImage[]) => { + setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length))) + }, + [setImages], + ) + const onPhotoPasted = useCallback( async (uri: string) => { track('Composer:PastedPhotos') - await gallery.paste(uri) + + const res = await pasteImage(uri) + onImageAdd([res]) }, - [gallery, track], + [track, onImageAdd], ) const isAltTextRequiredAndMissing = useMemo(() => { if (!requireAltTextEnabled) return false - if (gallery.needsAltText) return true + if (images.some(img => img.alt === '')) return true if (extGif) { if (!extLink?.meta?.description) return true @@ -226,7 +238,7 @@ export const ComposePost = observer(function ComposePost({ if (!parsedAlt.isPreferred) return true } return false - }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled]) + }, [images, extLink, extGif, requireAltTextEnabled]) const onPressPublish = async () => { if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { @@ -241,7 +253,7 @@ export const ComposePost = observer(function ComposePost({ if ( richtext.text.trim().length === 0 && - gallery.isEmpty && + images.length === 0 && !extLink && !quote ) { @@ -261,7 +273,7 @@ export const ComposePost = observer(function ComposePost({ await apilib.post(getAgent(), { rawText: richtext.text, replyTo: replyTo?.uri, - images: gallery.images, + images: images, quote, extLink, labels, @@ -273,7 +285,7 @@ export const ComposePost = observer(function ComposePost({ } catch (e: any) { logger.error(e, { message: `Composer: create post failed`, - hasImages: gallery.size > 0, + hasImages: images.length > 0, }) if (extLink) { @@ -289,7 +301,7 @@ export const ComposePost = observer(function ComposePost({ } finally { if (postUri) { logEvent('post:create', { - imageCount: gallery.size, + imageCount: images.length, isReply: replyTo != null, hasLink: extLink != null, hasQuote: quote != null, @@ -298,7 +310,7 @@ export const ComposePost = observer(function ComposePost({ }) } track('Create Post', { - imageCount: gallery.size, + imageCount: images.length, }) if (replyTo && replyTo.uri) track('Post:Reply') } @@ -323,8 +335,8 @@ export const ComposePost = observer(function ComposePost({ ? _(msg`Write your reply`) : _(msg`What's up?`) - const canSelectImages = gallery.size < 4 && !extLink - const hasMedia = gallery.size > 0 || Boolean(extLink) + const canSelectImages = images.length < MAX_IMAGES && !extLink + const hasMedia = images.length > 0 || Boolean(extLink) const onEmojiButtonPress = useCallback(() => { openPicker?.(textInput.current?.getCursorPosition()) @@ -509,8 +521,8 @@ export const ComposePost = observer(function ComposePost({ /> - - {gallery.isEmpty && extLink && ( + + {images.length === 0 && extLink && ( - - + + ) -}) +} const styles = StyleSheet.create({ outer: { diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index b81065e99d..680d3c1fcd 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -30,7 +30,7 @@ export const ExternalEmbed = ({ title: link.meta?.title ?? link.uri, uri: link.uri, description: link.meta?.description ?? '', - thumb: link.localThumb?.path, + thumb: link.localThumb?.source.path, }, [link], ) diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 7ff1b7b9ab..886dc193e3 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -1,29 +1,29 @@ -import React, {useState} from 'react' +import React from 'react' import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {Image} from 'expo-image' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {observer} from 'mobx-react-lite' +import {ComposerImage, cropImage} from '#/state/gallery' import {useModalControls} from '#/state/modals' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {Dimensions} from 'lib/media/types' import {colors, s} from 'lib/styles' import {isNative} from 'platform/detection' -import {GalleryModel} from 'state/models/media/gallery' import {Text} from 'view/com/util/text/Text' import {useTheme} from '#/alf' const IMAGE_GAP = 8 interface GalleryProps { - gallery: GalleryModel + images: ComposerImage[] + onChange: (next: ComposerImage[]) => void } -export const Gallery = (props: GalleryProps) => { - const [containerInfo, setContainerInfo] = useState() +export let Gallery = (props: GalleryProps): React.ReactNode => { + const [containerInfo, setContainerInfo] = React.useState() const onLayout = (evt: LayoutChangeEvent) => { const {width, height} = evt.nativeEvent.layout @@ -41,15 +41,13 @@ export const Gallery = (props: GalleryProps) => { ) } +Gallery = React.memo(Gallery) interface GalleryInnerProps extends GalleryProps { containerInfo: Dimensions } -const GalleryInner = observer(function GalleryImpl({ - gallery, - containerInfo, -}: GalleryInnerProps) { +const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => { const {_} = useLingui() const {isMobile} = useWebMediaQueries() const {openModal} = useModalControls() @@ -57,10 +55,11 @@ const GalleryInner = observer(function GalleryImpl({ let side: number - if (gallery.size === 1) { + if (images.length === 1) { side = 250 } else { - side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size + side = + (containerInfo.width - IMAGE_GAP * (images.length - 1)) / images.length } const imageStyle = { @@ -68,150 +67,153 @@ const GalleryInner = observer(function GalleryImpl({ width: side, } - const isOverflow = isMobile && gallery.size > 2 + const isOverflow = isMobile && images.length > 2 const altTextControlStyle = isOverflow - ? { - left: 4, - bottom: 4, - } - : !isMobile && gallery.size < 3 - ? { - left: 8, - top: 8, - } - : { - left: 4, - top: 4, - } + ? {left: 4, bottom: 4} + : !isMobile && images.length < 3 + ? {left: 8, top: 8} + : {left: 4, top: 4} const imageControlsStyle = { display: 'flex' as const, flexDirection: 'row' as const, position: 'absolute' as const, ...(isOverflow - ? { - top: 4, - right: 4, - gap: 4, - } - : !isMobile && gallery.size < 3 - ? { - top: 8, - right: 8, - gap: 8, - } - : { - top: 4, - right: 4, - gap: 4, - }), + ? {top: 4, right: 4, gap: 4} + : !isMobile && images.length < 3 + ? {top: 8, right: 8, gap: 8} + : {top: 4, right: 4, gap: 4}), zIndex: 1, } - return !gallery.isEmpty ? ( + return images.length !== 0 ? ( <> - {gallery.images.map(image => ( - - { - Keyboard.dismiss() - openModal({ - name: 'alt-text-image', - image, - }) - }} - style={[styles.altTextControl, altTextControlStyle]}> - {image.altText.length > 0 ? ( - - ) : ( - - )} - - ALT - - - - { - if (isNative) { - gallery.crop(image) - } else { - openModal({ - name: 'edit-image', - image, - gallery, - }) - } - }} - style={styles.imageControl}> - - - gallery.remove(image)} - style={styles.imageControl}> - - - - { - Keyboard.dismiss() - openModal({ - name: 'alt-text-image', - image, - }) - }} - style={styles.altTextHiddenRegion} - /> + {images.map((image, index) => { + const onRemove = () => { + const next = images.slice() + next.splice(index, 1) - - - ))} + onChange(next) + } + + const onAltText = () => { + Keyboard.dismiss() + openModal({ + name: 'alt-text-image', + image, + onChange(next) { + onChange( + images.map(i => (i.source === image.source ? next : i)), + ) + }, + }) + } + + const onEdit = () => { + if (isNative) { + cropImage(image).then(next => { + if (next === image) { + return + } + + onChange( + images.map(i => (i.source === image.source ? next : i)), + ) + }) + } else { + // openModal({ + // name: 'edit-image', + // image, + // gallery, + // }) + } + } + + return ( + + + {image.alt.length !== 0 ? ( + + ) : ( + + )} + + ALT + + + + {isNative && ( + + + + )} + + + + + + + + + ) + })} ) : null -}) +} export function AltTextReminder() { const t = useTheme() diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 8f9152e34d..33eefd33f4 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -9,17 +9,17 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions' import {openCamera} from '#/lib/media/picker' import {logger} from '#/logger' import {isMobileWeb, isNative} from '#/platform/detection' -import {GalleryModel} from '#/state/models/media/gallery' +import {ComposerImage, createComposerImage} from '#/state/gallery' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera' type Props = { - gallery: GalleryModel disabled?: boolean + onAdd: (next: ComposerImage[]) => void } -export function OpenCameraBtn({gallery, disabled}: Props) { +export function OpenCameraBtn({disabled, onAdd}: Props) { const {track} = useAnalytics() const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() @@ -48,13 +48,16 @@ export function OpenCameraBtn({gallery, disabled}: Props) { if (mediaPermissionRes) { await MediaLibrary.createAssetAsync(img.path) } - gallery.add(img) + + const res = await createComposerImage(img) + + onAdd([res]) } catch (err: any) { // ignore logger.warn('Error using camera', {error: err}) } }, [ - gallery, + onAdd, track, requestCameraAccessIfNeeded, mediaPermissionRes, diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index 747653fc8d..95d2df022c 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -5,18 +5,20 @@ import {useLingui} from '@lingui/react' import {useAnalytics} from '#/lib/analytics/analytics' import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' +import {openPicker} from '#/lib/media/picker' import {isNative} from '#/platform/detection' -import {GalleryModel} from '#/state/models/media/gallery' +import {ComposerImage, createComposerImage} from '#/state/gallery' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image' type Props = { - gallery: GalleryModel + size: number disabled?: boolean + onAdd: (next: ComposerImage[]) => void } -export function SelectPhotoBtn({gallery, disabled}: Props) { +export function SelectPhotoBtn({size, disabled, onAdd}: Props) { const {track} = useAnalytics() const {_} = useLingui() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() @@ -29,8 +31,17 @@ export function SelectPhotoBtn({gallery, disabled}: Props) { return } - gallery.pick() - }, [track, requestPhotoAccessIfNeeded, gallery]) + const images = await openPicker({ + selectionLimit: 4 - size, + allowsMultipleSelection: true, + }) + + const results = await Promise.all( + images.map(img => createComposerImage(img)), + ) + + onAdd(results) + }, [track, requestPhotoAccessIfNeeded, size, onAdd]) return ( + + + {/* below the text input to force tab order */} + + + Add ALT text + + + + + + + + + ) +} From c6c81465da2403ee84b2e0a9de5209188fc11860 Mon Sep 17 00:00:00 2001 From: Mary Date: Tue, 7 May 2024 22:07:37 +0700 Subject: [PATCH 05/20] refactor: remove old image alt dialog --- src/state/modals/index.tsx | 7 -- src/view/com/modals/AltImage.tsx | 187 ------------------------------ src/view/com/modals/Modal.tsx | 8 -- src/view/com/modals/Modal.web.tsx | 10 +- 4 files changed, 2 insertions(+), 210 deletions(-) delete mode 100644 src/view/com/modals/AltImage.tsx diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index b35ae57e0e..5f4ff4f555 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -50,12 +50,6 @@ export interface CropImageModal { onSelect: (img?: RNImage) => void } -export interface AltTextImageModal { - name: 'alt-text-image' - image: ComposerImage - onChange: (next: ComposerImage) => void -} - export interface DeleteAccountModal { name: 'delete-account' } @@ -151,7 +145,6 @@ export type Modal = | ListAddRemoveUsersModal // Posts - | AltTextImageModal | CropImageModal | EditImageModal | RepostModal diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx deleted file mode 100644 index b5654f15d9..0000000000 --- a/src/view/com/modals/AltImage.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, {useCallback, useMemo, useState} from 'react' -import { - ImageStyle, - ScrollView as RNScrollView, - StyleSheet, - TextInput as RNTextInput, - TouchableOpacity, - useWindowDimensions, - View, -} from 'react-native' -import {Image} from 'expo-image' -import {LinearGradient} from 'expo-linear-gradient' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {ComposerImage} from '#/state/gallery' -import {useModalControls} from '#/state/modals' -import {MAX_ALT_TEXT} from 'lib/constants' -import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible' -import {usePalette} from 'lib/hooks/usePalette' -import {enforceLen} from 'lib/strings/helpers' -import {gradients, s} from 'lib/styles' -import {useTheme} from 'lib/ThemeContext' -import {isWeb} from 'platform/detection' -import {Text} from '../util/text/Text' -import {ScrollView, TextInput} from './util' - -export const snapPoints = ['100%'] - -interface Props { - image: ComposerImage - onChange: (next: ComposerImage) => void -} - -export function Component({image, onChange}: Props) { - const pal = usePalette('default') - const theme = useTheme() - const {_} = useLingui() - const [altText, setAltText] = useState(image.alt) - const windim = useWindowDimensions() - const {closeModal} = useModalControls() - const inputRef = React.useRef(null) - const scrollViewRef = React.useRef(null) - const keyboardShown = useIsKeyboardVisible() - - // Autofocus hack when we open the modal. We have to wait for the animation to complete first - React.useEffect(() => { - setTimeout(() => { - inputRef.current?.focus() - }, 500) - }, []) - - // We'd rather be at the bottom here so that we can easily dismiss the modal instead of having to scroll - // (especially on android, it acts weird) - React.useEffect(() => { - if (keyboardShown[0]) { - scrollViewRef.current?.scrollToEnd() - } - }, [keyboardShown]) - - const imageStyles = useMemo(() => { - const maxWidth = isWeb ? 450 : windim.width - const source = image.transformed ?? image.source - - if (source.height > source.width) { - return { - resizeMode: 'contain', - width: '100%', - aspectRatio: 1, - borderRadius: 8, - } - } - return { - width: '100%', - height: (maxWidth / source.width) * source.height, - borderRadius: 8, - } - }, [image, windim]) - - const onUpdate = useCallback( - (v: string) => { - v = enforceLen(v, MAX_ALT_TEXT) - setAltText(v) - }, - [setAltText], - ) - - const onPressSave = useCallback(() => { - closeModal() - onChange({...image, alt: altText.trim()}) - }, [closeModal, image, altText, onChange]) - - return ( - - - - - - - - - - - Done - - - - - - - ) -} - -const styles = StyleSheet.create({ - scrollContainer: { - flex: 1, - height: '100%', - paddingHorizontal: isWeb ? 0 : 12, - paddingVertical: isWeb ? 0 : 24, - }, - scrollInner: { - gap: 12, - paddingTop: isWeb ? 0 : 12, - }, - imageContainer: { - borderRadius: 8, - }, - textArea: { - borderWidth: 1, - borderRadius: 6, - paddingTop: 10, - paddingHorizontal: 12, - fontSize: 16, - height: 100, - textAlignVertical: 'top', - }, - button: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: '100%', - borderRadius: 32, - padding: 10, - }, - buttonControls: { - gap: 8, - paddingBottom: isWeb ? 0 : 50, - }, -}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 6524813015..4f44016312 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -7,8 +7,6 @@ import {useModalControls, useModals} from '#/state/modals' import {usePalette} from 'lib/hooks/usePalette' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import * as AddAppPassword from './AddAppPasswords' -import * as AltImageModal from './AltImage' -import * as EditImageModal from './AltImage' import * as ChangeEmailModal from './ChangeEmail' import * as ChangeHandleModal from './ChangeHandle' import * as ChangePasswordModal from './ChangePassword' @@ -82,12 +80,6 @@ export function ModalsContainer() { } else if (activeModal?.name === 'threadgate') { snapPoints = ThreadgateModal.snapPoints element = - } else if (activeModal?.name === 'alt-text-image') { - snapPoints = AltImageModal.snapPoints - element = - } else if (activeModal?.name === 'edit-image') { - snapPoints = AltImageModal.snapPoints - element = } else if (activeModal?.name === 'change-handle') { snapPoints = ChangeHandleModal.snapPoints element = diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index f95c748111..4d1b8f4483 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -8,7 +8,6 @@ import {useModalControls, useModals} from '#/state/modals' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import * as AddAppPassword from './AddAppPasswords' -import * as AltTextImageModal from './AltImage' import * as ChangeEmailModal from './ChangeEmail' import * as ChangeHandleModal from './ChangeHandle' import * as ChangePasswordModal from './ChangePassword' @@ -56,11 +55,7 @@ function Modal({modal}: {modal: ModalIface}) { } const onPressMask = () => { - if ( - modal.name === 'crop-image' || - modal.name === 'edit-image' || - modal.name === 'alt-text-image' - ) { + if (modal.name === 'crop-image' || modal.name === 'edit-image') { return // dont close on mask presses during crop } closeModal() @@ -99,9 +94,8 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'post-languages-settings') { element = - } else if (modal.name === 'alt-text-image') { - element = } else if (modal.name === 'edit-image') { + // @ts-expect-error: todo element = } else if (modal.name === 'verify-email') { element = From a0da3a2a4557f8b9ee23b6d0b49974978eac0f64 Mon Sep 17 00:00:00 2001 From: Mary Date: Tue, 7 May 2024 22:18:34 +0700 Subject: [PATCH 06/20] fix: compressed prop isn't actually being used --- src/state/gallery.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/state/gallery.ts b/src/state/gallery.ts index feec267f53..db0f8ee861 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -36,7 +36,6 @@ export type ImageSource = { type ComposerImageBase = { alt: string source: ImageSource - compressed?: ImageSource } type ComposerImageWithoutTransformation = ComposerImageBase & { transformed?: undefined From d2f757876910e6150dffb0446f4734f258d54c7b Mon Sep 17 00:00:00 2001 From: Mary Date: Tue, 7 May 2024 22:41:11 +0700 Subject: [PATCH 07/20] fix: properly detect the mime type being passed --- src/lib/media/picker.shared.ts | 5 +++-- src/state/gallery.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts index 96e82e4c72..d739faf13e 100644 --- a/src/lib/media/picker.shared.ts +++ b/src/lib/media/picker.shared.ts @@ -3,8 +3,9 @@ import { launchImageLibraryAsync, MediaTypeOptions, } from 'expo-image-picker' -import {getDataUriSize} from './util' + import * as Toast from 'view/com/util/Toast' +import {getDataUriSize} from './util' export async function openPicker(opts?: ImagePickerOptions) { const response = await launchImageLibraryAsync({ @@ -26,7 +27,7 @@ export async function openPicker(opts?: ImagePickerOptions) { return false }) .map(image => ({ - mime: 'image/jpeg', + mime: image.mimeType || 'image/jpeg', height: image.height, width: image.width, path: image.uri, diff --git a/src/state/gallery.ts b/src/state/gallery.ts index db0f8ee861..a376ae641a 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -91,6 +91,7 @@ export async function pasteImage( uri: string, ): Promise { const {width, height} = await getImageDim(uri) + const match = /^data:(.+?);/.exec(uri) return { alt: '', @@ -98,7 +99,7 @@ export async function pasteImage( path: uri, width: width, height: height, - mime: 'image/jpeg', + mime: match ? match[1] : 'image/jpeg', }, } } From 1a8239a0c3a341292e859c5a4e999376ccab72d0 Mon Sep 17 00:00:00 2001 From: Mary Date: Wed, 8 May 2024 07:48:45 +0700 Subject: [PATCH 08/20] refactor: move purging outside of effect --- src/state/shell/composer.tsx | 6 +++++- src/view/shell/Composer.tsx | 5 ----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx index 5b4e505439..bbccfdd845 100644 --- a/src/state/shell/composer.tsx +++ b/src/state/shell/composer.tsx @@ -1,11 +1,13 @@ import React from 'react' import { + AppBskyActorDefs, AppBskyEmbedRecord, AppBskyRichtextFacet, ModerationDecision, - AppBskyActorDefs, } from '@atproto/api' + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {purgeTemporaryImageFiles} from '../gallery' export interface ComposerOptsPostRef { uri: string @@ -62,6 +64,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const closeComposer = useNonReactiveCallback(() => { let wasOpen = !!state + + purgeTemporaryImageFiles() setState(undefined) return wasOpen }) diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index 8148fe4bcb..96f296f507 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -1,7 +1,6 @@ import React, {useEffect} from 'react' import {Animated, Easing, Platform, StyleSheet, View} from 'react-native' -import {purgeTemporaryImageFiles} from '#/state/gallery' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {usePalette} from 'lib/hooks/usePalette' import {useComposerState} from 'state/shell/composer' @@ -14,10 +13,6 @@ export const Composer = ({winHeight}: {winHeight: number}) => { useEffect(() => { if (state) { - if (process.env.NODE_ENV !== 'development') { - purgeTemporaryImageFiles() - } - Animated.timing(initInterp, { toValue: 1, duration: 300, From 70030b32d8ebc68f5c0e69efe2aeb885e3af0b1e Mon Sep 17 00:00:00 2001 From: Mary Date: Wed, 8 May 2024 08:07:40 +0700 Subject: [PATCH 09/20] refactor: keyed gallery items --- src/state/gallery.ts | 16 ++++++++++++---- src/view/com/composer/photos/Gallery.tsx | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/state/gallery.ts b/src/state/gallery.ts index a376ae641a..ed0c5d8229 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -15,6 +15,7 @@ import { manipulateAsync, SaveFormat, } from 'expo-image-manipulator' +import {nanoid} from 'nanoid/non-secure' import {POST_IMG_MAX} from '#/lib/constants' import {getImageDim} from '#/lib/media/manip' @@ -26,13 +27,17 @@ export type ImageTransformation = Partial< ActionCrop & ActionExtent & ActionFlip & ActionResize & ActionRotate > -export type ImageSource = { +export type ImageMeta = { path: string width: number height: number mime: string } +export type ImageSource = ImageMeta & { + id: string +} + type ComposerImageBase = { alt: string source: ImageSource @@ -41,7 +46,7 @@ type ComposerImageWithoutTransformation = ComposerImageBase & { transformed?: undefined } type ComposerImageWithTransformation = ComposerImageBase & { - transformed: ImageSource + transformed: ImageMeta transformations?: ImageTransformation } @@ -54,11 +59,12 @@ const imageCacheDirectory = isNative : null export async function createComposerImage( - raw: ImageSource, + raw: ImageMeta, ): Promise { return { alt: '', source: { + id: nanoid(), path: await moveIfNecessary(raw.path), width: raw.width, height: raw.height, @@ -78,6 +84,7 @@ export function createInitialImages( return { alt: '', source: { + id: nanoid(), path: uri, width: width, height: height, @@ -96,6 +103,7 @@ export async function pasteImage( return { alt: '', source: { + id: nanoid(), path: uri, width: width, height: height, @@ -199,7 +207,7 @@ export function resetImageManipulation( return img } -export async function compressImage(img: ComposerImage): Promise { +export async function compressImage(img: ComposerImage): Promise { const source = img.transformed || img.source const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 250b549258..3840e77ee1 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -91,7 +91,7 @@ const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => { {images.map((image, index) => { return ( Date: Wed, 8 May 2024 08:09:37 +0700 Subject: [PATCH 10/20] refactor: don't bother reversing the order --- .../composer/photos/ImageAltTextDialog.tsx | 86 +++++++++---------- 1 file changed, 41 insertions(+), 45 deletions(-) diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx index b029d8ceb6..1985b76707 100644 --- a/src/view/com/composer/photos/ImageAltTextDialog.tsx +++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx @@ -69,55 +69,51 @@ const ImageAltTextInner = ({ - - - - - Descriptive alt text - - - setAltText(text)} - value={altText} - multiline - numberOfLines={3} - autoFocus - /> - - - - + + + Add ALT text + - {/* below the text input to force tab order */} + + + + + + - - Add ALT text - - - - + Descriptive alt text + + + setAltText(text)} + value={altText} + multiline + numberOfLines={3} + autoFocus /> - + + ) From a8f6058da3bad636fc290a2cee5dd475922f5252 Mon Sep 17 00:00:00 2001 From: Mary Date: Wed, 8 May 2024 09:22:36 +0700 Subject: [PATCH 11/20] refactor: remove gallery mobx model and old modals --- src/state/modals/index.tsx | 17 - src/state/models/media/gallery.ts | 110 ----- src/state/models/media/image.e2e.ts | 146 ------- src/state/models/media/image.ts | 299 ------------- src/view/com/composer/photos/Gallery.tsx | 6 - src/view/com/modals/EditImage.tsx | 402 ------------------ src/view/com/modals/Modal.web.tsx | 10 - .../com/modals/crop-image/CropImage.web.tsx | 228 ---------- .../com/modals/crop-image/cropImageUtil.ts | 13 - 9 files changed, 1231 deletions(-) delete mode 100644 src/state/models/media/gallery.ts delete mode 100644 src/state/models/media/image.e2e.ts delete mode 100644 src/state/models/media/image.ts delete mode 100644 src/view/com/modals/EditImage.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/src/state/modals/index.tsx b/src/state/modals/index.tsx index 5f4ff4f555..8298d282bc 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -1,9 +1,7 @@ import React from 'react' -import {Image as RNImage} from 'react-native-image-crop-picker' import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {ComposerImage} from '../gallery' import {ThreadgateSetting} from '../queries/threadgate' export interface EditProfileModal { @@ -37,19 +35,6 @@ export interface ListAddRemoveUsersModal { ) => void } -export interface EditImageModal { - name: 'edit-image' - image: ComposerImage - onChange: (next: ComposerImage) => void -} - -export interface CropImageModal { - name: 'crop-image' - uri: string - dimensions?: {width: number; height: number} - onSelect: (img?: RNImage) => void -} - export interface DeleteAccountModal { name: 'delete-account' } @@ -145,8 +130,6 @@ export type Modal = | ListAddRemoveUsersModal // Posts - | CropImageModal - | EditImageModal | RepostModal | SelfLabelModal | ThreadgateModal diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts deleted file mode 100644 index 9c8c13010d..0000000000 --- a/src/state/models/media/gallery.ts +++ /dev/null @@ -1,110 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {ImageModel} from './image' -import {Image as RNImage} from 'react-native-image-crop-picker' -import {openPicker} from 'lib/media/picker' -import {getImageDim} from 'lib/media/manip' - -interface InitialImageUri { - uri: string - width: number - height: number -} - -export class GalleryModel { - images: ImageModel[] = [] - - constructor(uris?: {uri: string; width: number; height: number}[]) { - makeAutoObservable(this) - - if (uris) { - this.addFromUris(uris) - } - } - - get isEmpty() { - return this.size === 0 - } - - get size() { - return this.images.length - } - - get needsAltText() { - return this.images.some(image => image.altText.trim() === '') - } - - *add(image_: Omit) { - if (this.size >= 4) { - return - } - - // Temporarily enforce uniqueness but can eventually also use index - if (!this.images.some(i => i.path === image_.path)) { - const image = new ImageModel(image_) - - // Initial resize - image.manipulate({}) - this.images.push(image) - } - } - - async paste(uri: string) { - if (this.size >= 4) { - return - } - - const {width, height} = await getImageDim(uri) - - const image = { - path: uri, - height, - width, - mime: 'image/jpeg', - } - - runInAction(() => { - this.add(image) - }) - } - - setAltText(image: ImageModel, altText: string) { - image.setAltText(altText) - } - - crop(image: ImageModel) { - image.crop() - } - - remove(image: ImageModel) { - const index = this.images.findIndex(image_ => image_.path === image.path) - this.images.splice(index, 1) - } - - async previous(image: ImageModel) { - image.previous() - } - - async pick() { - const images = await openPicker({ - selectionLimit: 4 - this.size, - allowsMultipleSelection: true, - }) - - return await Promise.all( - images.map(image => { - this.add(image) - }), - ) - } - - async addFromUris(uris: InitialImageUri[]) { - for (const uriObj of uris) { - this.add({ - mime: 'image/jpeg', - height: uriObj.height, - width: uriObj.width, - path: uriObj.uri, - }) - } - } -} diff --git a/src/state/models/media/image.e2e.ts b/src/state/models/media/image.e2e.ts deleted file mode 100644 index ccabd50475..0000000000 --- a/src/state/models/media/image.e2e.ts +++ /dev/null @@ -1,146 +0,0 @@ -import {Image as RNImage} from 'react-native-image-crop-picker' -import {makeAutoObservable} from 'mobx' -import {POST_IMG_MAX} from 'lib/constants' -import {ActionCrop} from 'expo-image-manipulator' -import {Position} from 'react-avatar-editor' -import {Dimensions} from 'lib/media/types' - -export interface ImageManipulationAttributes { - aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' - rotate?: number - scale?: number - position?: Position - flipHorizontal?: boolean - flipVertical?: boolean -} - -export class ImageModel implements Omit { - path: string - mime = 'image/jpeg' - width: number - height: number - altText = '' - cropped?: RNImage = undefined - compressed?: RNImage = undefined - - // Web manipulation - prev?: RNImage - attributes: ImageManipulationAttributes = { - aspectRatio: 'None', - scale: 1, - flipHorizontal: false, - flipVertical: false, - rotate: 0, - } - prevAttributes: ImageManipulationAttributes = {} - - constructor(image: Omit) { - makeAutoObservable(this) - - this.path = image.path - this.width = image.width - this.height = image.height - } - - 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() { - return { - '4:3': 4 / 3, - '1:1': 1, - '3:4': 3 / 4, - None: this.width / this.height, - } - } - - getUploadDimensions( - dimensions: Dimensions, - maxDimensions: Dimensions = POST_IMG_MAX, - as: ImageManipulationAttributes['aspectRatio'] = 'None', - ) { - const {width, height} = dimensions - const {width: maxWidth, height: maxHeight} = maxDimensions - - return width < maxWidth && height < maxHeight - ? { - width, - height, - } - : this.getResizedDimensions(as, POST_IMG_MAX.width) - } - - getResizedDimensions( - as: ImageManipulationAttributes['aspectRatio'] = 'None', - 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, - } - } - - setAltText(altText: string) { - this.altText = altText.trim() - } - - // Only compress prior to upload - async compress() { - // do nothing - } - - // Mobile - async crop() { - // do nothing - } - - // Web manipulation - async manipulate( - _attributes: { - crop?: ActionCrop['crop'] - } & ImageManipulationAttributes, - ) { - // do nothing - } - - resetCropped() { - this.manipulate({}) - } - - previous() { - this.cropped = this.prev - this.attributes = this.prevAttributes - } -} diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts deleted file mode 100644 index 5c547c148f..0000000000 --- a/src/state/models/media/image.ts +++ /dev/null @@ -1,299 +0,0 @@ -import {Image as RNImage} from 'react-native-image-crop-picker' -import {makeAutoObservable, runInAction} from 'mobx' -import {POST_IMG_MAX} from 'lib/constants' -import * as ImageManipulator from 'expo-image-manipulator' -import {getDataUriSize} 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 {Dimensions} from 'lib/media/types' -import {isIOS} from 'platform/detection' -import {logger} from '#/logger' - -export interface ImageManipulationAttributes { - aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' - rotate?: number - scale?: number - position?: Position - flipHorizontal?: boolean - flipVertical?: boolean -} - -const MAX_IMAGE_SIZE_IN_BYTES = 976560 - -export class ImageModel implements Omit { - path: string - mime = 'image/jpeg' - width: number - height: number - altText = '' - cropped?: RNImage = undefined - compressed?: RNImage = undefined - - // Web manipulation - prev?: RNImage - attributes: ImageManipulationAttributes = { - aspectRatio: 'None', - scale: 1, - flipHorizontal: false, - flipVertical: false, - rotate: 0, - } - prevAttributes: ImageManipulationAttributes = {} - - constructor(image: Omit) { - makeAutoObservable(this) - - this.path = image.path - this.width = image.width - this.height = image.height - } - - 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() { - return { - '4:3': 4 / 3, - '1:1': 1, - '3:4': 3 / 4, - None: this.width / this.height, - } - } - - getUploadDimensions( - dimensions: Dimensions, - maxDimensions: Dimensions = POST_IMG_MAX, - as: ImageManipulationAttributes['aspectRatio'] = 'None', - ) { - const {width, height} = dimensions - const {width: maxWidth, height: maxHeight} = maxDimensions - - return width < maxWidth && height < maxHeight - ? { - width, - height, - } - : this.getResizedDimensions(as, POST_IMG_MAX.width) - } - - getResizedDimensions( - as: ImageManipulationAttributes['aspectRatio'] = 'None', - 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, - } - } - - setAltText(altText: string) { - this.altText = altText.trim() - } - - // Only compress prior to upload - async compress() { - for (let i = 10; i > 0; i--) { - // Float precision - const factor = Math.round(i) / 10 - const compressed = await ImageManipulator.manipulateAsync( - this.cropped?.path ?? this.path, - undefined, - { - compress: factor, - base64: true, - format: SaveFormat.JPEG, - }, - ) - - if (compressed.base64 !== undefined) { - const size = getDataUriSize(compressed.base64) - - if (size < MAX_IMAGE_SIZE_IN_BYTES) { - runInAction(() => { - this.compressed = { - mime: 'image/jpeg', - path: compressed.uri, - size, - ...compressed, - } - }) - return - } - } - } - - // Compression fails when removing redundant information is not possible. - // This can be tested with images that have high variance in noise. - throw new Error('Failed to compress image') - } - - // Mobile - async crop() { - try { - // NOTE - // on ios, react-native-image-crop-picker gives really bad quality - // without specifying width and height. on android, however, the - // crop stretches incorrectly if you do specify it. these are - // both separate bugs in the library. we deal with that by - // providing width & height for ios only - // -prf - const {width, height} = this.getUploadDimensions({ - width: this.width, - height: this.height, - }) - - const cropped = await openCropper({ - mediaType: 'photo', - path: this.path, - freeStyleCropEnabled: true, - ...(isIOS ? {width, height} : {}), - }) - - runInAction(() => { - this.cropped = cropped - }) - } catch (err) { - logger.error('Failed to crop photo', {message: err}) - } - } - - // Web manipulation - async manipulate( - attributes: { - crop?: ActionCrop['crop'] - } & ImageManipulationAttributes, - ) { - let uploadWidth: number | undefined - let uploadHeight: number | undefined - - const {aspectRatio, crop, position, scale} = attributes - const modifiers = [] - - if (this.attributes.flipHorizontal) { - modifiers.push({flip: FlipType.Horizontal}) - } - - if (this.attributes.flipVertical) { - modifiers.push({flip: FlipType.Vertical}) - } - - if (this.attributes.rotate !== undefined) { - modifiers.push({rotate: this.attributes.rotate}) - } - - if (crop !== undefined) { - const croppedHeight = crop.height * this.height - const croppedWidth = crop.width * this.width - modifiers.push({ - crop: { - originX: crop.originX * this.width, - originY: crop.originY * this.height, - height: croppedHeight, - width: croppedWidth, - }, - }) - - const uploadDimensions = this.getUploadDimensions( - {width: croppedWidth, height: croppedHeight}, - POST_IMG_MAX, - aspectRatio, - ) - - uploadWidth = uploadDimensions.width - uploadHeight = uploadDimensions.height - } else { - const uploadDimensions = this.getUploadDimensions( - {width: this.width, height: this.height}, - POST_IMG_MAX, - aspectRatio, - ) - - uploadWidth = uploadDimensions.width - uploadHeight = uploadDimensions.height - } - - if (scale !== undefined) { - this.attributes.scale = scale - } - - if (position !== undefined) { - this.attributes.position = position - } - - if (aspectRatio !== undefined) { - this.attributes.aspectRatio = aspectRatio - } - - const ratioMultiplier = - this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1'] - - const result = await ImageManipulator.manipulateAsync( - this.path, - [ - ...modifiers, - { - resize: - ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight}, - }, - ], - { - base64: true, - format: SaveFormat.JPEG, - }, - ) - - runInAction(() => { - this.cropped = { - mime: 'image/jpeg', - path: result.uri, - size: - result.base64 !== undefined - ? getDataUriSize(result.base64) - : MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails - ...result, - } - }) - } - - resetCropped() { - this.manipulate({}) - } - - previous() { - this.cropped = this.prev - this.attributes = this.prevAttributes - } -} diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 3840e77ee1..a2606ae602 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -143,12 +143,6 @@ const GalleryItem = ({ cropImage(image).then(next => { onChange(next) }) - } else { - // openModal({ - // name: 'edit-image', - // image, - // gallery, - // }) } } diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx deleted file mode 100644 index b39dcd9364..0000000000 --- a/src/view/com/modals/EditImage.tsx +++ /dev/null @@ -1,402 +0,0 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' -import {Pressable, StyleSheet, View} from 'react-native' -import {useWindowDimensions} from 'react-native' -import {LinearGradient} from 'expo-linear-gradient' -import {MaterialIcons} from '@expo/vector-icons' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {Slider} from '@miblanchard/react-native-slider' -import {observer} from 'mobx-react-lite' -import ImageEditor, {Position} from 'react-avatar-editor' - -import {useModalControls} from '#/state/modals' -import {MAX_ALT_TEXT} from 'lib/constants' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {RectTallIcon, RectWideIcon, SquareIcon} from 'lib/icons' -import {enforceLen} from 'lib/strings/helpers' -import {gradients, s} from 'lib/styles' -import {useTheme} from 'lib/ThemeContext' -import {getKeys} from 'lib/type-assertions' -import {GalleryModel} from 'state/models/media/gallery' -import {ImageModel} from 'state/models/media/image' -import {Text} from '../util/text/Text' -import {TextInput} from './util' - -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 { - image: ImageModel - gallery: GalleryModel -} - -export const Component = observer(function EditImageImpl({ - image, - gallery, -}: Props) { - const pal = usePalette('default') - const theme = useTheme() - const {_} = useLingui() - const windowDimensions = useWindowDimensions() - const {isMobile} = useWebMediaQueries() - const {closeModal} = useModalControls() - - const { - aspectRatio, - // rotate = 0 - } = image.attributes - - const editorRef = useRef(null) - const [scale, setScale] = useState(image.attributes.scale ?? 1) - const [position, setPosition] = useState( - image.attributes.position, - ) - const [altText, setAltText] = useState(image?.altText ?? '') - - const onFlipHorizontal = useCallback(() => { - image.flipHorizontal() - }, [image]) - - const onFlipVertical = useCallback(() => { - 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( - () => [ - // { - // name: 'rotate-left' as const, - // label: 'Rotate left', - // onPress: () => { - // onSetRotate('left') - // }, - // }, - // { - // name: 'rotate-right' as const, - // label: 'Rotate right', - // onPress: () => { - // onSetRotate('right') - // }, - // }, - { - name: 'flip' as const, - label: _(msg`Flip horizontal`), - onPress: onFlipHorizontal, - }, - { - name: 'flip' as const, - label: _(msg`Flip vertically`), - onPress: onFlipVertical, - }, - ], - [onFlipHorizontal, onFlipVertical, _], - ) - - useEffect(() => { - image.prev = image.cropped - image.prevAttributes = image.attributes - image.resetCropped() - }, [image]) - - const onCloseModal = useCallback(() => { - closeModal() - }, [closeModal]) - - 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} : {}), - } - : {}), - }) - - image.prev = image.cropped - image.prevAttributes = image.attributes - onCloseModal() - }, [altText, image, position, scale, onCloseModal]) - - const getLabelIconSize = useCallback((as: AspectRatio) => { - switch (as) { - case 'None': - return 22 - case '1:1': - return 32 - default: - return 26 - } - }, []) - - if (image.cropped === undefined) { - return null - } - - const computedWidth = - windowDimensions.width > 500 ? 410 : windowDimensions.width - 80 - const sideLength = isMobile ? computedWidth : 300 - - const dimensions = image.getResizedDimensions(aspectRatio, sideLength) - const imgContainerStyles = {width: sideLength, height: sideLength} - - const imgControlStyles = { - alignItems: 'center' as const, - flexDirection: isMobile ? ('column' as const) : ('row' as const), - gap: isMobile ? 0 : 5, - } - - return ( - - - Edit image - - - - - - - - setScale(Array.isArray(v) ? v[0] : v) - } - minimumValue={1} - maximumValue={3} - /> - - - {!isMobile ? ( - - Ratios - - ) : null} - - {getKeys(RATIOS).map(ratio => { - const {Icon, ...props} = RATIOS[ratio] - const labelIconSize = getLabelIconSize(ratio) - const isSelected = aspectRatio === ratio - - return ( - { - onSetRatio(ratio) - }} - accessibilityLabel={ratio} - accessibilityHint=""> - - - - {ratio} - - - ) - })} - - {!isMobile ? ( - - Transformations - - ) : null} - - {adjustments.map(({label, name, onPress}) => ( - - - - ))} - - - - - - Accessibility - - setAltText(enforceLen(text, MAX_ALT_TEXT))} - accessibilityLabel={_(msg`Alt text`)} - accessibilityHint="" - accessibilityLabelledBy="alt-text" - /> - - - - - Cancel - - - - - - Done - - - - - - ) -}) - -const styles = StyleSheet.create({ - container: { - gap: 18, - height: '100%', - width: '100%', - }, - subsection: {marginTop: 12}, - gap18: {gap: 18}, - title: { - fontWeight: 'bold', - fontSize: 24, - }, - btns: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - btn: { - borderRadius: 4, - paddingVertical: 8, - paddingHorizontal: 24, - }, - imgControl: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - height: 40, - }, - imgEditor: { - maxWidth: '100%', - }, - imgContainer: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - borderWidth: 1, - borderStyle: 'solid', - 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', - }, - bottomSection: { - borderTopWidth: 1, - paddingTop: 18, - }, -}) diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 4d1b8f4483..16da4205f6 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -12,9 +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 DeleteAccountModal from './DeleteAccount' -import * as EditImageModal from './EditImage' import * as EditProfileModal from './EditProfile' import * as InviteCodesModal from './InviteCodes' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' @@ -55,9 +53,6 @@ function Modal({modal}: {modal: ModalIface}) { } const onPressMask = () => { - if (modal.name === 'crop-image' || modal.name === 'edit-image') { - return // dont close on mask presses during crop - } closeModal() } const onInnerPress = () => { @@ -74,8 +69,6 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'list-add-remove-users') { element = - } else if (modal.name === 'crop-image') { - element = } else if (modal.name === 'delete-account') { element = } else if (modal.name === 'repost') { @@ -94,9 +87,6 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'post-languages-settings') { element = - } else if (modal.name === 'edit-image') { - // @ts-expect-error: todo - element = } else if (modal.name === 'verify-email') { element = } else if (modal.name === 'change-email') { 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, - } -} From 59f23546fd6b8f70486257487e30b0820194d226 Mon Sep 17 00:00:00 2001 From: Mary Date: Wed, 8 May 2024 10:43:09 +0700 Subject: [PATCH 12/20] feat: new image editor --- package.json | 1 + src/state/gallery.ts | 21 +- .../com/composer/photos/EditImageDialog.tsx | 14 ++ .../composer/photos/EditImageDialog.web.tsx | 188 ++++++++++++++++++ src/view/com/composer/photos/Gallery.tsx | 34 ++-- yarn.lock | 5 + 6 files changed, 232 insertions(+), 31 deletions(-) create mode 100644 src/view/com/composer/photos/EditImageDialog.tsx create mode 100644 src/view/com/composer/photos/EditImageDialog.web.tsx diff --git a/package.json b/package.json index 26d6b061b1..8284e3b4d6 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "react": "18.2.0", "react-avatar-editor": "^13.0.0", "react-dom": "^18.2.0", + "react-image-crop": "^11.0.5", "react-keyed-flatten-children": "^3.0.0", "react-native": "0.73.2", "react-native-date-picker": "^4.4.0", diff --git a/src/state/gallery.ts b/src/state/gallery.ts index ed0c5d8229..dcd7cdf5af 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -8,10 +8,6 @@ import { import { Action, ActionCrop, - ActionExtent, - ActionFlip, - ActionResize, - ActionRotate, manipulateAsync, SaveFormat, } from 'expo-image-manipulator' @@ -23,9 +19,7 @@ import {openCropper} from '#/lib/media/picker' import {getDataUriSize} from '#/lib/media/util' import {isIOS, isNative} from '#/platform/detection' -export type ImageTransformation = Partial< - ActionCrop & ActionExtent & ActionFlip & ActionResize & ActionRotate -> +export type ImageTransformation = Partial export type ImageMeta = { path: string @@ -44,10 +38,11 @@ type ComposerImageBase = { } type ComposerImageWithoutTransformation = ComposerImageBase & { transformed?: undefined + manips?: undefined } type ComposerImageWithTransformation = ComposerImageBase & { transformed: ImageMeta - transformations?: ImageTransformation + manips?: ImageTransformation } export type ComposerImage = @@ -161,13 +156,7 @@ export async function manipulateImage( img: ComposerImage, trans: ImageTransformation, ): Promise { - const rawActions: (Action | undefined)[] = [ - trans.extent && {extent: trans.extent}, - trans.flip && {flip: trans.flip}, - trans.rotate !== undefined ? {rotate: trans.rotate} : undefined, - trans.resize && {resize: trans.resize}, - trans.crop && {crop: trans.crop}, - ] + const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}] const actions = rawActions.filter((a): a is Action => a !== undefined) @@ -193,7 +182,7 @@ export async function manipulateImage( height: result.height, mime: 'image/png', }, - transformations: trans, + manips: trans, } } 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..ff1542f8f0 --- /dev/null +++ b/src/view/com/composer/photos/EditImageDialog.web.tsx @@ -0,0 +1,188 @@ +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, {PixelCrop} from 'react-image-crop' + +import {atoms as a, useTheme} 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 t = useTheme() + + const source = image.source + const manips = image.manips + + const {initialCrop, initialAspect, sourceAspect} = React.useMemo(() => { + const initialArea = manips?.crop + + let crop: PixelCrop | undefined + + if (initialArea) { + crop = { + unit: 'px', + x: initialArea.originX, + y: initialArea.originY, + width: initialArea.width, + height: initialArea.height, + } + } + + const originalAspect = source.width / source.height + + return { + initialCrop: crop, + initialAspect: crop ? crop.width / crop.height : originalAspect, + sourceAspect: originalAspect, + } + }, [source, manips]) + + const [crop, setCrop] = React.useState(initialCrop) + const [aspect, setAspect] = React.useState(initialAspect) + + const isEmpty = !crop || (crop.width || crop.height) === 0 + const isNew = initialCrop ? true : !isEmpty + + const onPressSubmit = React.useCallback(async () => { + const result = await manipulateImage(image, { + crop: !isEmpty + ? { + originX: crop.x, + originY: crop.y, + width: crop.width, + height: crop.height, + } + : undefined, + }) + + onChange(result) + control.close() + }, [crop, isEmpty, image, control, onChange]) + + const changeAspect = (next: number) => { + if (next !== aspect) { + setAspect(next) + setCrop(undefined) + } + } + + return ( + + + Edit image + + + + + + + + + + + changeAspect(sourceAspect)}> + + + + + + changeAspect(16 / 9)}> + + + + + + changeAspect(1 / 1)}> + + + + + + + + + + ) +} + +const ToolbarButton = ({ + children, + label, + onPress, +}: { + children: React.ReactElement + label: string + onPress?: () => void +}) => { + return ( + + ) +} +import {Path, Svg} from 'react-native-svg' + +import {manipulateImage} from '#/state/gallery' diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index a2606ae602..31f2af06ba 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -14,6 +14,7 @@ import {isNative} from 'platform/detection' 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 @@ -137,12 +138,15 @@ const GalleryItem = ({ const t = useTheme() const altTextControl = Dialog.useDialogControl() + const editControl = Dialog.useDialogControl() const onEdit = () => { if (isNative) { cropImage(image).then(next => { onChange(next) }) + } else { + editControl.open() } } @@ -173,21 +177,15 @@ const GalleryItem = ({ - {isNative && ( - - - - )} + + + + + ) } diff --git a/yarn.lock b/yarn.lock index 4edc4ce430..96e183a9af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18664,6 +18664,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.5: + version "11.0.5" + resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-11.0.5.tgz#c7abcf9cae28305d253d55d481158a594a937867" + integrity sha512-A/Y/kspOzki1zDL/bSgwWIY1X3CQ9F1QwpdnncWLBVAktnKfAZDIQnWmjXzuzEjZHDMsBlArytIcPBVi6DNklg== + "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" From f6515fe19940a76b687a62dc636dbdf4e3cb6f43 Mon Sep 17 00:00:00 2001 From: Mary Date: Wed, 8 May 2024 10:44:30 +0700 Subject: [PATCH 13/20] chore: remove no longer used dependencies --- package.json | 5 ----- yarn.lock | 33 +-------------------------------- 2 files changed, 1 insertion(+), 37 deletions(-) diff --git a/package.json b/package.json index 8284e3b4d6..f61f893550 100644 --- a/package.json +++ b/package.json @@ -153,16 +153,12 @@ "lodash.set": "^4.3.2", "lodash.shuffle": "^4.2.0", "lodash.throttle": "^4.1.1", - "mobx": "^6.6.1", - "mobx-react-lite": "^3.4.0", - "mobx-utils": "^6.0.6", "nanoid": "^5.0.5", "normalize-url": "^8.0.0", "patch-package": "^6.5.1", "postinstall-postinstall": "^2.1.0", "psl": "^1.9.0", "react": "18.2.0", - "react-avatar-editor": "^13.0.0", "react-dom": "^18.2.0", "react-image-crop": "^11.0.5", "react-keyed-flatten-children": "^3.0.0", @@ -227,7 +223,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/yarn.lock b/yarn.lock index 96e183a9af..23c0e141a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2210,7 +2210,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== @@ -7811,13 +7811,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" @@ -16376,21 +16369,6 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mobx-react-lite@^3.4.0: - version "3.4.3" - resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz#3a4c22c30bfaa8b1b2aa48d12b2ba811c0947ab7" - integrity sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg== - -mobx-utils@^6.0.6: - version "6.0.8" - resolved "https://registry.yarnpkg.com/mobx-utils/-/mobx-utils-6.0.8.tgz#843e222c7694050c2e42842682fd24a84fdb7024" - integrity sha512-fPNt0vJnHwbQx9MojJFEnJLfM3EMGTtpy4/qOOW6xueh1mPofMajrbYAUvByMYAvCJnpy1A5L0t+ZVB5niKO4g== - -mobx@^6.6.1: - version "6.10.0" - resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.10.0.tgz#3537680fe98d45232cc19cc8f76280bd8bb6b0b7" - integrity sha512-WMbVpCMFtolbB8swQ5E2YRrU+Yu8iLozCVx3CdGjbBKlP7dFiCSuiG06uea3JCFN5DnvtAX7+G5Bp82e2xu0ww== - moment@^2.19.3: version "2.29.4" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" @@ -18599,15 +18577,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-dev-utils@^12.0.1: version "12.0.1" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.1.tgz#ba92edb4a1f379bd46ccd6bcd4e7bc398df33e73" From c67043738d3dc1697da671d800214fe2fa292bac Mon Sep 17 00:00:00 2001 From: Mary Date: Wed, 8 May 2024 11:20:32 +0700 Subject: [PATCH 14/20] refactor: restore old image cropper --- src/lib/media/picker.web.tsx | 4 - src/state/modals/index.tsx | 8 ++ src/view/com/modals/CropImage.web.tsx | 134 ++++++++++++++++++++++++++ src/view/com/modals/Modal.web.tsx | 3 + 4 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 src/view/com/modals/CropImage.web.tsx diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index 8782e14570..7e2562e297 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -17,10 +17,6 @@ export async function openCropper(opts: CropperOptions): Promise { unstable__openModal({ name: 'crop-image', uri: opts.path, - dimensions: - opts.height && opts.width - ? {width: opts.width, height: opts.height} - : undefined, onSelect: (img?: RNImage) => { if (img) { resolve(img) diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 8298d282bc..f7eb762462 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -1,4 +1,5 @@ import React from 'react' +import {Image as RNImage} from 'react-native-image-crop-picker' import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -35,6 +36,12 @@ export interface ListAddRemoveUsersModal { ) => void } +export interface CropImageModal { + name: 'crop-image' + uri: string + onSelect: (img?: RNImage) => void +} + export interface DeleteAccountModal { name: 'delete-account' } @@ -130,6 +137,7 @@ export type Modal = | ListAddRemoveUsersModal // Posts + | CropImageModal | RepostModal | SelfLabelModal | ThreadgateModal diff --git a/src/view/com/modals/CropImage.web.tsx b/src/view/com/modals/CropImage.web.tsx new file mode 100644 index 0000000000..9ccdaf043d --- /dev/null +++ b/src/view/com/modals/CropImage.web.tsx @@ -0,0 +1,134 @@ +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, {PixelCrop} from 'react-image-crop' + +import {useModalControls} from '#/state/modals' +import {usePalette} from 'lib/hooks/usePalette' +import {getDataUriSize} from 'lib/media/util' +import {gradients, s} from 'lib/styles' +import {Text} from 'view/com/util/text/Text' + +export const snapPoints = ['0%'] + +export function Component({ + uri, + onSelect, +}: { + uri: string + onSelect: (img?: RNImage) => void +}) { + const pal = usePalette('default') + const {_} = useLingui() + + const {closeModal} = useModalControls() + + const [crop, setCrop] = React.useState() + + const isEmpty = !crop || (crop.width || crop.height) === 0 + + const onPressCancel = () => { + onSelect(undefined) + closeModal() + } + const onPressDone = async () => { + if (!isEmpty) { + const result = await manipulateAsync( + uri, + [ + { + crop: { + originX: crop.x, + originY: crop.y, + width: crop.width, + height: crop.height, + }, + }, + ], + { + 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, + }) + } else { + onSelect(undefined) + } + + closeModal() + } + + return ( + + + + + + + + + + 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 16da4205f6..16d5ecb39a 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -12,6 +12,7 @@ import * as ChangeEmailModal from './ChangeEmail' import * as ChangeHandleModal from './ChangeHandle' import * as ChangePasswordModal from './ChangePassword' import * as CreateOrEditListModal from './CreateOrEditList' +import * as CropImage from './CropImage.web' import * as DeleteAccountModal from './DeleteAccount' import * as EditProfileModal from './EditProfile' import * as InviteCodesModal from './InviteCodes' @@ -69,6 +70,8 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'list-add-remove-users') { element = + } else if (modal.name === 'crop-image') { + element = } else if (modal.name === 'delete-account') { element = } else if (modal.name === 'repost') { From 36729153cca1a4900e702de00e39216b7516b2c7 Mon Sep 17 00:00:00 2001 From: Mary Date: Wed, 8 May 2024 11:23:31 +0700 Subject: [PATCH 15/20] refactor: lazily initialize path --- src/state/gallery.ts | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/state/gallery.ts b/src/state/gallery.ts index dcd7cdf5af..e21bfeaea8 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -49,9 +49,15 @@ export type ComposerImage = | ComposerImageWithoutTransformation | ComposerImageWithTransformation -const imageCacheDirectory = isNative - ? joinPath(cacheDirectory!, 'bsky-composer') - : null +let _imageCacheDirectory: string + +function getImageCacheDirectory(): string | null { + if (isNative) { + return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer')) + } + + return null +} export async function createComposerImage( raw: ImageMeta, @@ -200,6 +206,7 @@ export async function compressImage(img: ComposerImage): Promise { const source = img.transformed || img.source const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) + const cacheDir = isNative && getImageCacheDirectory() for (let i = 10; i > 0; i--) { // Float precision @@ -226,7 +233,7 @@ export async function compressImage(img: ComposerImage): Promise { } } - if (imageCacheDirectory) { + if (cacheDir) { await deleteAsync(res.uri) } } @@ -235,10 +242,12 @@ export async function compressImage(img: ComposerImage): Promise { } async function moveIfNecessary(from: string) { - if (cacheDirectory && from.startsWith(cacheDirectory!)) { - const to = joinPath(imageCacheDirectory!, uuid.v4() + '') + const cacheDir = isNative && getImageCacheDirectory() - await makeDirectoryAsync(imageCacheDirectory!, {intermediates: true}) + if (cacheDir && from.startsWith(cacheDir)) { + const to = joinPath(cacheDir, uuid.v4() + '') + + await makeDirectoryAsync(cacheDir, {intermediates: true}) await moveAsync({from, to}) return to @@ -249,9 +258,11 @@ async function moveIfNecessary(from: string) { /** Purge files that were created to accomodate image manipulation */ export async function purgeTemporaryImageFiles() { - if (imageCacheDirectory) { - await deleteAsync(imageCacheDirectory, {idempotent: true}) - await makeDirectoryAsync(imageCacheDirectory) + const cacheDir = isNative && getImageCacheDirectory() + + if (cacheDir) { + await deleteAsync(cacheDir, {idempotent: true}) + await makeDirectoryAsync(cacheDir) } } From 9a260c944be5a651747370d92fe47fc22eb53ba2 Mon Sep 17 00:00:00 2001 From: Mary Date: Wed, 8 May 2024 12:00:53 +0700 Subject: [PATCH 16/20] fix: wrong scale calculation --- src/state/gallery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/state/gallery.ts b/src/state/gallery.ts index e21bfeaea8..1253c1f5aa 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -286,7 +286,7 @@ function containImageRes( let scale = 1 if (w > maxW || h > maxH) { - scale = w < h ? maxW / w : maxH / h + scale = w > h ? maxW / w : maxH / h w = Math.floor(w * scale) h = Math.floor(h * scale) } From dda330f0a036d4895d27c256fea4f78c042c0b4b Mon Sep 17 00:00:00 2001 From: Mary Date: Wed, 8 May 2024 12:03:47 +0700 Subject: [PATCH 17/20] refactor: work with percentage crops --- .../composer/photos/EditImageDialog.web.tsx | 35 ++++++++++--------- src/view/com/modals/CropImage.web.tsx | 19 +++++----- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx index ff1542f8f0..4d18ba8c07 100644 --- a/src/view/com/composer/photos/EditImageDialog.web.tsx +++ b/src/view/com/composer/photos/EditImageDialog.web.tsx @@ -2,10 +2,12 @@ import 'react-image-crop/dist/ReactCrop.css' import React from 'react' import {View} from 'react-native' +import {Path, Svg} from 'react-native-svg' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import ReactCrop, {PixelCrop} from 'react-image-crop' +import ReactCrop, {PercentCrop} from 'react-image-crop' +import {manipulateImage} from '#/state/gallery' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' @@ -30,15 +32,15 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { const {initialCrop, initialAspect, sourceAspect} = React.useMemo(() => { const initialArea = manips?.crop - let crop: PixelCrop | undefined + let crop: PercentCrop | undefined if (initialArea) { crop = { - unit: 'px', - x: initialArea.originX, - y: initialArea.originY, - width: initialArea.width, - height: initialArea.height, + 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, } } @@ -46,7 +48,9 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { return { initialCrop: crop, - initialAspect: crop ? crop.width / crop.height : originalAspect, + initialAspect: initialArea + ? initialArea.width / initialArea.height + : originalAspect, sourceAspect: originalAspect, } }, [source, manips]) @@ -61,17 +65,17 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { const result = await manipulateImage(image, { crop: !isEmpty ? { - originX: crop.x, - originY: crop.y, - width: crop.width, - height: crop.height, + 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, isEmpty, image, control, onChange]) + }, [crop, isEmpty, image, source, control, onChange]) const changeAspect = (next: number) => { if (next !== aspect) { @@ -90,7 +94,7 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { setCrop(next)} className="ReactCrop--no-animate"> @@ -183,6 +187,3 @@ const ToolbarButton = ({ ) } -import {Path, Svg} from 'react-native-svg' - -import {manipulateImage} from '#/state/gallery' diff --git a/src/view/com/modals/CropImage.web.tsx b/src/view/com/modals/CropImage.web.tsx index 9ccdaf043d..8a678b4294 100644 --- a/src/view/com/modals/CropImage.web.tsx +++ b/src/view/com/modals/CropImage.web.tsx @@ -5,7 +5,7 @@ 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, {PixelCrop} from 'react-image-crop' +import ReactCrop, {PercentCrop} from 'react-image-crop' import {useModalControls} from '#/state/modals' import {usePalette} from 'lib/hooks/usePalette' @@ -27,7 +27,8 @@ export function Component({ const {closeModal} = useModalControls() - const [crop, setCrop] = React.useState() + const imageRef = React.useRef(null) + const [crop, setCrop] = React.useState() const isEmpty = !crop || (crop.width || crop.height) === 0 @@ -37,15 +38,17 @@ export function Component({ } const onPressDone = async () => { if (!isEmpty) { + const img = imageRef.current! + const result = await manipulateAsync( uri, [ { crop: { - originX: crop.x, - originY: crop.y, - width: crop.width, - height: crop.height, + originX: (crop.x * img.width) / 100, + originY: (crop.y * img.height) / 100, + width: (crop.width * img.width) / 100, + height: (crop.height * img.height) / 100, }, }, ], @@ -72,8 +75,8 @@ export function Component({ return ( - - + setCrop(next)}> + From 35ca71e07d01b8c3a74aea75d93faa5be1b36ced Mon Sep 17 00:00:00 2001 From: Mary Date: Wed, 8 May 2024 12:10:48 +0700 Subject: [PATCH 18/20] fix: missing close button on edit --- src/view/com/composer/photos/EditImageDialog.web.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx index 4d18ba8c07..8483744f58 100644 --- a/src/view/com/composer/photos/EditImageDialog.web.tsx +++ b/src/view/com/composer/photos/EditImageDialog.web.tsx @@ -86,6 +86,8 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { return ( + + Edit image From e4410a8bad14ceeb1eb5acfed7cf8763cdca8904 Mon Sep 17 00:00:00 2001 From: Mary Date: Wed, 8 May 2024 12:12:23 +0700 Subject: [PATCH 19/20] refactor: bring back dimensions --- src/lib/media/picker.web.tsx | 4 ++++ src/state/modals/index.tsx | 1 + src/view/com/modals/CropImage.web.tsx | 8 +++++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index 7e2562e297..95c79cc879 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -17,6 +17,10 @@ export async function openCropper(opts: CropperOptions): Promise { unstable__openModal({ name: 'crop-image', uri: opts.path, + dimensions: + opts.width && opts.height + ? {width: opts.width, height: opts.height} + : undefined, onSelect: (img?: RNImage) => { if (img) { resolve(img) diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index f7eb762462..86eaac3478 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -39,6 +39,7 @@ export interface ListAddRemoveUsersModal { export interface CropImageModal { name: 'crop-image' uri: string + dimensions?: {width: number; height: number} onSelect: (img?: RNImage) => void } diff --git a/src/view/com/modals/CropImage.web.tsx b/src/view/com/modals/CropImage.web.tsx index 8a678b4294..3d8f094b17 100644 --- a/src/view/com/modals/CropImage.web.tsx +++ b/src/view/com/modals/CropImage.web.tsx @@ -17,9 +17,11 @@ export const snapPoints = ['0%'] export function Component({ uri, + dimensions, onSelect, }: { uri: string + dimensions?: {width: number; height: number} onSelect: (img?: RNImage) => void }) { const pal = usePalette('default') @@ -31,6 +33,7 @@ export function Component({ const [crop, setCrop] = React.useState() const isEmpty = !crop || (crop.width || crop.height) === 0 + const aspect = dimensions ? dimensions.width / dimensions.height : undefined const onPressCancel = () => { onSelect(undefined) @@ -75,7 +78,10 @@ export function Component({ return ( - setCrop(next)}> + setCrop(next)}> From a320ca8654a226773d3d35654f43c3e283e34fd4 Mon Sep 17 00:00:00 2001 From: Mary Date: Wed, 8 May 2024 21:35:45 +0700 Subject: [PATCH 20/20] fix: store the intended aspect ratio as well --- src/state/gallery.ts | 4 +++- .../com/composer/photos/EditImageDialog.web.tsx | 13 +++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/state/gallery.ts b/src/state/gallery.ts index 1253c1f5aa..75ab28a90f 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -19,7 +19,9 @@ import {openCropper} from '#/lib/media/picker' import {getDataUriSize} from '#/lib/media/util' import {isIOS, isNative} from '#/platform/detection' -export type ImageTransformation = Partial +export type ImageTransformation = { + crop?: ActionCrop['crop'] & {aspect: number} +} export type ImageMeta = { path: string diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx index 8483744f58..bc5859b79a 100644 --- a/src/view/com/composer/photos/EditImageDialog.web.tsx +++ b/src/view/com/composer/photos/EditImageDialog.web.tsx @@ -31,8 +31,10 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { const {initialCrop, initialAspect, sourceAspect} = React.useMemo(() => { const initialArea = manips?.crop + const originalAspect = source.width / source.height let crop: PercentCrop | undefined + let aspect = originalAspect if (initialArea) { crop = { @@ -42,15 +44,13 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { width: (initialArea.width / source.width) * 100, height: (initialArea.height / source.height) * 100, } - } - const originalAspect = source.width / source.height + aspect = initialArea.aspect + } return { initialCrop: crop, - initialAspect: initialArea - ? initialArea.width / initialArea.height - : originalAspect, + initialAspect: aspect, sourceAspect: originalAspect, } }, [source, manips]) @@ -65,6 +65,7 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { const result = await manipulateImage(image, { crop: !isEmpty ? { + aspect: aspect, originX: (crop.x * source.width) / 100, originY: (crop.y * source.height) / 100, width: (crop.width * source.width) / 100, @@ -75,7 +76,7 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { onChange(result) control.close() - }, [crop, isEmpty, image, source, control, onChange]) + }, [crop, isEmpty, aspect, image, source, control, onChange]) const changeAspect = (next: number) => { if (next !== aspect) {