mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
2import {Pressable, StyleSheet, View} from 'react-native'
3import {useWindowDimensions} from 'react-native'
4import {LinearGradient} from 'expo-linear-gradient'
5import {MaterialIcons} from '@expo/vector-icons'
6import {msg, Trans} from '@lingui/macro'
7import {useLingui} from '@lingui/react'
8import {Slider} from '@miblanchard/react-native-slider'
9import {observer} from 'mobx-react-lite'
10import ImageEditor, {Position} from 'react-avatar-editor'
11
12import {useModalControls} from '#/state/modals'
13import {MAX_ALT_TEXT} from 'lib/constants'
14import {usePalette} from 'lib/hooks/usePalette'
15import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
16import {RectTallIcon, RectWideIcon, SquareIcon} from 'lib/icons'
17import {enforceLen} from 'lib/strings/helpers'
18import {gradients, s} from 'lib/styles'
19import {useTheme} from 'lib/ThemeContext'
20import {getKeys} from 'lib/type-assertions'
21import {GalleryModel} from 'state/models/media/gallery'
22import {ImageModel} from 'state/models/media/image'
23import {Text} from '../util/text/Text'
24import {TextInput} from './util'
25
26export const snapPoints = ['80%']
27
28const RATIOS = {
29 '4:3': {
30 Icon: RectWideIcon,
31 },
32 '1:1': {
33 Icon: SquareIcon,
34 },
35 '3:4': {
36 Icon: RectTallIcon,
37 },
38 None: {
39 label: 'None',
40 Icon: MaterialIcons,
41 name: 'do-not-disturb-alt',
42 },
43} as const
44
45type AspectRatio = keyof typeof RATIOS
46
47interface Props {
48 image: ImageModel
49 gallery: GalleryModel
50}
51
52export const Component = observer(function EditImageImpl({
53 image,
54 gallery,
55}: Props) {
56 const pal = usePalette('default')
57 const theme = useTheme()
58 const {_} = useLingui()
59 const windowDimensions = useWindowDimensions()
60 const {isMobile} = useWebMediaQueries()
61 const {closeModal} = useModalControls()
62
63 const {
64 aspectRatio,
65 // rotate = 0
66 } = image.attributes
67
68 const editorRef = useRef<ImageEditor>(null)
69 const [scale, setScale] = useState<number>(image.attributes.scale ?? 1)
70 const [position, setPosition] = useState<Position | undefined>(
71 image.attributes.position,
72 )
73 const [altText, setAltText] = useState(image?.altText ?? '')
74
75 const onFlipHorizontal = useCallback(() => {
76 image.flipHorizontal()
77 }, [image])
78
79 const onFlipVertical = useCallback(() => {
80 image.flipVertical()
81 }, [image])
82
83 // const onSetRotate = useCallback(
84 // (direction: 'left' | 'right') => {
85 // const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360
86 // image.setRotate(rotation)
87 // },
88 // [rotate, image],
89 // )
90
91 const onSetRatio = useCallback(
92 (ratio: AspectRatio) => {
93 image.setRatio(ratio)
94 },
95 [image],
96 )
97
98 const adjustments = useMemo(
99 () => [
100 // {
101 // name: 'rotate-left' as const,
102 // label: 'Rotate left',
103 // onPress: () => {
104 // onSetRotate('left')
105 // },
106 // },
107 // {
108 // name: 'rotate-right' as const,
109 // label: 'Rotate right',
110 // onPress: () => {
111 // onSetRotate('right')
112 // },
113 // },
114 {
115 name: 'flip' as const,
116 label: _(msg`Flip horizontal`),
117 onPress: onFlipHorizontal,
118 },
119 {
120 name: 'flip' as const,
121 label: _(msg`Flip vertically`),
122 onPress: onFlipVertical,
123 },
124 ],
125 [onFlipHorizontal, onFlipVertical, _],
126 )
127
128 useEffect(() => {
129 image.prev = image.cropped
130 image.prevAttributes = image.attributes
131 image.resetCropped()
132 }, [image])
133
134 const onCloseModal = useCallback(() => {
135 closeModal()
136 }, [closeModal])
137
138 const onPressCancel = useCallback(async () => {
139 await gallery.previous(image)
140 onCloseModal()
141 }, [onCloseModal, gallery, image])
142
143 const onPressSave = useCallback(async () => {
144 image.setAltText(altText)
145
146 const crop = editorRef.current?.getCroppingRect()
147
148 await image.manipulate({
149 ...(crop !== undefined
150 ? {
151 crop: {
152 originX: crop.x,
153 originY: crop.y,
154 width: crop.width,
155 height: crop.height,
156 },
157 ...(scale !== 1 ? {scale} : {}),
158 ...(position !== undefined ? {position} : {}),
159 }
160 : {}),
161 })
162
163 image.prev = image.cropped
164 image.prevAttributes = image.attributes
165 onCloseModal()
166 }, [altText, image, position, scale, onCloseModal])
167
168 const getLabelIconSize = useCallback((as: AspectRatio) => {
169 switch (as) {
170 case 'None':
171 return 22
172 case '1:1':
173 return 32
174 default:
175 return 26
176 }
177 }, [])
178
179 if (image.cropped === undefined) {
180 return null
181 }
182
183 const computedWidth =
184 windowDimensions.width > 500 ? 410 : windowDimensions.width - 80
185 const sideLength = isMobile ? computedWidth : 300
186
187 const dimensions = image.getResizedDimensions(aspectRatio, sideLength)
188 const imgContainerStyles = {width: sideLength, height: sideLength}
189
190 const imgControlStyles = {
191 alignItems: 'center' as const,
192 flexDirection: isMobile ? ('column' as const) : ('row' as const),
193 gap: isMobile ? 0 : 5,
194 }
195
196 return (
197 <View
198 testID="editImageModal"
199 style={[
200 pal.view,
201 styles.container,
202 s.flex1,
203 {
204 paddingHorizontal: isMobile ? 16 : undefined,
205 },
206 ]}>
207 <Text style={[styles.title, pal.text]}>
208 <Trans>Edit image</Trans>
209 </Text>
210 <View style={[styles.gap18, s.flexRow]}>
211 <View>
212 <View
213 style={[styles.imgContainer, pal.borderDark, imgContainerStyles]}>
214 <ImageEditor
215 ref={editorRef}
216 style={styles.imgEditor}
217 image={image.cropped.path}
218 scale={scale}
219 border={0}
220 position={position}
221 onPositionChange={setPosition}
222 {...dimensions}
223 />
224 </View>
225 <Slider
226 value={scale}
227 onValueChange={(v: number | number[]) =>
228 setScale(Array.isArray(v) ? v[0] : v)
229 }
230 minimumValue={1}
231 maximumValue={3}
232 />
233 </View>
234 <View>
235 {!isMobile ? (
236 <Text type="sm-bold" style={pal.text}>
237 <Trans>Ratios</Trans>
238 </Text>
239 ) : null}
240 <View style={imgControlStyles}>
241 {getKeys(RATIOS).map(ratio => {
242 const {Icon, ...props} = RATIOS[ratio]
243 const labelIconSize = getLabelIconSize(ratio)
244 const isSelected = aspectRatio === ratio
245
246 return (
247 <Pressable
248 key={ratio}
249 onPress={() => {
250 onSetRatio(ratio)
251 }}
252 accessibilityLabel={ratio}
253 accessibilityHint="">
254 <Icon
255 size={labelIconSize}
256 style={[styles.imgControl, isSelected ? s.blue3 : pal.text]}
257 color={(isSelected ? s.blue3 : pal.text).color}
258 {...props}
259 />
260
261 <Text
262 type={isSelected ? 'xs-bold' : 'xs-medium'}
263 style={[isSelected ? s.blue3 : pal.text, s.textCenter]}>
264 {ratio}
265 </Text>
266 </Pressable>
267 )
268 })}
269 </View>
270 {!isMobile ? (
271 <Text type="sm-bold" style={[pal.text, styles.subsection]}>
272 <Trans>Transformations</Trans>
273 </Text>
274 ) : null}
275 <View style={imgControlStyles}>
276 {adjustments.map(({label, name, onPress}) => (
277 <Pressable
278 key={label}
279 onPress={onPress}
280 accessibilityLabel={label}
281 accessibilityHint=""
282 style={styles.flipBtn}>
283 <MaterialIcons
284 name={name}
285 size={label?.startsWith('Flip') ? 22 : 24}
286 style={[
287 pal.text,
288 label === _(msg`Flip vertically`)
289 ? styles.flipVertical
290 : undefined,
291 ]}
292 />
293 </Pressable>
294 ))}
295 </View>
296 </View>
297 </View>
298 <View style={[styles.gap18, styles.bottomSection, pal.border]}>
299 <Text type="sm-bold" style={pal.text} nativeID="alt-text">
300 <Trans>Accessibility</Trans>
301 </Text>
302 <TextInput
303 testID="altTextImageInput"
304 style={[
305 styles.textArea,
306 pal.border,
307 pal.text,
308 {
309 maxHeight: isMobile ? 50 : undefined,
310 },
311 ]}
312 keyboardAppearance={theme.colorScheme}
313 multiline
314 value={altText}
315 onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
316 accessibilityLabel={_(msg`Alt text`)}
317 accessibilityHint=""
318 accessibilityLabelledBy="alt-text"
319 />
320 </View>
321 <View style={styles.btns}>
322 <Pressable onPress={onPressCancel} accessibilityRole="button">
323 <Text type="xl" style={pal.link}>
324 <Trans>Cancel</Trans>
325 </Text>
326 </Pressable>
327 <Pressable onPress={onPressSave} accessibilityRole="button">
328 <LinearGradient
329 colors={[gradients.blueLight.start, gradients.blueLight.end]}
330 start={{x: 0, y: 0}}
331 end={{x: 1, y: 1}}
332 style={[styles.btn]}>
333 <Text type="xl-medium" style={s.white}>
334 <Trans context="action">Done</Trans>
335 </Text>
336 </LinearGradient>
337 </Pressable>
338 </View>
339 </View>
340 )
341})
342
343const styles = StyleSheet.create({
344 container: {
345 gap: 18,
346 height: '100%',
347 width: '100%',
348 },
349 subsection: {marginTop: 12},
350 gap18: {gap: 18},
351 title: {
352 fontWeight: 'bold',
353 fontSize: 24,
354 },
355 btns: {
356 flexDirection: 'row',
357 alignItems: 'center',
358 justifyContent: 'space-between',
359 },
360 btn: {
361 borderRadius: 4,
362 paddingVertical: 8,
363 paddingHorizontal: 24,
364 },
365 imgControl: {
366 display: 'flex',
367 alignItems: 'center',
368 justifyContent: 'center',
369 height: 40,
370 },
371 imgEditor: {
372 maxWidth: '100%',
373 },
374 imgContainer: {
375 display: 'flex',
376 alignItems: 'center',
377 justifyContent: 'center',
378 borderWidth: 1,
379 borderStyle: 'solid',
380 marginBottom: 4,
381 },
382 flipVertical: {
383 transform: [{rotate: '90deg'}],
384 },
385 flipBtn: {
386 paddingHorizontal: 4,
387 paddingVertical: 8,
388 },
389 textArea: {
390 borderWidth: 1,
391 borderRadius: 6,
392 paddingTop: 10,
393 paddingHorizontal: 12,
394 fontSize: 16,
395 height: 100,
396 textAlignVertical: 'top',
397 },
398 bottomSection: {
399 borderTopWidth: 1,
400 paddingTop: 18,
401 },
402})