forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import 'react-image-crop/dist/ReactCrop.css'
2
3import {useCallback, useImperativeHandle, useRef, useState} from 'react'
4import {View} from 'react-native'
5import {msg, Trans} from '@lingui/macro'
6import {useLingui} from '@lingui/react'
7import ReactCrop, {type PercentCrop} from 'react-image-crop'
8
9import {
10 type ImageSource,
11 type ImageTransformation,
12 manipulateImage,
13} from '#/state/gallery'
14import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
15import {atoms as a, useTheme} from '#/alf'
16import {Button, ButtonIcon, ButtonText} from '#/components/Button'
17import * as Dialog from '#/components/Dialog'
18import {Loader} from '#/components/Loader'
19import {type EditImageDialogProps} from './EditImageDialog'
20
21export function EditImageDialog(props: EditImageDialogProps) {
22 return (
23 <Dialog.Outer control={props.control} webOptions={{alignCenter: true}}>
24 <Dialog.Handle />
25 <DialogInner {...props} />
26 </Dialog.Outer>
27 )
28}
29
30function DialogInner({
31 control,
32 image,
33 onChange,
34 circularCrop,
35 aspectRatio,
36}: EditImageDialogProps) {
37 const {_} = useLingui()
38 const [pending, setPending] = useState(false)
39 const ref = useRef<{save: () => Promise<void>}>(null)
40
41 const enableSquareButtons = useEnableSquareButtons()
42
43 const cancelButton = useCallback(
44 () => (
45 <Button
46 label={_(msg`Cancel`)}
47 disabled={pending}
48 onPress={() => control.close()}
49 size="small"
50 color="primary"
51 variant="ghost"
52 style={enableSquareButtons ? [a.rounded_sm] : [a.rounded_full]}
53 testID="cropImageCancelBtn">
54 <ButtonText style={[a.text_md]}>
55 <Trans>Cancel</Trans>
56 </ButtonText>
57 </Button>
58 ),
59 [control, _, pending, enableSquareButtons],
60 )
61
62 const saveButton = useCallback(
63 () => (
64 <Button
65 label={_(msg`Save`)}
66 onPress={async () => {
67 setPending(true)
68 await ref.current?.save()
69 setPending(false)
70 }}
71 disabled={pending}
72 size="small"
73 color="primary"
74 variant="ghost"
75 style={enableSquareButtons ? [a.rounded_sm] : [a.rounded_full]}
76 testID="cropImageSaveBtn">
77 <ButtonText style={[a.text_md]}>
78 <Trans>Save</Trans>
79 </ButtonText>
80 {pending && <ButtonIcon icon={Loader} />}
81 </Button>
82 ),
83 [_, pending, enableSquareButtons],
84 )
85
86 return (
87 <Dialog.Inner
88 label={_(msg`Edit image`)}
89 header={
90 <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}>
91 <Dialog.HeaderText>
92 <Trans>Edit image</Trans>
93 </Dialog.HeaderText>
94 </Dialog.Header>
95 }>
96 {image && (
97 <EditImageInner
98 saveRef={ref}
99 key={image.source.id}
100 image={image}
101 onChange={onChange}
102 circularCrop={circularCrop}
103 aspectRatio={aspectRatio}
104 />
105 )}
106 </Dialog.Inner>
107 )
108}
109
110function EditImageInner({
111 image,
112 onChange,
113 saveRef,
114 circularCrop = false,
115 aspectRatio,
116}: Required<Pick<EditImageDialogProps, 'image'>> &
117 Omit<EditImageDialogProps, 'control' | 'image'> & {
118 saveRef: React.RefObject<{save: () => Promise<void>} | null>
119 }) {
120 const t = useTheme()
121 const [isDragging, setIsDragging] = useState(false)
122 const control = Dialog.useDialogContext()
123
124 const source = image.source
125
126 const initialCrop = getInitialCrop(source, image.manips)
127 const [crop, setCrop] = useState(initialCrop)
128
129 const onPressSubmit = useCallback(async () => {
130 const result = await manipulateImage(image, {
131 crop:
132 crop && (crop.width || crop.height) !== 0
133 ? {
134 originX: (crop.x * source.width) / 100,
135 originY: (crop.y * source.height) / 100,
136 width: (crop.width * source.width) / 100,
137 height: (crop.height * source.height) / 100,
138 }
139 : undefined,
140 })
141
142 control.close(() => {
143 onChange(result)
144 })
145 }, [crop, image, source, control, onChange])
146
147 useImperativeHandle(
148 saveRef,
149 () => ({
150 save: onPressSubmit,
151 }),
152 [onPressSubmit],
153 )
154
155 return (
156 <View
157 style={[
158 a.mx_auto,
159 a.border,
160 t.atoms.border_contrast_low,
161 a.rounded_xs,
162 a.overflow_hidden,
163 a.align_center,
164 ]}>
165 <ReactCrop
166 crop={crop}
167 aspect={aspectRatio}
168 circularCrop={circularCrop}
169 onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
170 className="ReactCrop--no-animate"
171 onDragStart={() => setIsDragging(true)}
172 onDragEnd={() => setIsDragging(false)}>
173 <img src={source.path} style={{maxHeight: `50vh`}} />
174 </ReactCrop>
175 {/* Eat clicks when dragging, otherwise mousing up over the backdrop
176 causes the dialog to close */}
177 {isDragging && <View style={[a.fixed, a.inset_0]} />}
178 </View>
179 )
180}
181
182const getInitialCrop = (
183 source: ImageSource,
184 manips: ImageTransformation | undefined,
185): PercentCrop | undefined => {
186 const initialArea = manips?.crop
187
188 if (initialArea) {
189 return {
190 unit: '%',
191 x: (initialArea.originX / source.width) * 100,
192 y: (initialArea.originY / source.height) * 100,
193 width: (initialArea.width / source.width) * 100,
194 height: (initialArea.height / source.height) * 100,
195 }
196 }
197}