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
parent
8f6b5d3df9
commit
b0ebb6c9d1
|
@ -74,6 +74,7 @@
|
||||||
"expo-dev-client": "~2.1.1",
|
"expo-dev-client": "~2.1.1",
|
||||||
"expo-device": "~5.2.1",
|
"expo-device": "~5.2.1",
|
||||||
"expo-image": "^1.2.1",
|
"expo-image": "^1.2.1",
|
||||||
|
"expo-image-manipulator": "^11.1.1",
|
||||||
"expo-image-picker": "~14.1.1",
|
"expo-image-picker": "~14.1.1",
|
||||||
"expo-localization": "~14.1.1",
|
"expo-localization": "~14.1.1",
|
||||||
"expo-media-library": "~15.2.3",
|
"expo-media-library": "~15.2.3",
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const getKeys = Object.keys as <T extends object>(
|
||||||
|
obj: T,
|
||||||
|
) => Array<keyof T>
|
|
@ -5,6 +5,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
import {openPicker} from 'lib/media/picker'
|
import {openPicker} from 'lib/media/picker'
|
||||||
import {getImageDim} from 'lib/media/manip'
|
import {getImageDim} from 'lib/media/manip'
|
||||||
import {getDataUriSize} from 'lib/media/util'
|
import {getDataUriSize} from 'lib/media/util'
|
||||||
|
import {isNative} from 'platform/detection'
|
||||||
|
|
||||||
export class GalleryModel {
|
export class GalleryModel {
|
||||||
images: ImageModel[] = []
|
images: ImageModel[] = []
|
||||||
|
@ -37,7 +38,12 @@ export class GalleryModel {
|
||||||
// Temporarily enforce uniqueness but can eventually also use index
|
// Temporarily enforce uniqueness but can eventually also use index
|
||||||
if (!this.images.some(i => i.path === image_.path)) {
|
if (!this.images.some(i => i.path === image_.path)) {
|
||||||
const image = new ImageModel(this.rootStore, image_)
|
const image = new ImageModel(this.rootStore, image_)
|
||||||
await image.compress()
|
|
||||||
|
if (!isNative) {
|
||||||
|
await image.manipulate({})
|
||||||
|
} else {
|
||||||
|
await image.compress()
|
||||||
|
}
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.images.push(image)
|
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) {
|
async paste(uri: string) {
|
||||||
if (this.size >= 4) {
|
if (this.size >= 4) {
|
||||||
return
|
return
|
||||||
|
@ -65,8 +85,8 @@ export class GalleryModel {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setAltText(image: ImageModel) {
|
setAltText(image: ImageModel, altText: string) {
|
||||||
image.setAltText()
|
image.setAltText(altText)
|
||||||
}
|
}
|
||||||
|
|
||||||
crop(image: ImageModel) {
|
crop(image: ImageModel) {
|
||||||
|
@ -78,6 +98,10 @@ export class GalleryModel {
|
||||||
this.images.splice(index, 1)
|
this.images.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async previous(image: ImageModel) {
|
||||||
|
image.previous()
|
||||||
|
}
|
||||||
|
|
||||||
async pick() {
|
async pick() {
|
||||||
const images = await openPicker(this.rootStore, {
|
const images = await openPicker(this.rootStore, {
|
||||||
multiple: true,
|
multiple: true,
|
||||||
|
|
|
@ -1,13 +1,26 @@
|
||||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
import {RootStoreModel} from 'state/index'
|
import {RootStoreModel} from 'state/index'
|
||||||
import {compressAndResizeImageForPost} from 'lib/media/manip'
|
|
||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
import {openCropper} from 'lib/media/picker'
|
|
||||||
import {POST_IMG_MAX} from 'lib/constants'
|
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
|
// TODO: EXIF embed
|
||||||
// Cases to consider: ExternalEmbed
|
// 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 {
|
export class ImageModel implements RNImage {
|
||||||
path: string
|
path: string
|
||||||
mime = 'image/jpeg'
|
mime = 'image/jpeg'
|
||||||
|
@ -20,6 +33,17 @@ export class ImageModel implements RNImage {
|
||||||
scaledWidth: number = POST_IMG_MAX.width
|
scaledWidth: number = POST_IMG_MAX.width
|
||||||
scaledHeight: number = POST_IMG_MAX.height
|
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) {
|
constructor(public rootStore: RootStoreModel, image: RNImage) {
|
||||||
makeAutoObservable(this, {
|
makeAutoObservable(this, {
|
||||||
rootStore: false,
|
rootStore: false,
|
||||||
|
@ -32,12 +56,55 @@ export class ImageModel implements RNImage {
|
||||||
this.calcScaledDimensions()
|
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() {
|
calcScaledDimensions() {
|
||||||
const {width, height} = scaleDownDimensions(
|
const {width, height} = scaleDownDimensions(
|
||||||
{width: this.width, height: this.height},
|
{width: this.width, height: this.height},
|
||||||
POST_IMG_MAX,
|
POST_IMG_MAX,
|
||||||
)
|
)
|
||||||
|
|
||||||
this.scaledWidth = width
|
this.scaledWidth = width
|
||||||
this.scaledHeight = height
|
this.scaledHeight = height
|
||||||
}
|
}
|
||||||
|
@ -46,6 +113,7 @@ export class ImageModel implements RNImage {
|
||||||
this.altText = altText
|
this.altText = altText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only for mobile
|
||||||
async crop() {
|
async crop() {
|
||||||
try {
|
try {
|
||||||
const cropped = await openCropper(this.rootStore, {
|
const cropped = await openCropper(this.rootStore, {
|
||||||
|
@ -55,15 +123,13 @@ export class ImageModel implements RNImage {
|
||||||
width: this.scaledWidth,
|
width: this.scaledWidth,
|
||||||
height: this.scaledHeight,
|
height: this.scaledHeight,
|
||||||
})
|
})
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.cropped = cropped
|
this.cropped = cropped
|
||||||
|
this.compress()
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.rootStore.log.error('Failed to crop photo', err)
|
this.rootStore.log.error('Failed to crop photo', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.compress()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async compress() {
|
async compress() {
|
||||||
|
@ -74,6 +140,8 @@ export class ImageModel implements RNImage {
|
||||||
: {width: this.width, height: this.height},
|
: {width: this.width, height: this.height},
|
||||||
POST_IMG_MAX,
|
POST_IMG_MAX,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TODO: Revisit this - currently iOS uses this as well
|
||||||
const compressed = await compressAndResizeImageForPost({
|
const compressed = await compressAndResizeImageForPost({
|
||||||
...(this.cropped === undefined ? this : this.cropped),
|
...(this.cropped === undefined ? this : this.cropped),
|
||||||
width,
|
width,
|
||||||
|
@ -87,4 +155,99 @@ export class ImageModel implements RNImage {
|
||||||
this.rootStore.log.error('Failed to compress photo', err)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {ProfileModel} from '../content/profile'
|
||||||
import {isObj, hasProp} from 'lib/type-guards'
|
import {isObj, hasProp} from 'lib/type-guards'
|
||||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
import {ImageModel} from '../media/image'
|
import {ImageModel} from '../media/image'
|
||||||
|
import {GalleryModel} from '../media/gallery'
|
||||||
|
|
||||||
export interface ConfirmModal {
|
export interface ConfirmModal {
|
||||||
name: 'confirm'
|
name: 'confirm'
|
||||||
|
@ -37,6 +38,12 @@ export interface ReportAccountModal {
|
||||||
did: string
|
did: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EditImageModal {
|
||||||
|
name: 'edit-image'
|
||||||
|
image: ImageModel
|
||||||
|
gallery: GalleryModel
|
||||||
|
}
|
||||||
|
|
||||||
export interface CropImageModal {
|
export interface CropImageModal {
|
||||||
name: 'crop-image'
|
name: 'crop-image'
|
||||||
uri: string
|
uri: string
|
||||||
|
@ -102,6 +109,7 @@ export type Modal =
|
||||||
// Posts
|
// Posts
|
||||||
| AltTextImageModal
|
| AltTextImageModal
|
||||||
| CropImageModal
|
| CropImageModal
|
||||||
|
| EditImageModal
|
||||||
| ServerInputModal
|
| ServerInputModal
|
||||||
| RepostModal
|
| RepostModal
|
||||||
|
|
||||||
|
|
|
@ -50,7 +50,7 @@ export const Gallery = observer(function ({gallery}: Props) {
|
||||||
|
|
||||||
const handleEditPhoto = useCallback(
|
const handleEditPhoto = useCallback(
|
||||||
(image: ImageModel) => {
|
(image: ImageModel) => {
|
||||||
gallery.crop(image)
|
gallery.edit(image)
|
||||||
},
|
},
|
||||||
[gallery],
|
[gallery],
|
||||||
)
|
)
|
||||||
|
@ -121,10 +121,10 @@ export const Gallery = observer(function ({gallery}: Props) {
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={imageControlsSubgroupStyle}>
|
<View style={imageControlsSubgroupStyle}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="cropPhotoButton"
|
testID="editPhotoButton"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Crop image"
|
accessibilityLabel="Edit image"
|
||||||
accessibilityHint="Opens modal for cropping image"
|
accessibilityHint=""
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
handleEditPhoto(image)
|
handleEditPhoto(image)
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -24,7 +24,6 @@ export function Component({image}: Props) {
|
||||||
const [altText, setAltText] = useState(image.altText)
|
const [altText, setAltText] = useState(image.altText)
|
||||||
|
|
||||||
const onPressSave = useCallback(() => {
|
const onPressSave = useCallback(() => {
|
||||||
setAltText(altText)
|
|
||||||
image.setAltText(altText)
|
image.setAltText(altText)
|
||||||
store.shell.closeModal()
|
store.shell.closeModal()
|
||||||
}, [store, image, altText])
|
}, [store, image, altText])
|
||||||
|
|
|
@ -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',
|
||||||
|
},
|
||||||
|
})
|
|
@ -15,6 +15,7 @@ import * as DeleteAccountModal from './DeleteAccount'
|
||||||
import * as RepostModal from './Repost'
|
import * as RepostModal from './Repost'
|
||||||
import * as CropImageModal from './crop-image/CropImage.web'
|
import * as CropImageModal from './crop-image/CropImage.web'
|
||||||
import * as AltTextImageModal from './AltImage'
|
import * as AltTextImageModal from './AltImage'
|
||||||
|
import * as EditImageModal from './EditImage'
|
||||||
import * as ChangeHandleModal from './ChangeHandle'
|
import * as ChangeHandleModal from './ChangeHandle'
|
||||||
import * as WaitlistModal from './Waitlist'
|
import * as WaitlistModal from './Waitlist'
|
||||||
import * as InviteCodesModal from './InviteCodes'
|
import * as InviteCodesModal from './InviteCodes'
|
||||||
|
@ -47,7 +48,7 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPressMask = () => {
|
const onPressMask = () => {
|
||||||
if (modal.name === 'crop-image') {
|
if (modal.name === 'crop-image' || modal.name === 'edit-image') {
|
||||||
return // dont close on mask presses during crop
|
return // dont close on mask presses during crop
|
||||||
}
|
}
|
||||||
store.shell.closeModal()
|
store.shell.closeModal()
|
||||||
|
@ -88,6 +89,8 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||||
element = <ContentLanguagesSettingsModal.Component />
|
element = <ContentLanguagesSettingsModal.Component />
|
||||||
} else if (modal.name === 'alt-text-image') {
|
} else if (modal.name === 'alt-text-image') {
|
||||||
element = <AltTextImageModal.Component {...modal} />
|
element = <AltTextImageModal.Component {...modal} />
|
||||||
|
} else if (modal.name === 'edit-image') {
|
||||||
|
element = <EditImageModal.Component {...modal} />
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-4.1.1.tgz#efadbb17de1861106864820194900f336dd641b6"
|
||||||
integrity sha512-ciEHVokU0f6w0eTxdRxLCio6tskMsjxWIoV92+/ZD37qePUJYMfEphPhu1sruyvMBNR8/j5iyOvPFVGTfO8oxA==
|
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:
|
expo-image-picker@~14.1.1:
|
||||||
version "14.1.1"
|
version "14.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-14.1.1.tgz#181f1348ba6a43df7b87cee4a601d45c79b7c2d7"
|
resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-14.1.1.tgz#181f1348ba6a43df7b87cee4a601d45c79b7c2d7"
|
||||||
|
|
Loading…
Reference in New Issue