my fork of the bluesky client

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>

authored by

Ollie H
Paul Frazee
and committed by
GitHub
b0ebb6c9 8f6b5d3d

+642 -16
+1
package.json
··· 74 "expo-dev-client": "~2.1.1", 75 "expo-device": "~5.2.1", 76 "expo-image": "^1.2.1", 77 "expo-image-picker": "~14.1.1", 78 "expo-localization": "~14.1.1", 79 "expo-media-library": "~15.2.3",
··· 74 "expo-dev-client": "~2.1.1", 75 "expo-device": "~5.2.1", 76 "expo-image": "^1.2.1", 77 + "expo-image-manipulator": "^11.1.1", 78 "expo-image-picker": "~14.1.1", 79 "expo-localization": "~14.1.1", 80 "expo-media-library": "~15.2.3",
+3
src/lib/type-assertions.ts
···
··· 1 + export const getKeys = Object.keys as <T extends object>( 2 + obj: T, 3 + ) => Array<keyof T>
+27 -3
src/state/models/media/gallery.ts
··· 5 import {openPicker} from 'lib/media/picker' 6 import {getImageDim} from 'lib/media/manip' 7 import {getDataUriSize} from 'lib/media/util' 8 9 export class GalleryModel { 10 images: ImageModel[] = [] ··· 37 // Temporarily enforce uniqueness but can eventually also use index 38 if (!this.images.some(i => i.path === image_.path)) { 39 const image = new ImageModel(this.rootStore, image_) 40 - await image.compress() 41 42 runInAction(() => { 43 this.images.push(image) ··· 45 } 46 } 47 48 async paste(uri: string) { 49 if (this.size >= 4) { 50 return ··· 65 }) 66 } 67 68 - setAltText(image: ImageModel) { 69 - image.setAltText() 70 } 71 72 crop(image: ImageModel) { ··· 76 remove(image: ImageModel) { 77 const index = this.images.findIndex(image_ => image_.path === image.path) 78 this.images.splice(index, 1) 79 } 80 81 async pick() {
··· 5 import {openPicker} from 'lib/media/picker' 6 import {getImageDim} from 'lib/media/manip' 7 import {getDataUriSize} from 'lib/media/util' 8 + import {isNative} from 'platform/detection' 9 10 export class GalleryModel { 11 images: ImageModel[] = [] ··· 38 // Temporarily enforce uniqueness but can eventually also use index 39 if (!this.images.some(i => i.path === image_.path)) { 40 const image = new ImageModel(this.rootStore, image_) 41 + 42 + if (!isNative) { 43 + await image.manipulate({}) 44 + } else { 45 + await image.compress() 46 + } 47 48 runInAction(() => { 49 this.images.push(image) ··· 51 } 52 } 53 54 + async edit(image: ImageModel) { 55 + if (!isNative) { 56 + this.rootStore.shell.openModal({ 57 + name: 'edit-image', 58 + image, 59 + gallery: this, 60 + }) 61 + 62 + return 63 + } else { 64 + this.crop(image) 65 + } 66 + } 67 + 68 async paste(uri: string) { 69 if (this.size >= 4) { 70 return ··· 85 }) 86 } 87 88 + setAltText(image: ImageModel, altText: string) { 89 + image.setAltText(altText) 90 } 91 92 crop(image: ImageModel) { ··· 96 remove(image: ImageModel) { 97 const index = this.images.findIndex(image_ => image_.path === image.path) 98 this.images.splice(index, 1) 99 + } 100 + 101 + async previous(image: ImageModel) { 102 + image.previous() 103 } 104 105 async pick() {
+170 -7
src/state/models/media/image.ts
··· 1 import {Image as RNImage} from 'react-native-image-crop-picker' 2 import {RootStoreModel} from 'state/index' 3 - import {compressAndResizeImageForPost} from 'lib/media/manip' 4 import {makeAutoObservable, runInAction} from 'mobx' 5 - import {openCropper} from 'lib/media/picker' 6 import {POST_IMG_MAX} from 'lib/constants' 7 - import {scaleDownDimensions} from 'lib/media/util' 8 9 // TODO: EXIF embed 10 // Cases to consider: ExternalEmbed 11 export class ImageModel implements RNImage { 12 path: string 13 mime = 'image/jpeg' ··· 20 scaledWidth: number = POST_IMG_MAX.width 21 scaledHeight: number = POST_IMG_MAX.height 22 23 constructor(public rootStore: RootStoreModel, image: RNImage) { 24 makeAutoObservable(this, { 25 rootStore: false, ··· 32 this.calcScaledDimensions() 33 } 34 35 calcScaledDimensions() { 36 const {width, height} = scaleDownDimensions( 37 {width: this.width, height: this.height}, 38 POST_IMG_MAX, 39 ) 40 - 41 this.scaledWidth = width 42 this.scaledHeight = height 43 } ··· 46 this.altText = altText 47 } 48 49 async crop() { 50 try { 51 const cropped = await openCropper(this.rootStore, { ··· 55 width: this.scaledWidth, 56 height: this.scaledHeight, 57 }) 58 - 59 runInAction(() => { 60 this.cropped = cropped 61 }) 62 } catch (err) { 63 this.rootStore.log.error('Failed to crop photo', err) 64 } 65 - 66 - this.compress() 67 } 68 69 async compress() { ··· 74 : {width: this.width, height: this.height}, 75 POST_IMG_MAX, 76 ) 77 const compressed = await compressAndResizeImageForPost({ 78 ...(this.cropped === undefined ? this : this.cropped), 79 width, ··· 86 } catch (err) { 87 this.rootStore.log.error('Failed to compress photo', err) 88 } 89 } 90 }
··· 1 import {Image as RNImage} from 'react-native-image-crop-picker' 2 import {RootStoreModel} from 'state/index' 3 import {makeAutoObservable, runInAction} from 'mobx' 4 import {POST_IMG_MAX} from 'lib/constants' 5 + import * as ImageManipulator from 'expo-image-manipulator' 6 + import {getDataUriSize, scaleDownDimensions} from 'lib/media/util' 7 + import {openCropper} from 'lib/media/picker' 8 + import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator' 9 + import {Position} from 'react-avatar-editor' 10 + import {compressAndResizeImageForPost} from 'lib/media/manip' 11 12 // TODO: EXIF embed 13 // Cases to consider: ExternalEmbed 14 + 15 + export interface ImageManipulationAttributes { 16 + rotate?: number 17 + scale?: number 18 + position?: Position 19 + flipHorizontal?: boolean 20 + flipVertical?: boolean 21 + aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' 22 + } 23 + 24 export class ImageModel implements RNImage { 25 path: string 26 mime = 'image/jpeg' ··· 33 scaledWidth: number = POST_IMG_MAX.width 34 scaledHeight: number = POST_IMG_MAX.height 35 36 + // Web manipulation 37 + aspectRatio?: ImageManipulationAttributes['aspectRatio'] 38 + position?: Position = undefined 39 + prev?: RNImage = undefined 40 + rotation?: number = 0 41 + scale?: number = 1 42 + flipHorizontal?: boolean = false 43 + flipVertical?: boolean = false 44 + 45 + prevAttributes: ImageManipulationAttributes = {} 46 + 47 constructor(public rootStore: RootStoreModel, image: RNImage) { 48 makeAutoObservable(this, { 49 rootStore: false, ··· 56 this.calcScaledDimensions() 57 } 58 59 + // TODO: Revisit compression factor due to updated sizing with zoom 60 + // get compressionFactor() { 61 + // const MAX_IMAGE_SIZE_IN_BYTES = 976560 62 + 63 + // return this.size < MAX_IMAGE_SIZE_IN_BYTES 64 + // ? 1 65 + // : MAX_IMAGE_SIZE_IN_BYTES / this.size 66 + // } 67 + 68 + get ratioMultipliers() { 69 + return { 70 + '4:3': 4 / 3, 71 + '1:1': 1, 72 + '3:4': 3 / 4, 73 + None: this.width / this.height, 74 + } 75 + } 76 + 77 + getDisplayDimensions( 78 + as: ImageManipulationAttributes['aspectRatio'] = '1:1', 79 + maxSide: number, 80 + ) { 81 + const ratioMultiplier = this.ratioMultipliers[as] 82 + 83 + if (ratioMultiplier === 1) { 84 + return { 85 + height: maxSide, 86 + width: maxSide, 87 + } 88 + } 89 + 90 + if (ratioMultiplier < 1) { 91 + return { 92 + width: maxSide * ratioMultiplier, 93 + height: maxSide, 94 + } 95 + } 96 + 97 + return { 98 + width: maxSide, 99 + height: maxSide / ratioMultiplier, 100 + } 101 + } 102 + 103 calcScaledDimensions() { 104 const {width, height} = scaleDownDimensions( 105 {width: this.width, height: this.height}, 106 POST_IMG_MAX, 107 ) 108 this.scaledWidth = width 109 this.scaledHeight = height 110 } ··· 113 this.altText = altText 114 } 115 116 + // Only for mobile 117 async crop() { 118 try { 119 const cropped = await openCropper(this.rootStore, { ··· 123 width: this.scaledWidth, 124 height: this.scaledHeight, 125 }) 126 runInAction(() => { 127 this.cropped = cropped 128 + this.compress() 129 }) 130 } catch (err) { 131 this.rootStore.log.error('Failed to crop photo', err) 132 } 133 } 134 135 async compress() { ··· 140 : {width: this.width, height: this.height}, 141 POST_IMG_MAX, 142 ) 143 + 144 + // TODO: Revisit this - currently iOS uses this as well 145 const compressed = await compressAndResizeImageForPost({ 146 ...(this.cropped === undefined ? this : this.cropped), 147 width, ··· 154 } catch (err) { 155 this.rootStore.log.error('Failed to compress photo', err) 156 } 157 + } 158 + 159 + // Web manipulation 160 + async manipulate( 161 + attributes: { 162 + crop?: ActionCrop['crop'] 163 + } & ImageManipulationAttributes, 164 + ) { 165 + const {aspectRatio, crop, flipHorizontal, flipVertical, rotate, scale} = 166 + attributes 167 + const modifiers = [] 168 + 169 + if (flipHorizontal !== undefined) { 170 + this.flipHorizontal = flipHorizontal 171 + } 172 + 173 + if (flipVertical !== undefined) { 174 + this.flipVertical = flipVertical 175 + } 176 + 177 + if (this.flipHorizontal) { 178 + modifiers.push({flip: FlipType.Horizontal}) 179 + } 180 + 181 + if (this.flipVertical) { 182 + modifiers.push({flip: FlipType.Vertical}) 183 + } 184 + 185 + // TODO: Fix rotation -- currently not functional 186 + if (rotate !== undefined) { 187 + this.rotation = rotate 188 + } 189 + 190 + if (this.rotation !== undefined) { 191 + modifiers.push({rotate: this.rotation}) 192 + } 193 + 194 + if (crop !== undefined) { 195 + modifiers.push({ 196 + crop: { 197 + originX: crop.originX * this.width, 198 + originY: crop.originY * this.height, 199 + height: crop.height * this.height, 200 + width: crop.width * this.width, 201 + }, 202 + }) 203 + } 204 + 205 + if (scale !== undefined) { 206 + this.scale = scale 207 + } 208 + 209 + if (aspectRatio !== undefined) { 210 + this.aspectRatio = aspectRatio 211 + } 212 + 213 + const ratioMultiplier = this.ratioMultipliers[this.aspectRatio ?? '1:1'] 214 + 215 + // TODO: Ollie - should support up to 2000 but smaller images that scale 216 + // up need an updated compression factor calculation. Use 1000 for now. 217 + const MAX_SIDE = 1000 218 + 219 + const result = await ImageManipulator.manipulateAsync( 220 + this.path, 221 + [ 222 + ...modifiers, 223 + {resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}}, 224 + ], 225 + { 226 + compress: 0.7, // TODO: revisit compression calculation 227 + format: SaveFormat.JPEG, 228 + }, 229 + ) 230 + 231 + runInAction(() => { 232 + this.compressed = { 233 + mime: 'image/jpeg', 234 + path: result.uri, 235 + size: getDataUriSize(result.uri), 236 + ...result, 237 + } 238 + }) 239 + } 240 + 241 + previous() { 242 + this.compressed = this.prev 243 + 244 + const {flipHorizontal, flipVertical, rotate, position, scale} = 245 + this.prevAttributes 246 + 247 + this.scale = scale 248 + this.rotation = rotate 249 + this.flipHorizontal = flipHorizontal 250 + this.flipVertical = flipVertical 251 + this.position = position 252 } 253 }
+8
src/state/models/ui/shell.ts
··· 5 import {isObj, hasProp} from 'lib/type-guards' 6 import {Image as RNImage} from 'react-native-image-crop-picker' 7 import {ImageModel} from '../media/image' 8 9 export interface ConfirmModal { 10 name: 'confirm' ··· 35 export interface ReportAccountModal { 36 name: 'report-account' 37 did: string 38 } 39 40 export interface CropImageModal { ··· 102 // Posts 103 | AltTextImageModal 104 | CropImageModal 105 | ServerInputModal 106 | RepostModal 107
··· 5 import {isObj, hasProp} from 'lib/type-guards' 6 import {Image as RNImage} from 'react-native-image-crop-picker' 7 import {ImageModel} from '../media/image' 8 + import {GalleryModel} from '../media/gallery' 9 10 export interface ConfirmModal { 11 name: 'confirm' ··· 36 export interface ReportAccountModal { 37 name: 'report-account' 38 did: string 39 + } 40 + 41 + export interface EditImageModal { 42 + name: 'edit-image' 43 + image: ImageModel 44 + gallery: GalleryModel 45 } 46 47 export interface CropImageModal { ··· 109 // Posts 110 | AltTextImageModal 111 | CropImageModal 112 + | EditImageModal 113 | ServerInputModal 114 | RepostModal 115
+4 -4
src/view/com/composer/photos/Gallery.tsx
··· 50 51 const handleEditPhoto = useCallback( 52 (image: ImageModel) => { 53 - gallery.crop(image) 54 }, 55 [gallery], 56 ) ··· 121 </TouchableOpacity> 122 <View style={imageControlsSubgroupStyle}> 123 <TouchableOpacity 124 - testID="cropPhotoButton" 125 accessibilityRole="button" 126 - accessibilityLabel="Crop image" 127 - accessibilityHint="Opens modal for cropping image" 128 onPress={() => { 129 handleEditPhoto(image) 130 }}
··· 50 51 const handleEditPhoto = useCallback( 52 (image: ImageModel) => { 53 + gallery.edit(image) 54 }, 55 [gallery], 56 ) ··· 121 </TouchableOpacity> 122 <View style={imageControlsSubgroupStyle}> 123 <TouchableOpacity 124 + testID="editPhotoButton" 125 accessibilityRole="button" 126 + accessibilityLabel="Edit image" 127 + accessibilityHint="" 128 onPress={() => { 129 handleEditPhoto(image) 130 }}
-1
src/view/com/modals/AltImage.tsx
··· 24 const [altText, setAltText] = useState(image.altText) 25 26 const onPressSave = useCallback(() => { 27 - setAltText(altText) 28 image.setAltText(altText) 29 store.shell.closeModal() 30 }, [store, image, altText])
··· 24 const [altText, setAltText] = useState(image.altText) 25 26 const onPressSave = useCallback(() => { 27 image.setAltText(altText) 28 store.shell.closeModal() 29 }, [store, image, altText])
+418
src/view/com/modals/EditImage.tsx
···
··· 1 + import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 + import {Pressable, StyleSheet, View} from 'react-native' 3 + import {usePalette} from 'lib/hooks/usePalette' 4 + import {useWindowDimensions} from 'react-native' 5 + import {gradients, s} from 'lib/styles' 6 + import {useTheme} from 'lib/ThemeContext' 7 + import {Text} from '../util/text/Text' 8 + import LinearGradient from 'react-native-linear-gradient' 9 + import {useStores} from 'state/index' 10 + import ImageEditor, {Position} from 'react-avatar-editor' 11 + import {TextInput} from './util' 12 + import {enforceLen} from 'lib/strings/helpers' 13 + import {MAX_ALT_TEXT} from 'lib/constants' 14 + import {GalleryModel} from 'state/models/media/gallery' 15 + import {ImageModel} from 'state/models/media/image' 16 + import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons' 17 + import {Slider} from '@miblanchard/react-native-slider' 18 + import {MaterialIcons} from '@expo/vector-icons' 19 + import {observer} from 'mobx-react-lite' 20 + import {getKeys} from 'lib/type-assertions' 21 + 22 + export const snapPoints = ['80%'] 23 + 24 + interface Props { 25 + image: ImageModel 26 + gallery: GalleryModel 27 + } 28 + 29 + // This is only used for desktop web 30 + export const Component = observer(function ({image, gallery}: Props) { 31 + const pal = usePalette('default') 32 + const store = useStores() 33 + const {shell} = store 34 + const theme = useTheme() 35 + const winDim = useWindowDimensions() 36 + 37 + const [altText, setAltText] = useState(image.altText) 38 + const [aspectRatio, setAspectRatio] = useState<AspectRatio>( 39 + image.aspectRatio ?? 'None', 40 + ) 41 + const [flipHorizontal, setFlipHorizontal] = useState<boolean>( 42 + image.flipHorizontal ?? false, 43 + ) 44 + const [flipVertical, setFlipVertical] = useState<boolean>( 45 + image.flipVertical ?? false, 46 + ) 47 + 48 + // TODO: doesn't seem to be working correctly with crop 49 + // const [rotation, setRotation] = useState(image.rotation ?? 0) 50 + const [scale, setScale] = useState<number>(image.scale ?? 1) 51 + const [position, setPosition] = useState<Position>() 52 + const [isEditing, setIsEditing] = useState(false) 53 + const editorRef = useRef<ImageEditor>(null) 54 + 55 + const imgEditorStyles = useMemo(() => { 56 + const dim = Math.min(425, winDim.width - 24) 57 + return {width: dim, height: dim} 58 + }, [winDim.width]) 59 + 60 + const manipulationAttributes = useMemo( 61 + () => ({ 62 + // TODO: doesn't seem to be working correctly with crop 63 + // ...(rotation !== undefined ? {rotate: rotation} : {}), 64 + ...(flipHorizontal !== undefined ? {flipHorizontal} : {}), 65 + ...(flipVertical !== undefined ? {flipVertical} : {}), 66 + }), 67 + [flipHorizontal, flipVertical], 68 + ) 69 + 70 + useEffect(() => { 71 + const manipulateImage = async () => { 72 + await image.manipulate(manipulationAttributes) 73 + } 74 + 75 + manipulateImage() 76 + }, [image, manipulationAttributes]) 77 + 78 + const ratios = useMemo( 79 + () => 80 + ({ 81 + '4:3': { 82 + hint: 'Sets image aspect ratio to wide', 83 + Icon: RectWideIcon, 84 + }, 85 + '1:1': { 86 + hint: 'Sets image aspect ratio to square', 87 + Icon: SquareIcon, 88 + }, 89 + '3:4': { 90 + hint: 'Sets image aspect ratio to tall', 91 + Icon: RectTallIcon, 92 + }, 93 + None: { 94 + label: 'None', 95 + hint: 'Sets image aspect ratio to tall', 96 + Icon: MaterialIcons, 97 + name: 'do-not-disturb-alt', 98 + }, 99 + } as const), 100 + [], 101 + ) 102 + 103 + type AspectRatio = keyof typeof ratios 104 + 105 + const onFlipHorizontal = useCallback(() => { 106 + setFlipHorizontal(!flipHorizontal) 107 + image.manipulate({flipHorizontal}) 108 + }, [flipHorizontal, image]) 109 + 110 + const onFlipVertical = useCallback(() => { 111 + setFlipVertical(!flipVertical) 112 + image.manipulate({flipVertical}) 113 + }, [flipVertical, image]) 114 + 115 + const adjustments = useMemo( 116 + () => 117 + [ 118 + // { 119 + // name: 'rotate-left', 120 + // label: 'Rotate left', 121 + // hint: 'Rotate image left', 122 + // onPress: () => { 123 + // const rotate = (rotation - 90) % 360 124 + // setRotation(rotate) 125 + // image.manipulate({rotate}) 126 + // }, 127 + // }, 128 + // { 129 + // name: 'rotate-right', 130 + // label: 'Rotate right', 131 + // hint: 'Rotate image right', 132 + // onPress: () => { 133 + // const rotate = (rotation + 90) % 360 134 + // setRotation(rotate) 135 + // image.manipulate({rotate}) 136 + // }, 137 + // }, 138 + { 139 + name: 'flip', 140 + label: 'Flip horizontal', 141 + hint: 'Flip image horizontally', 142 + onPress: onFlipHorizontal, 143 + }, 144 + { 145 + name: 'flip', 146 + label: 'Flip vertically', 147 + hint: 'Flip image vertically', 148 + onPress: onFlipVertical, 149 + }, 150 + ] as const, 151 + [onFlipHorizontal, onFlipVertical], 152 + ) 153 + 154 + useEffect(() => { 155 + image.prev = image.compressed 156 + setIsEditing(true) 157 + }, [image]) 158 + 159 + const onCloseModal = useCallback(() => { 160 + shell.closeModal() 161 + setIsEditing(false) 162 + }, [shell]) 163 + 164 + const onPressCancel = useCallback(async () => { 165 + await gallery.previous(image) 166 + onCloseModal() 167 + }, [onCloseModal, gallery, image]) 168 + 169 + const onPressSave = useCallback(async () => { 170 + image.setAltText(altText) 171 + 172 + const crop = editorRef.current?.getCroppingRect() 173 + 174 + await image.manipulate({ 175 + ...(crop !== undefined 176 + ? { 177 + crop: { 178 + originX: crop.x, 179 + originY: crop.y, 180 + width: crop.width, 181 + height: crop.height, 182 + }, 183 + ...(scale !== 1 ? {scale} : {}), 184 + ...(position !== undefined ? {position} : {}), 185 + } 186 + : {}), 187 + ...manipulationAttributes, 188 + aspectRatio, 189 + }) 190 + 191 + image.prevAttributes = manipulationAttributes 192 + onCloseModal() 193 + }, [ 194 + altText, 195 + aspectRatio, 196 + image, 197 + manipulationAttributes, 198 + position, 199 + scale, 200 + onCloseModal, 201 + ]) 202 + 203 + const onPressRatio = useCallback((as: AspectRatio) => { 204 + setAspectRatio(as) 205 + }, []) 206 + 207 + const getLabelIconSize = useCallback((as: AspectRatio) => { 208 + switch (as) { 209 + case 'None': 210 + return 22 211 + case '1:1': 212 + return 32 213 + default: 214 + return 26 215 + } 216 + }, []) 217 + 218 + // Prevents preliminary flash when transformations are being applied 219 + if (image.compressed === undefined) { 220 + return null 221 + } 222 + 223 + const {width, height} = image.getDisplayDimensions( 224 + aspectRatio, 225 + imgEditorStyles.width, 226 + ) 227 + 228 + return ( 229 + <View testID="editImageModal" style={[pal.view, styles.container, s.flex1]}> 230 + <Text style={[styles.title, pal.text]}>Edit image</Text> 231 + <View> 232 + <View style={[styles.imgContainer, imgEditorStyles, pal.borderDark]}> 233 + <ImageEditor 234 + ref={editorRef} 235 + style={styles.imgEditor} 236 + image={isEditing ? image.compressed.path : image.path} 237 + width={width} 238 + height={height} 239 + scale={scale} 240 + border={0} 241 + position={position} 242 + onPositionChange={setPosition} 243 + /> 244 + </View> 245 + <Slider 246 + value={scale} 247 + onValueChange={(v: number | number[]) => 248 + setScale(Array.isArray(v) ? v[0] : v) 249 + } 250 + minimumValue={1} 251 + maximumValue={3} 252 + /> 253 + <View style={[s.flexRow, styles.gap18]}> 254 + <View style={styles.imgControls}> 255 + {getKeys(ratios).map(ratio => { 256 + const {hint, Icon, ...props} = ratios[ratio] 257 + const labelIconSize = getLabelIconSize(ratio) 258 + const isSelected = aspectRatio === ratio 259 + 260 + return ( 261 + <Pressable 262 + key={ratio} 263 + onPress={() => { 264 + onPressRatio(ratio) 265 + }} 266 + accessibilityLabel={ratio} 267 + accessibilityHint={hint}> 268 + <Icon 269 + size={labelIconSize} 270 + style={[styles.imgControl, isSelected ? s.blue3 : pal.text]} 271 + color={(isSelected ? s.blue3 : pal.text).color} 272 + {...props} 273 + /> 274 + 275 + <Text 276 + type={isSelected ? 'xs-bold' : 'xs-medium'} 277 + style={[isSelected ? s.blue3 : pal.text, s.textCenter]}> 278 + {ratio} 279 + </Text> 280 + </Pressable> 281 + ) 282 + })} 283 + </View> 284 + <View style={[styles.verticalSep, pal.border]} /> 285 + <View style={styles.imgControls}> 286 + {adjustments.map(({label, hint, name, onPress}) => ( 287 + <Pressable 288 + key={label} 289 + onPress={onPress} 290 + accessibilityLabel={label} 291 + accessibilityHint={hint} 292 + style={styles.flipBtn}> 293 + <MaterialIcons 294 + name={name} 295 + size={label.startsWith('Flip') ? 22 : 24} 296 + style={[ 297 + pal.text, 298 + label === 'Flip vertically' 299 + ? styles.flipVertical 300 + : undefined, 301 + ]} 302 + /> 303 + </Pressable> 304 + ))} 305 + </View> 306 + </View> 307 + </View> 308 + <View style={[styles.gap18]}> 309 + <TextInput 310 + testID="altTextImageInput" 311 + style={[styles.textArea, pal.border, pal.text]} 312 + keyboardAppearance={theme.colorScheme} 313 + multiline 314 + value={altText} 315 + onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} 316 + placeholder="Image description" 317 + placeholderTextColor={pal.colors.textLight} 318 + accessibilityLabel="Image alt text" 319 + accessibilityHint="Sets image alt text for screenreaders" 320 + accessibilityLabelledBy="imageAltText" 321 + /> 322 + </View> 323 + <View style={styles.btns}> 324 + <Pressable onPress={onPressCancel} accessibilityRole="button"> 325 + <Text type="xl" style={pal.link}> 326 + Cancel 327 + </Text> 328 + </Pressable> 329 + <Pressable onPress={onPressSave} accessibilityRole="button"> 330 + <LinearGradient 331 + colors={[gradients.blueLight.start, gradients.blueLight.end]} 332 + start={{x: 0, y: 0}} 333 + end={{x: 1, y: 1}} 334 + style={[styles.btn]}> 335 + <Text type="xl-medium" style={s.white}> 336 + Done 337 + </Text> 338 + </LinearGradient> 339 + </Pressable> 340 + </View> 341 + </View> 342 + ) 343 + }) 344 + 345 + const styles = StyleSheet.create({ 346 + container: { 347 + gap: 18, 348 + paddingVertical: 18, 349 + paddingHorizontal: 12, 350 + height: '100%', 351 + width: '100%', 352 + }, 353 + gap18: { 354 + gap: 18, 355 + }, 356 + 357 + title: { 358 + fontWeight: 'bold', 359 + fontSize: 24, 360 + }, 361 + 362 + textArea: { 363 + borderWidth: 1, 364 + borderRadius: 6, 365 + paddingTop: 10, 366 + paddingHorizontal: 12, 367 + fontSize: 16, 368 + height: 100, 369 + textAlignVertical: 'top', 370 + }, 371 + 372 + btns: { 373 + flexDirection: 'row', 374 + alignItems: 'center', 375 + justifyContent: 'space-between', 376 + }, 377 + btn: { 378 + borderRadius: 4, 379 + paddingVertical: 8, 380 + paddingHorizontal: 24, 381 + }, 382 + 383 + verticalSep: { 384 + borderLeftWidth: 1, 385 + }, 386 + 387 + imgControls: { 388 + flexDirection: 'row', 389 + gap: 5, 390 + }, 391 + imgControl: { 392 + display: 'flex', 393 + alignItems: 'center', 394 + justifyContent: 'center', 395 + height: 40, 396 + }, 397 + flipVertical: { 398 + transform: [{rotate: '90deg'}], 399 + }, 400 + flipBtn: { 401 + paddingHorizontal: 4, 402 + paddingVertical: 8, 403 + }, 404 + imgEditor: { 405 + maxWidth: '100%', 406 + }, 407 + imgContainer: { 408 + display: 'flex', 409 + alignItems: 'center', 410 + justifyContent: 'center', 411 + height: 425, 412 + width: 425, 413 + borderWidth: 1, 414 + borderRadius: 8, 415 + borderStyle: 'solid', 416 + overflow: 'hidden', 417 + }, 418 + })
+4 -1
src/view/com/modals/Modal.web.tsx
··· 15 import * as RepostModal from './Repost' 16 import * as CropImageModal from './crop-image/CropImage.web' 17 import * as AltTextImageModal from './AltImage' 18 import * as ChangeHandleModal from './ChangeHandle' 19 import * as WaitlistModal from './Waitlist' 20 import * as InviteCodesModal from './InviteCodes' ··· 47 } 48 49 const onPressMask = () => { 50 - if (modal.name === 'crop-image') { 51 return // dont close on mask presses during crop 52 } 53 store.shell.closeModal() ··· 88 element = <ContentLanguagesSettingsModal.Component /> 89 } else if (modal.name === 'alt-text-image') { 90 element = <AltTextImageModal.Component {...modal} /> 91 } else { 92 return null 93 }
··· 15 import * as RepostModal from './Repost' 16 import * as CropImageModal from './crop-image/CropImage.web' 17 import * as AltTextImageModal from './AltImage' 18 + import * as EditImageModal from './EditImage' 19 import * as ChangeHandleModal from './ChangeHandle' 20 import * as WaitlistModal from './Waitlist' 21 import * as InviteCodesModal from './InviteCodes' ··· 48 } 49 50 const onPressMask = () => { 51 + if (modal.name === 'crop-image' || modal.name === 'edit-image') { 52 return // dont close on mask presses during crop 53 } 54 store.shell.closeModal() ··· 89 element = <ContentLanguagesSettingsModal.Component /> 90 } else if (modal.name === 'alt-text-image') { 91 element = <AltTextImageModal.Component {...modal} /> 92 + } else if (modal.name === 'edit-image') { 93 + element = <EditImageModal.Component {...modal} /> 94 } else { 95 return null 96 }
+7
yarn.lock
··· 8713 resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-4.1.1.tgz#efadbb17de1861106864820194900f336dd641b6" 8714 integrity sha512-ciEHVokU0f6w0eTxdRxLCio6tskMsjxWIoV92+/ZD37qePUJYMfEphPhu1sruyvMBNR8/j5iyOvPFVGTfO8oxA== 8715 8716 expo-image-picker@~14.1.1: 8717 version "14.1.1" 8718 resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-14.1.1.tgz#181f1348ba6a43df7b87cee4a601d45c79b7c2d7"
··· 8713 resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-4.1.1.tgz#efadbb17de1861106864820194900f336dd641b6" 8714 integrity sha512-ciEHVokU0f6w0eTxdRxLCio6tskMsjxWIoV92+/ZD37qePUJYMfEphPhu1sruyvMBNR8/j5iyOvPFVGTfO8oxA== 8715 8716 + expo-image-manipulator@^11.1.1: 8717 + version "11.1.1" 8718 + resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-11.1.1.tgz#bb54df80e98abc9798876e3f70596a5b880168c9" 8719 + integrity sha512-W9LfJK/IL7EhhkkC1JQnEX/1S9B09rcGasJiQjXc2s1bEsrQnqXvXEv7shUW8b/L8rE+ynf+XvvDE+YIDL7oFg== 8720 + dependencies: 8721 + expo-image-loader "~4.1.0" 8722 + 8723 expo-image-picker@~14.1.1: 8724 version "14.1.1" 8725 resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-14.1.1.tgz#181f1348ba6a43df7b87cee4a601d45c79b7c2d7"