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>
zio/stable
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

@ -74,6 +74,7 @@
"expo-dev-client": "~2.1.1",
"expo-device": "~5.2.1",
"expo-image": "^1.2.1",
"expo-image-manipulator": "^11.1.1",
"expo-image-picker": "~14.1.1",
"expo-localization": "~14.1.1",
"expo-media-library": "~15.2.3",

View File

@ -0,0 +1,3 @@
export const getKeys = Object.keys as <T extends object>(
obj: T,
) => Array<keyof T>

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

View File

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

View File

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

View File

@ -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',
},
})

View File

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

View File

@ -8713,6 +8713,13 @@ expo-image-loader@~4.1.0:
resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-4.1.1.tgz#efadbb17de1861106864820194900f336dd641b6"
integrity sha512-ciEHVokU0f6w0eTxdRxLCio6tskMsjxWIoV92+/ZD37qePUJYMfEphPhu1sruyvMBNR8/j5iyOvPFVGTfO8oxA==
expo-image-manipulator@^11.1.1:
version "11.1.1"
resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-11.1.1.tgz#bb54df80e98abc9798876e3f70596a5b880168c9"
integrity sha512-W9LfJK/IL7EhhkkC1JQnEX/1S9B09rcGasJiQjXc2s1bEsrQnqXvXEv7shUW8b/L8rE+ynf+XvvDE+YIDL7oFg==
dependencies:
expo-image-loader "~4.1.0"
expo-image-picker@~14.1.1:
version "14.1.1"
resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-14.1.1.tgz#181f1348ba6a43df7b87cee4a601d45c79b7c2d7"