about summary refs log tree commit diff
path: root/src
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
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')
-rw-r--r--src/lib/type-assertions.ts3
-rw-r--r--src/state/models/media/gallery.ts30
-rw-r--r--src/state/models/media/image.ts177
-rw-r--r--src/state/models/ui/shell.ts8
-rw-r--r--src/view/com/composer/photos/Gallery.tsx8
-rw-r--r--src/view/com/modals/AltImage.tsx1
-rw-r--r--src/view/com/modals/EditImage.tsx418
-rw-r--r--src/view/com/modals/Modal.web.tsx5
8 files changed, 634 insertions, 16 deletions
diff --git a/src/lib/type-assertions.ts b/src/lib/type-assertions.ts
new file mode 100644
index 000000000..6b5db5124
--- /dev/null
+++ b/src/lib/type-assertions.ts
@@ -0,0 +1,3 @@
+export const getKeys = Object.keys as <T extends object>(
+  obj: T,
+) => Array<keyof T>
diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts
index 97b1ac1d8..86bf8a314 100644
--- a/src/state/models/media/gallery.ts
+++ b/src/state/models/media/gallery.ts
@@ -5,6 +5,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
 import {openPicker} from 'lib/media/picker'
 import {getImageDim} from 'lib/media/manip'
 import {getDataUriSize} from 'lib/media/util'
