about summary refs log tree commit diff
path: root/src/view/com/composer
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/composer')
-rw-r--r--src/view/com/composer/photos/EditImageDialog.tsx10
-rw-r--r--src/view/com/composer/photos/EditImageDialog.web.tsx185
2 files changed, 143 insertions, 52 deletions
diff --git a/src/view/com/composer/photos/EditImageDialog.tsx b/src/view/com/composer/photos/EditImageDialog.tsx
index 4263587fd..9799f7b82 100644
--- a/src/view/com/composer/photos/EditImageDialog.tsx
+++ b/src/view/com/composer/photos/EditImageDialog.tsx
@@ -1,12 +1,14 @@
-import React from 'react'
+import type React from 'react'
 
-import {ComposerImage} from '#/state/gallery'
-import * as Dialog from '#/components/Dialog'
+import {type ComposerImage} from '#/state/gallery'
+import type * as Dialog from '#/components/Dialog'
 
 export type EditImageDialogProps = {
   control: Dialog.DialogOuterProps['control']
-  image: ComposerImage
+  image?: ComposerImage
   onChange: (next: ComposerImage) => void
+  aspectRatio?: number
+  circularCrop?: boolean
 }
 
 export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx
index ebe528abc..9a170736a 100644
--- a/src/view/com/composer/photos/EditImageDialog.web.tsx
+++ b/src/view/com/composer/photos/EditImageDialog.web.tsx
@@ -1,43 +1,130 @@
 import 'react-image-crop/dist/ReactCrop.css'
 
-import React from 'react'
+import {useCallback, useImperativeHandle, useRef, useState} from 'react'
 import {View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import ReactCrop, {PercentCrop} from 'react-image-crop'
+import ReactCrop, {type PercentCrop} from 'react-image-crop'
 
 import {
-  ImageSource,
-  ImageTransformation,
+  type ImageSource,
+  type ImageTransformation,
   manipulateImage,
 } from '#/state/gallery'
-import {atoms as a} from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
-import {Text} from '#/components/Typography'
-import {EditImageDialogProps} from './EditImageDialog'
+import {Loader} from '#/components/Loader'
+import {type EditImageDialogProps} from './EditImageDialog'
 
-export const EditImageDialog = (props: EditImageDialogProps) => {
+export function EditImageDialog(props: EditImageDialogProps) {
   return (
     <Dialog.Outer control={props.control}>
       <Dialog.Handle />
-      <EditImageInner key={props.image.source.id} {...props} />
+      <DialogInner {...props} />
     </Dialog.Outer>
   )
 }
 
-const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
+function DialogInner({
+  control,
+  image,
+  onChange,
+  circularCrop,
+  aspectRatio,
+}: EditImageDialogProps) {
   const {_} = useLingui()
+  const [pending, setPending] = useState(false)
+  const ref = useRef<{save: () => Promise<void>}>(null)
+
+  const cancelButton = useCallback(
+    () => (
+      <Button
+        label={_(msg`Cancel`)}
+        disabled={pending}
+        onPress={() => control.close()}
+        size="small"
+        color="primary"
+        variant="ghost"
+        style={[a.rounded_full]}
+        testID="cropImageCancelBtn">
+        <ButtonText style={[a.text_md]}>
+          <Trans>Cancel</Trans>
+        </ButtonText>
+      </Button>
+    ),
+    [control, _, pending],
+  )
+
+  const saveButton = useCallback(
+    () => (
+      <Button
+        label={_(msg`Save`)}
+        onPress={async () => {
+          setPending(true)
+          await ref.current?.save()
+          setPending(false)
+        }}
+        disabled={pending}
+        size="small"
+        color="primary"
+        variant="ghost"
+        style={[a.rounded_full]}
+        testID="cropImageSaveBtn">
+        <ButtonText style={[a.text_md]}>
+          <Trans>Save</Trans>
+        </ButtonText>
+        {pending && <ButtonIcon icon={Loader} />}
+      </Button>
+    ),
+    [_, pending],
+  )
+
+  return (
+    <Dialog.Inner
+      label={_(msg`Edit image`)}
+      header={
+        <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}>
+          <Dialog.HeaderText>
+            <Trans>Edit image</Trans>
+          </Dialog.HeaderText>
+        </Dialog.Header>
+      }>
+      {image && (
+        <EditImageInner
+          saveRef={ref}
+          key={image.source.id}
+          image={image}
+          onChange={onChange}
+          circularCrop={circularCrop}
+          aspectRatio={aspectRatio}
+        />
+      )}
+    </Dialog.Inner>
+  )
+}
+
+function EditImageInner({
+  image,
+  onChange,
+  saveRef,
+  circularCrop = false,
+  aspectRatio,
+}: Required<Pick<EditImageDialogProps, 'image'>> &
+  Omit<EditImageDialogProps, 'control' | 'image'> & {
+    saveRef: React.RefObject<{save: () => Promise<void>}>
+  }) {
+  const t = useTheme()
+  const [isDragging, setIsDragging] = useState(false)
+  const {_} = useLingui()
+  const control = Dialog.useDialogContext()
 
   const source = image.source
 
   const initialCrop = getInitialCrop(source, image.manips)
-  const [crop, setCrop] = React.useState(initialCrop)
-
-  const isEmpty = !crop || (crop.width || crop.height) === 0
-  const isNew = initialCrop ? true : !isEmpty
+  const [crop, setCrop] = useState(initialCrop)
 
-  const onPressSubmit = React.useCallback(async () => {
+  const onPressSubmit = useCallback(async () => {
     const result = await manipulateImage(image, {
       crop:
         crop && (crop.width || crop.height) !== 0
@@ -50,41 +137,43 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
           : undefined,
     })
 
-    onChange(result)
-    control.close()
+    control.close(() => {
+      onChange(result)
+    })
   }, [crop, image, source, control, onChange])
 
+  useImperativeHandle(
+    saveRef,
+    () => ({
+      save: onPressSubmit,
+    }),
+    [onPressSubmit],
+  )
+
   return (
-    <Dialog.Inner label={_(msg`Edit image`)}>
-      <Dialog.Close />
-
-      <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
-        <Trans>Edit image</Trans>
-      </Text>
-
-      <View style={[a.align_center]}>
-        <ReactCrop
-          crop={crop}
-          onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
-          className="ReactCrop--no-animate">
-          <img src={source.path} style={{maxHeight: `50vh`}} />
-        </ReactCrop>
-      </View>
-
-      <View style={[a.mt_md, a.gap_md]}>
-        <Button
-          disabled={!isNew}
-          label={_(msg`Save`)}
-          size="large"
-          color="primary"
-          variant="solid"
-          onPress={onPressSubmit}>
-          <ButtonText>
-            <Trans>Save</Trans>
-          </ButtonText>
-        </Button>
-      </View>
-    </Dialog.Inner>
+    <View
+      style={[
+        a.mx_auto,
+        a.border,
+        t.atoms.border_contrast_low,
+        a.rounded_xs,
+        a.overflow_hidden,
+        a.align_center,
+      ]}>
+      <ReactCrop
+        crop={crop}
+        aspect={aspectRatio}
+        circularCrop={circularCrop}
+        onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
+        className="ReactCrop--no-animate"
+        onDragStart={() => setIsDragging(true)}
+        onDragEnd={() => setIsDragging(false)}>
+        <img src={source.path} style={{maxHeight: `50vh`}} />
+      </ReactCrop>
+      {/* Eat clicks when dragging, otherwise mousing up over the backdrop
+        causes the dialog to close */}
+      {isDragging && <View style={[a.fixed, a.inset_0]} />}
+    </View>
   )
 }