Skip to content

Commit

Permalink
Revamp image editor (#5462)
Browse files Browse the repository at this point in the history
* new image editor

* Rm react-avatar-editor

---------

Co-authored-by: Dan Abramov <[email protected]>
  • Loading branch information
mary-ext and gaearon authored Sep 24, 2024
1 parent ed512d6 commit b951620
Show file tree
Hide file tree
Showing 14 changed files with 318 additions and 293 deletions.
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,9 @@
"postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"react": "18.2.0",
"react-avatar-editor": "^13.0.0",
"react-compiler-runtime": "file:./lib/react-compiler-runtime",
"react-dom": "^18.2.0",
"react-image-crop": "^11.0.7",
"react-keyed-flatten-children": "^3.0.0",
"react-native": "0.74.1",
"react-native-compressor": "^1.8.24",
Expand Down Expand Up @@ -236,7 +236,6 @@
"@types/lodash.set": "^4.3.7",
"@types/lodash.shuffle": "^4.2.7",
"@types/psl": "^1.1.1",
"@types/react-avatar-editor": "^13.0.0",
"@types/react-dom": "^18.2.18",
"@types/react-responsive": "^8.0.5",
"@types/react-test-renderer": "^17.0.1",
Expand Down
4 changes: 3 additions & 1 deletion src/lib/media/picker.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ export async function openCropper(opts: CropperOptions): Promise<RNImage> {
name: 'crop-image',
uri: opts.path,
dimensions:
opts.height && opts.width
opts.width && opts.height
? {width: opts.width, height: opts.height}
: undefined,
aspect: opts.webAspectRatio,
circular: opts.webCircularCrop,
onSelect: (img?: RNImage) => {
if (img) {
resolve(img)
Expand Down
5 changes: 4 additions & 1 deletion src/lib/media/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ export interface CameraOpts {
cropperCircleOverlay?: boolean
}

export type CropperOptions = Parameters<typeof openCropper>[0]
export type CropperOptions = Parameters<typeof openCropper>[0] & {
webAspectRatio?: number
webCircularCrop?: boolean
}
2 changes: 2 additions & 0 deletions src/state/modals/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export interface CropImageModal {
name: 'crop-image'
uri: string
dimensions?: {width: number; height: number}
aspect?: number
circular?: boolean
onSelect: (img?: RNImage) => void
}

Expand Down
14 changes: 14 additions & 0 deletions src/view/com/composer/photos/EditImageDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React from 'react'

import {ComposerImage} from '#/state/gallery'
import * as Dialog from '#/components/Dialog'

export type EditImageDialogProps = {
control: Dialog.DialogOuterProps['control']
image: ComposerImage
onChange: (next: ComposerImage) => void
}

export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
return null
}
105 changes: 105 additions & 0 deletions src/view/com/composer/photos/EditImageDialog.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import 'react-image-crop/dist/ReactCrop.css'

import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import ReactCrop, {PercentCrop} from 'react-image-crop'

import {
ImageSource,
ImageTransformation,
manipulateImage,
} from '#/state/gallery'
import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
import {EditImageDialogProps} from './EditImageDialog'

export const EditImageDialog = (props: EditImageDialogProps) => {
return (
<Dialog.Outer control={props.control}>
<EditImageInner key={props.image.source.id} {...props} />
</Dialog.Outer>
)
}

const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
const {_} = useLingui()

const source = image.source

const initialCrop = getInitialCrop(source, image.manips)
const [crop, setCrop] = React.useState(initialCrop)

const isEmpty = !crop || (crop.width || crop.height) === 0
const isNew = initialCrop ? true : !isEmpty

const onPressSubmit = React.useCallback(async () => {
const result = await manipulateImage(image, {
crop:
crop && (crop.width || crop.height) !== 0
? {
originX: (crop.x * source.width) / 100,
originY: (crop.y * source.height) / 100,
width: (crop.width * source.width) / 100,
height: (crop.height * source.height) / 100,
}
: undefined,
})

onChange(result)
control.close()
}, [crop, image, source, control, onChange])

return (
<Dialog.Inner label={_(msg`Edit image`)}>
<Dialog.Close />

<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
<Trans>Edit image</Trans>
</Text>

<View style={[a.align_center]}>
<ReactCrop
crop={crop}
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
className="ReactCrop--no-animate">
<img src={source.path} style={{maxHeight: `50vh`}} />
</ReactCrop>
</View>

<View style={[a.mt_md, a.gap_md]}>
<Button
disabled={!isNew}
label={_(msg`Save`)}
size="large"
color="primary"
variant="solid"
onPress={onPressSubmit}>
<ButtonText>
<Trans>Save</Trans>
</ButtonText>
</Button>
</View>
</Dialog.Inner>
)
}

const getInitialCrop = (
source: ImageSource,
manips: ImageTransformation | undefined,
): PercentCrop | undefined => {
const initialArea = manips?.crop

if (initialArea) {
return {
unit: '%',
x: (initialArea.originX / source.width) * 100,
y: (initialArea.originY / source.height) * 100,
width: (initialArea.width / source.width) * 100,
height: (initialArea.height / source.height) * 100,
}
}
}
34 changes: 19 additions & 15 deletions src/view/com/composer/photos/Gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery'
import {Text} from '#/view/com/util/text/Text'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {EditImageDialog} from './EditImageDialog'
import {ImageAltTextDialog} from './ImageAltTextDialog'

const IMAGE_GAP = 8
Expand Down Expand Up @@ -144,12 +145,15 @@ const GalleryItem = ({
const t = useTheme()

const altTextControl = Dialog.useDialogControl()
const editControl = Dialog.useDialogControl()

const onImageEdit = () => {
if (isNative) {
cropImage(image).then(next => {
onChange(next)
})
} else {
editControl.open()
}
}

Expand Down Expand Up @@ -185,21 +189,15 @@ const GalleryItem = ({
</Text>
</TouchableOpacity>
<View style={imageControlsStyle}>
{isNative && (
<TouchableOpacity
testID="editPhotoButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Edit image`)}
accessibilityHint=""
onPress={onImageEdit}
style={styles.imageControl}>
<FontAwesomeIcon
icon="pen"
size={12}
style={{color: colors.white}}
/>
</TouchableOpacity>
)}
<TouchableOpacity
testID="editPhotoButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Edit image`)}
accessibilityHint=""
onPress={onImageEdit}
style={styles.imageControl}>
<FontAwesomeIcon icon="pen" size={12} style={{color: colors.white}} />
</TouchableOpacity>
<TouchableOpacity
testID="removePhotoButton"
accessibilityRole="button"
Expand Down Expand Up @@ -237,6 +235,12 @@ const GalleryItem = ({
image={image}
onChange={onChange}
/>

<EditImageDialog
control={editControl}
image={image}
onChange={onChange}
/>
</View>
)
}
Expand Down
145 changes: 145 additions & 0 deletions src/view/com/modals/CropImage.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import ReactCrop, {PercentCrop} from 'react-image-crop'

import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {getDataUriSize} from '#/lib/media/util'
import {gradients, s} from '#/lib/styles'
import {useModalControls} from '#/state/modals'
import {Text} from '#/view/com/util/text/Text'

export const snapPoints = ['0%']

export function Component({
uri,
aspect,
circular,
onSelect,
}: {
uri: string
aspect?: number
circular?: boolean
onSelect: (img?: RNImage) => void
}) {
const pal = usePalette('default')
const {_} = useLingui()

const {closeModal} = useModalControls()
const {isMobile} = useWebMediaQueries()

const imageRef = React.useRef<HTMLImageElement>(null)
const [crop, setCrop] = React.useState<PercentCrop>()

const isEmpty = !crop || (crop.width || crop.height) === 0

const onPressCancel = () => {
onSelect(undefined)
closeModal()
}
const onPressDone = async () => {
const img = imageRef.current!

const result = await manipulateAsync(
uri,
isEmpty
? []
: [
{
crop: {
originX: (crop.x * img.naturalWidth) / 100,
originY: (crop.y * img.naturalHeight) / 100,
width: (crop.width * img.naturalWidth) / 100,
height: (crop.height * img.naturalHeight) / 100,
},
},
],
{
base64: true,
format: SaveFormat.JPEG,
},
)

onSelect({
path: result.uri,
mime: 'image/jpeg',
size: result.base64 !== undefined ? getDataUriSize(result.base64) : 0,
width: result.width,
height: result.height,
})

closeModal()
}

return (
<View>
<View style={[styles.cropper, pal.borderDark]}>
<ReactCrop
aspect={aspect}
crop={crop}
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
circularCrop={circular}>
<img ref={imageRef} src={uri} style={{maxHeight: '75vh'}} />
</ReactCrop>
</View>
<View style={[styles.btns, isMobile && {paddingHorizontal: 16}]}>
<TouchableOpacity
onPress={onPressCancel}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel image crop`)}
accessibilityHint={_(msg`Exits image cropping process`)}>
<Text type="xl" style={pal.link}>
<Trans>Cancel</Trans>
</Text>
</TouchableOpacity>
<View style={s.flex1} />
<TouchableOpacity
onPress={onPressDone}
accessibilityRole="button"
accessibilityLabel={_(msg`Save image crop`)}
accessibilityHint={_(msg`Saves image crop settings`)}>
<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}>
<Trans>Done</Trans>
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
)
}

const styles = StyleSheet.create({
cropper: {
marginLeft: 'auto',
marginRight: 'auto',
borderWidth: 1,
borderRadius: 4,
overflow: 'hidden',
alignItems: 'center',
},
ctrls: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
},
btns: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
},
btn: {
borderRadius: 4,
paddingVertical: 8,
paddingHorizontal: 24,
},
})
2 changes: 1 addition & 1 deletion src/view/com/modals/Modal.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as ChangeEmailModal from './ChangeEmail'
import * as ChangeHandleModal from './ChangeHandle'
import * as ChangePasswordModal from './ChangePassword'
import * as CreateOrEditListModal from './CreateOrEditList'
import * as CropImageModal from './crop-image/CropImage.web'
import * as CropImageModal from './CropImage.web'
import * as DeleteAccountModal from './DeleteAccount'
import * as EditProfileModal from './EditProfile'
import * as InviteCodesModal from './InviteCodes'
Expand Down
Loading

0 comments on commit b951620

Please sign in to comment.