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>
This commit is contained in:
Ollie H 2023-05-09 12:55:44 -07:00 committed by GitHub
parent 8f6b5d3df9
commit b0ebb6c9d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 642 additions and 16 deletions

View file

@ -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,

View file

@ -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
}
}

View file

@ -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