diff options
Diffstat (limited to 'src/state/models')
-rw-r--r-- | src/state/models/media/gallery.ts | 30 | ||||
-rw-r--r-- | src/state/models/media/image.ts | 177 | ||||
-rw-r--r-- | src/state/models/ui/shell.ts | 8 |
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 |