+import {isNative} from 'platform/detection'
 
 export class GalleryModel {
   images: ImageModel[] = []
@@ -37,7 +38,12 @@ export class GalleryModel {
     // Temporarily enforce uniqueness but can eventually also use index
     if (!this.images.some(i => i.path === image_.path)) {
       const image = new ImageModel(this.rootStore, image_)
-      await image.compress()
+
+      if (!isNative) {
+        await image.manipulate({})
+      } else {
+        await image.compress()
+      }
 
       runInAction(() => {
         this.images.push(image)
@@ -45,6 +51,20 @@ export class GalleryModel {
     }
   }
 
+  async edit(image: ImageModel) {
+    if (!isNative) {
+      this.rootStore.shell.openModal({
+        name: 'edit-image',
+        image,
+        gallery: this,
+      })
+
+      return
+    } else {
+      this.crop(image)
+    }
+  }
+
   async paste(uri: string) {
     if (this.size >= 4) {
       return
@@ -65,8 +85,8 @@ export class GalleryModel {
     })
   }
 
-  setAltText(image: ImageModel) {
-    image.setAltText()
+  setAltText(image: ImageModel, altText: string) {
+    image.setAltText(altText)
   }
 
   crop(image: ImageModel) {
@@ -78,6 +98,10 @@ export class GalleryModel {
     this.images.splice(index, 1)
   }
 
+  async previous(image: ImageModel) {
+    image.previous()
+  }
+
   async pick() {
     const images = await openPicker(this.rootStore, {
       multiple: true,
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
index dcd47665c..ff464a5a9 100644
--- a/src/state/models/media/image.ts
+++ b/src/state/models/media/image.ts
@@ -1,13 +1,26 @@
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {RootStoreModel} from 'state/index'
-import {compressAndResizeImageForPost} from 'lib/media/manip'
 import {makeAutoObservable, runInAction} from 'mobx'
-import {openCropper} from 'lib/media/picker'
 import {POST_IMG_MAX} from 'lib/constants'
-import {scaleDownDimensions} from 'lib/media/util'
+import * as ImageManipulator from 'expo-image-manipulator'
+import {getDataUriSize, scaleDownDimensions} from 'lib/media/util'
+import {openCropper} from 'lib/media/picker'
+import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
+import {Position} from 'react-avatar-editor'
+import {compressAndResizeImageForPost} from 'lib/media/manip'
 
 // TODO: EXIF embed
 // Cases to consider: ExternalEmbed
+
+export interface ImageManipulationAttributes {
+  rotate?: number
+  scale?: number
+  position?: Position
+  flipHorizontal?: boolean
+  flipVertical?: boolean
+  aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
+}
+
 export class ImageModel implements RNImage {
   path: string
   mime = 'image/jpeg'
@@ -20,6 +33,17 @@ export class ImageModel implements RNImage {
   scaledWidth: number = POST_IMG_MAX.width
   scaledHeight: number = POST_IMG_MAX.height
 
+  // Web manipulation
+  aspectRatio?: ImageManipulationAttributes['aspectRatio']
+  position?: Position = undefined
+  prev?: RNImage = undefined
+  rotation?: number = 0
+  scale?: number = 1
+  flipHorizontal?: boolean = false
+  flipVertical?: boolean = false
+
+  prevAttributes: ImageManipulationAttributes = {}
+
   constructor(public rootStore: RootStoreModel, image: RNImage) {
     makeAutoObservable(this, {
       rootStore: false,
@@ -32,12 +56,55 @@ export class ImageModel implements RNImage {
     this.calcScaledDimensions()
   }
 
+  // TODO: Revisit compression factor due to updated sizing with zoom
+  // get compressionFactor() {
+  //   const MAX_IMAGE_SIZE_IN_BYTES = 976560
+
+  //   return this.size < MAX_IMAGE_SIZE_IN_BYTES
+  //     ? 1
+  //     : MAX_IMAGE_SIZE_IN_BYTES / this.size
+  // }
+
+  get ratioMultipliers() {
+    return {
+      '4:3': 4 / 3,
+      '1:1': 1,
+      '3:4': 3 / 4,
+      None: this.width / this.height,
+    }
+  }
+
+  getDisplayDimensions(
+    as: ImageManipulationAttributes['aspectRatio'] = '1:1',
+    maxSide: number,
+  ) {
+    const ratioMultiplier = this.ratioMultipliers[as]
+
+    if (ratioMultiplier === 1) {
+      return {
+        height: maxSide,
+        width: maxSide,
+      }
+    }
+
+    if (ratioMultiplier < 1) {
+      return {
+        width: maxSide * ratioMultiplier,
+        height: maxSide,
+      }
+    }
+
+    return {
+      width: maxSide,
+      height: maxSide / ratioMultiplier,
+    }
+  }
+
   calcScaledDimensions() {
     const {width, height} = scaleDownDimensions(
       {width: this.width, height: this.height},
       POST_IMG_MAX,
     )
-
     this.scaledWidth = width
     this.scaledHeight = height
   }
@@ -46,6 +113,7 @@ export class ImageModel implements RNImage {
     this.altText = altText
   }
 
+  // Only for mobile
   async crop() {
     try {
       const cropped = await openCropper(this.rootStore, {
@@ -55,15 +123,13 @@ export class ImageModel implements RNImage {
         width: this.scaledWidth,
         height: this.scaledHeight,
       })
-
       runInAction(() => {
         this.cropped = cropped
+        this.compress()
       })
     } catch (err) {
       this.rootStore.log.error('Failed to crop photo', err)
     }
-
-    this.compress()
   }
 
   async compress() {
@@ -74,6 +140,8 @@ export class ImageModel implements RNImage {
           : {width: this.width, height: this.height},
         POST_IMG_MAX,
       )
+
+      // TODO: Revisit this - currently iOS uses this as well
       const compressed = await compressAndResizeImageForPost({
         ...(this.cropped === undefined ? this : this.cropped),
         width,
@@ -87,4 +155,99 @@ export class ImageModel implements RNImage {
       this.rootStore.log.error('Failed to compress photo', err)
     }
   }
+
+  // Web manipulation
+  async manipulate(
+    attributes: {
+      crop?: ActionCrop['crop']
+    } & ImageManipulationAttributes,
+  ) {
+    const {aspectRatio, crop, flipHorizontal, flipVertical, rotate, scale} =
+      attributes
+    const modifiers = []
+
+    if (flipHorizontal !== undefined) {
+      this.flipHorizontal = flipHorizontal
+    }
+
+    if (flipVertical !== undefined) {
+      this.flipVertical = flipVertical
+    }
+
+    if (this.flipHorizontal) {
+      modifiers.push({flip: FlipType.Horizontal})
+    }
+
+    if (this.flipVertical) {
+      modifiers.push({flip: FlipType.Vertical})
+    }
+
+    // TODO: Fix rotation -- currently not functional
+    if (rotate !== undefined) {
+      this.rotation = rotate
+    }
+
+    if (this.rotation !== undefined) {
+      modifiers.push({rotate: this.rotation})
+    }
+
+    if (crop !== undefined) {
+      modifiers.push({
+        crop: {
+          originX: crop.originX * this.width,
+          originY: crop.originY * this.height,
+          height: crop.height * this.height,
+          width: crop.width * this.width,
+        },
+      })
+    }
+
+    if (scale !== undefined) {
+      this.scale = scale
+    }
+
+    if (aspectRatio !== undefined) {
+      this.aspectRatio = aspectRatio
+    }
+
+    const ratioMultiplier = this.ratioMultipliers[this.aspectRatio ?? '1:1']
+
+    // TODO: Ollie - should support up to 2000 but smaller images that scale
+    // up need an updated compression factor calculation. Use 1000 for now.
+    const MAX_SIDE = 1000
+
+    const result = await ImageManipulator.manipulateAsync(
+      this.path,
+      [
+        ...modifiers,
+        {resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}},
+      ],
+      {
+        compress: 0.7, // TODO: revisit compression calculation
+        format: SaveFormat.JPEG,
+      },
+    )
+
+    runInAction(() => {
+      this.compressed = {
+        mime: 'image/jpeg',
+        path: result.uri,
+        size: getDataUriSize(result.uri),
+        ...result,
+      }
+    })
+  }
+
+  previous() {
+    this.compressed = this.prev
+
+    const {flipHorizontal, flipVertical, rotate, position, scale} =
+      this.prevAttributes
+
+    this.scale = scale
+    this.rotation = rotate
+    this.flipHorizontal = flipHorizontal
+    this.flipVertical = flipVertical
+    this.position = position
+  }
 }
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index 4a55c23ad..67f8e16d4 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -5,6 +5,7 @@ import {ProfileModel} from '../content/profile'
 import {isObj, hasProp} from 'lib/type-guards'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {ImageModel} from '../media/image'
+import {GalleryModel} from '../media/gallery'
 
 export interface ConfirmModal {
   name: 'confirm'
@@ -37,6 +38,12 @@ export interface ReportAccountModal {
   did: string
 }
 
+export interface EditImageModal {
+  name: 'edit-image'
+  image: ImageModel
+  gallery: GalleryModel
+}
+
 export interface CropImageModal {
   name: 'crop-image'
   uri: string
@@ -102,6 +109,7 @@ export type Modal =
   // Posts
   | AltTextImageModal
   | CropImageModal
+  | EditImageModal
   | ServerInputModal
   | RepostModal
 
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 1aa0aef7a..accd96803 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -50,7 +50,7 @@ export const Gallery = observer(function ({gallery}: Props) {
 
   const handleEditPhoto = useCallback(
     (image: ImageModel) => {
-      gallery.crop(image)
+      gallery.edit(image)
     },
     [gallery],
   )
@@ -121,10 +121,10 @@ export const Gallery = observer(function ({gallery}: Props) {
             </TouchableOpacity>
             <View style={imageControlsSubgroupStyle}>
               <TouchableOpacity
-                testID="cropPhotoButton"
+                testID="editPhotoButton"
                 accessibilityRole="button"
-                accessibilityLabel="Crop image"
-                accessibilityHint="Opens modal for cropping image"
+                accessibilityLabel="Edit image"
+                accessibilityHint=""
                 onPress={() => {
                   handleEditPhoto(image)
                 }}
diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx
index 0359359cc..07270d557 100644
--- a/src/view/com/modals/AltImage.tsx
+++ b/src/view/com/modals/AltImage.tsx
@@ -24,7 +24,6 @@ export function Component({image}: Props) {
   const [altText, setAltText] = useState(image.altText)
 
   const onPressSave = useCallback(() => {
-    setAltText(altText)
     image.setAltText(altText)
     store.shell.closeModal()
   }, [store, image, altText])
diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx
new file mode 100644
index 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',
+  },
+})
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 9dcc8fa7e..c9f2c4952 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -15,6 +15,7 @@ import * as DeleteAccountModal from './DeleteAccount'
 import * as RepostModal from './Repost'
 import * as CropImageModal from './crop-image/CropImage.web'
 import * as AltTextImageModal from './AltImage'
+import * as EditImageModal from './EditImage'
 import * as ChangeHandleModal from './ChangeHandle'
 import * as WaitlistModal from './Waitlist'
 import * as InviteCodesModal from './InviteCodes'
@@ -47,7 +48,7 @@ function Modal({modal}: {modal: ModalIface}) {
   }
 
   const onPressMask = () => {
-    if (modal.name === 'crop-image') {
+    if (modal.name === 'crop-image' || modal.name === 'edit-image') {
       return // dont close on mask presses during crop
     }
     store.shell.closeModal()
@@ -88,6 +89,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <ContentLanguagesSettingsModal.Component />
   } else if (modal.name === 'alt-text-image') {
     element = <AltTextImageModal.Component {...modal} />
+  } else if (modal.name === 'edit-image') {
+    element = <EditImageModal.Component {...modal} />
   } else {
     return null
   }