about summary refs log tree commit diff
path: root/src/state/models
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/models')
-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
3 files changed, 205 insertions, 10 deletions
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