about summary refs log tree commit diff
path: root/src/view/com/modals/EditImage.tsx
diff options
context:
space:
mode:
authorOllie H <renahlee@outlook.com>2023-05-09 12:55:44 -0700
committerGitHub <noreply@github.com>2023-05-09 14:55:44 -0500
commitb0ebb6c9d17f9f6f78bf13fd2a0ba89d83a7c2a8 (patch)
tree45e68261fe810ea19a1aec76674c1091faa00fb8 /src/view/com/modals/EditImage.tsx
parent8f6b5d3df9b5a5bb61514497f3f25289513ef119 (diff)
downloadvoidsky-b0ebb6c9d17f9f6f78bf13fd2a0ba89d83a7c2a8.tar.zst
Update web image editor (#588)
* Update web image editor

* Delete type-assertions.ts

* Re-add getKeys

* Uncomment rotation code

* Revert "Uncomment rotation code"

This reverts commit 6269f3b928c2e5cacaf5d0ff5323fe975ee48eab.

* Shuffle dependencies and update mobile resolution

* Update ImageEditor modal layout for mobile

* Avoid accidental closes of the EditImage modal

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Diffstat (limited to 'src/view/com/modals/EditImage.tsx')
-rw-r--r--src/view/com/modals/EditImage.tsx418
1 files changed, 418 insertions, 0 deletions
diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx
new file mode 100644
index 000000000..4a5d9bfde
--- /dev/null
+++ b/src/view/com/modals/EditImage.tsx
@@ -0,0 +1,418 @@
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
+import {Pressable, StyleSheet, View} from 'react-native'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWindowDimensions} from 'react-native'
+import {gradients, s} from 'lib/styles'
+import {useTheme} from 'lib/ThemeContext'
+import {Text} from '../util/text/Text'
+import LinearGradient from 'react-native-linear-gradient'
+import {useStores} from 'state/index'
+import ImageEditor, {Position} from 'react-avatar-editor'
+import {TextInput} from './util'
+import {enforceLen} from 'lib/strings/helpers'
+import {MAX_ALT_TEXT} from 'lib/constants'
+import {GalleryModel} from 'state/models/media/gallery'
+import {ImageModel} from 'state/models/media/image'
+import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons'
+import {Slider} from '@miblanchard/react-native-slider'
+import {MaterialIcons} from '@expo/vector-icons'
+import {observer} from 'mobx-react-lite'
+import {getKeys} from 'lib/type-assertions'
+
+export const snapPoints = ['80%']
+
+interface Props {
+  image: ImageModel
+  gallery: GalleryModel
+}
+
+// This is only used for desktop web
+export const Component = observer(function ({image, gallery}: Props) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const {shell} = store
+  const theme = useTheme()
+  const winDim = useWindowDimensions()
+
+  const [altText, setAltText] = useState(image.altText)
+  const [aspectRatio, setAspectRatio] = useState<AspectRatio>(
+    image.aspectRatio ?? 'None',
+  )
+  const [flipHorizontal, setFlipHorizontal] = useState<boolean>(
+    image.flipHorizontal ?? false,
+  )
+  const [flipVertical, setFlipVertical] = useState<boolean>(
+    image.flipVertical ?? false,
+  )
+
+  // TODO: doesn't seem to be working correctly with crop
+  // const [rotation, setRotation] = useState(image.rotation ?? 0)
+  const [scale, setScale] = useState<number>(image.scale ?? 1)
+  const [position, setPosition] = useState<Position>()
+  const [isEditing, setIsEditing] = useState(false)
+  const editorRef = useRef<ImageEditor>(null)
+
+  const imgEditorStyles = useMemo(() => {
+    const dim = Math.min(425, winDim.width - 24)
+    return {width: dim, height: dim}
+  }, [winDim.width])
+
+  const manipulationAttributes = useMemo(
+    () => ({
+      // TODO: doesn't seem to be working correctly with crop
+      // ...(rotation !== undefined ? {rotate: rotation} : {}),
+      ...(flipHorizontal !== undefined ? {flipHorizontal} : {}),
+      ...(flipVertical !== undefined ? {flipVertical} : {}),
+    }),
+    [flipHorizontal, flipVertical],
+  )
+
+  useEffect(() => {
+    const manipulateImage = async () => {
+      await image.manipulate(manipulationAttributes)
+    }
+
+    manipulateImage()
+  }, [image, manipulationAttributes])
+
+  const ratios = useMemo(
+    () =>
+      ({
+        '4:3': {
+          hint: 'Sets image aspect ratio to wide',
+          Icon: RectWideIcon,
+        },
+        '1:1': {
+          hint: 'Sets image aspect ratio to square',
+          Icon: SquareIcon,
+        },
+        '3:4': {
+          hint: 'Sets image aspect ratio to tall',
+          Icon: RectTallIcon,
+        },
+        None: {
+          label: 'None',
+          hint: 'Sets image aspect ratio to tall',
+          Icon: MaterialIcons,
+          name: 'do-not-disturb-alt',
+        },
+      } as const),
+    [],
+  )
+
+  type AspectRatio = keyof typeof ratios
+
+  const onFlipHorizontal = useCallback(() => {
+    setFlipHorizontal(!flipHorizontal)
+    image.manipulate({flipHorizontal})
+  }, [flipHorizontal, image])
+
+  const onFlipVertical = useCallback(() => {
+    setFlipVertical(!flipVertical)
+    image.manipulate({flipVertical})
+  }, [flipVertical, image])
+
+  const adjustments = useMemo(
+    () =>
+      [
+        // {
+        //   name: 'rotate-left',
+        //   label: 'Rotate left',
+        //   hint: 'Rotate image left',
+        //   onPress: () => {
+        //     const rotate = (rotation - 90) % 360
+        //     setRotation(rotate)
+        //     image.manipulate({rotate})
+        //   },
+        // },
+        // {
+        //   name: 'rotate-right',
+        //   label: 'Rotate right',
+        //   hint: 'Rotate image right',
+        //   onPress: () => {
+        //     const rotate = (rotation + 90) % 360
+        //     setRotation(rotate)
+        //     image.manipulate({rotate})
+        //   },
+        // },
+        {
+          name: 'flip',
+          label: 'Flip horizontal',
+          hint: 'Flip image horizontally',
+          onPress: onFlipHorizontal,
+        },
+        {
+          name: 'flip',
+          label: 'Flip vertically',
+          hint: 'Flip image vertically',
+          onPress: onFlipVertical,
+        },
+      ] as const,
+    [onFlipHorizontal, onFlipVertical],
+  )
+
+  useEffect(() => {
+    image.prev = image.compressed
+    setIsEditing(true)
+  }, [image])
+
+  const onCloseModal = useCallback(() => {
+    shell.closeModal()
+    setIsEditing(false)
+  }, [shell])
+
+  const onPressCancel = useCallback(async () => {
+    await gallery.previous(image)
+    onCloseModal()
+  }, [onCloseModal, gallery, image])
+
+  const onPressSave = useCallback(async () => {
+    image.setAltText(altText)
+
+    const crop = editorRef.current?.getCroppingRect()
+
+    await image.manipulate({
+      ...(crop !== undefined
+        ? {
+            crop: {
+              originX: crop.x,
+              originY: crop.y,
+              width: crop.width,
+              height: crop.height,
+            },
+            ...(scale !== 1 ? {scale} : {}),
+            ...(position !== undefined ? {position} : {}),
+          }
+        : {}),
+      ...manipulationAttributes,
+      aspectRatio,
+    })
+
+    image.prevAttributes = manipulationAttributes
+    onCloseModal()
+  }, [
+    altText,
+    aspectRatio,
+    image,
+    manipulationAttributes,
+    position,
+    scale,
+    onCloseModal,
+  ])
+
+  const onPressRatio = useCallback((as: AspectRatio) => {
+    setAspectRatio(as)
+  }, [])
+
+  const getLabelIconSize = useCallback((as: AspectRatio) => {
+    switch (as) {
+      case 'None':
+        return 22
+      case '1:1':
+        return 32
+      default:
+        return 26
+    }
+  }, [])
+
+  // Prevents preliminary flash when transformations are being applied
+  if (image.compressed === undefined) {
+    return null
+  }
+
+  const {width, height} = image.getDisplayDimensions(
+    aspectRatio,
+    imgEditorStyles.width,
+  )
+
+  return (
+    <View testID="editImageModal" style={[pal.view, styles.container, s.flex1]}>
+      <Text style={[styles.title, pal.text]}>Edit image</Text>
+      <View>
+        <View style={[styles.imgContainer, imgEditorStyles, pal.borderDark]}>
+          <ImageEditor
+            ref={editorRef}
+            style={styles.imgEditor}
+            image={isEditing ? image.compressed.path : image.path}
+            width={width}
+            height={height}
+            scale={scale}
+            border={0}
+            position={position}
+            onPositionChange={setPosition}
+          />
+        </View>
+        <Slider
+          value={scale}
+          onValueChange={(v: number | number[]) =>
+            setScale(Array.isArray(v) ? v[0] : v)
+          }
+          minimumValue={1}
+          maximumValue={3}
+        />
+        <View style={[s.flexRow, styles.gap18]}>
+          <View style={styles.imgControls}>
+            {getKeys(ratios).map(ratio => {
+              const {hint, Icon, ...props} = ratios[ratio]
+              const labelIconSize = getLabelIconSize(ratio)
+              const isSelected = aspectRatio === ratio
+
+              return (
+                <Pressable
+                  key={ratio}
+                  onPress={() => {
+                    onPressRatio(ratio)
+                  }}
+                  accessibilityLabel={ratio}
+                  accessibilityHint={hint}>
+                  <Icon
+                    size={labelIconSize}
+                    style={[styles.imgControl, isSelected ? s.blue3 : pal.text]}
+                    color={(isSelected ? s.blue3 : pal.text).color}
+                    {...props}
+                  />
+
+                  <Text
+                    type={isSelected ? 'xs-bold' : 'xs-medium'}
+                    style={[isSelected ? s.blue3 : pal.text, s.textCenter]}>
+                    {ratio}
+                  </Text>
+                </Pressable>
+              )
+            })}
+          </View>
+          <View style={[styles.verticalSep, pal.border]} />
+          <View style={styles.imgControls}>
+            {adjustments.map(({label, hint, name, onPress}) => (
+              <Pressable
+                key={label}
+                onPress={onPress}
+                accessibilityLabel={label}
+                accessibilityHint={hint}
+                style={styles.flipBtn}>
+                <MaterialIcons
+                  name={name}
+                  size={label.startsWith('Flip') ? 22 : 24}
+                  style={[
+                    pal.text,
+                    label === 'Flip vertically'
+                      ? styles.flipVertical
+                      : undefined,
+                  ]}
+                />
+              </Pressable>
+            ))}
+          </View>
+        </View>
+      </View>
+      <View style={[styles.gap18]}>
+        <TextInput
+          testID="altTextImageInput"
+          style={[styles.textArea, pal.border, pal.text]}
+          keyboardAppearance={theme.colorScheme}
+          multiline
+          value={altText}
+          onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
+          placeholder="Image description"
+          placeholderTextColor={pal.colors.textLight}
+          accessibilityLabel="Image alt text"
+          accessibilityHint="Sets image alt text for screenreaders"
+          accessibilityLabelledBy="imageAltText"
+        />
+      </View>
+      <View style={styles.btns}>
+        <Pressable onPress={onPressCancel} accessibilityRole="button">
+          <Text type="xl" style={pal.link}>
+            Cancel
+          </Text>
+        </Pressable>
+        <Pressable onPress={onPressSave} accessibilityRole="button">
+          <LinearGradient
+            colors={[gradients.blueLight.start, gradients.blueLight.end]}
+            start={{x: 0, y: 0}}
+            end={{x: 1, y: 1}}
+            style={[styles.btn]}>
+            <Text type="xl-medium" style={s.white}>
+              Done
+            </Text>
+          </LinearGradient>
+        </Pressable>
+      </View>
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  container: {
+    gap: 18,
+    paddingVertical: 18,
+    paddingHorizontal: 12,
+    height: '100%',
+    width: '100%',
+  },
+  gap18: {
+    gap: 18,
+  },
+
+  title: {
+    fontWeight: 'bold',
+    fontSize: 24,
+  },
+
+  textArea: {
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingTop: 10,
+    paddingHorizontal: 12,
+    fontSize: 16,
+    height: 100,
+    textAlignVertical: 'top',
+  },
+
+  btns: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+  },
+  btn: {
+    borderRadius: 4,
+    paddingVertical: 8,
+    paddingHorizontal: 24,
+  },
+
+  verticalSep: {
+    borderLeftWidth: 1,
+  },
+
+  imgControls: {
+    flexDirection: 'row',
+    gap: 5,
+  },
+  imgControl: {
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    height: 40,
+  },
+  flipVertical: {
+    transform: [{rotate: '90deg'}],
+  },
+  flipBtn: {
+    paddingHorizontal: 4,
+    paddingVertical: 8,
+  },
+  imgEditor: {
+    maxWidth: '100%',
+  },
+  imgContainer: {
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    height: 425,
+    width: 425,
+    borderWidth: 1,
+    borderRadius: 8,
+    borderStyle: 'solid',
+    overflow: 'hidden',
+  },
+})