feat: new image editor
This commit is contained in:
@@ -164,6 +164,7 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-avatar-editor": "^13.0.0",
|
"react-avatar-editor": "^13.0.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-image-crop": "^11.0.5",
|
||||||
"react-keyed-flatten-children": "^3.0.0",
|
"react-keyed-flatten-children": "^3.0.0",
|
||||||
"react-native": "0.73.2",
|
"react-native": "0.73.2",
|
||||||
"react-native-date-picker": "^4.4.0",
|
"react-native-date-picker": "^4.4.0",
|
||||||
|
|||||||
+5
-16
@@ -8,10 +8,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
Action,
|
Action,
|
||||||
ActionCrop,
|
ActionCrop,
|
||||||
ActionExtent,
|
|
||||||
ActionFlip,
|
|
||||||
ActionResize,
|
|
||||||
ActionRotate,
|
|
||||||
manipulateAsync,
|
manipulateAsync,
|
||||||
SaveFormat,
|
SaveFormat,
|
||||||
} from 'expo-image-manipulator'
|
} from 'expo-image-manipulator'
|
||||||
@@ -23,9 +19,7 @@ import {openCropper} from '#/lib/media/picker'
|
|||||||
import {getDataUriSize} from '#/lib/media/util'
|
import {getDataUriSize} from '#/lib/media/util'
|
||||||
import {isIOS, isNative} from '#/platform/detection'
|
import {isIOS, isNative} from '#/platform/detection'
|
||||||
|
|
||||||
export type ImageTransformation = Partial<
|
export type ImageTransformation = Partial<ActionCrop>
|
||||||
ActionCrop & ActionExtent & ActionFlip & ActionResize & ActionRotate
|
|
||||||
>
|
|
||||||
|
|
||||||
export type ImageMeta = {
|
export type ImageMeta = {
|
||||||
path: string
|
path: string
|
||||||
@@ -44,10 +38,11 @@ type ComposerImageBase = {
|
|||||||
}
|
}
|
||||||
type ComposerImageWithoutTransformation = ComposerImageBase & {
|
type ComposerImageWithoutTransformation = ComposerImageBase & {
|
||||||
transformed?: undefined
|
transformed?: undefined
|
||||||
|
manips?: undefined
|
||||||
}
|
}
|
||||||
type ComposerImageWithTransformation = ComposerImageBase & {
|
type ComposerImageWithTransformation = ComposerImageBase & {
|
||||||
transformed: ImageMeta
|
transformed: ImageMeta
|
||||||
transformations?: ImageTransformation
|
manips?: ImageTransformation
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ComposerImage =
|
export type ComposerImage =
|
||||||
@@ -161,13 +156,7 @@ export async function manipulateImage(
|
|||||||
img: ComposerImage,
|
img: ComposerImage,
|
||||||
trans: ImageTransformation,
|
trans: ImageTransformation,
|
||||||
): Promise<ComposerImage> {
|
): Promise<ComposerImage> {
|
||||||
const rawActions: (Action | undefined)[] = [
|
const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}]
|
||||||
trans.extent && {extent: trans.extent},
|
|
||||||
trans.flip && {flip: trans.flip},
|
|
||||||
trans.rotate !== undefined ? {rotate: trans.rotate} : undefined,
|
|
||||||
trans.resize && {resize: trans.resize},
|
|
||||||
trans.crop && {crop: trans.crop},
|
|
||||||
]
|
|
||||||
|
|
||||||
const actions = rawActions.filter((a): a is Action => a !== undefined)
|
const actions = rawActions.filter((a): a is Action => a !== undefined)
|
||||||
|
|
||||||
@@ -193,7 +182,7 @@ export async function manipulateImage(
|
|||||||
height: result.height,
|
height: result.height,
|
||||||
mime: 'image/png',
|
mime: 'image/png',
|
||||||
},
|
},
|
||||||
transformations: trans,
|
manips: trans,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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, {PixelCrop} from 'react-image-crop'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} 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 t = useTheme()
|
||||||
|
|
||||||
|
const source = image.source
|
||||||
|
const manips = image.manips
|
||||||
|
|
||||||
|
const {initialCrop, initialAspect, sourceAspect} = React.useMemo(() => {
|
||||||
|
const initialArea = manips?.crop
|
||||||
|
|
||||||
|
let crop: PixelCrop | undefined
|
||||||
|
|
||||||
|
if (initialArea) {
|
||||||
|
crop = {
|
||||||
|
unit: 'px',
|
||||||
|
x: initialArea.originX,
|
||||||
|
y: initialArea.originY,
|
||||||
|
width: initialArea.width,
|
||||||
|
height: initialArea.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalAspect = source.width / source.height
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialCrop: crop,
|
||||||
|
initialAspect: crop ? crop.width / crop.height : originalAspect,
|
||||||
|
sourceAspect: originalAspect,
|
||||||
|
}
|
||||||
|
}, [source, manips])
|
||||||
|
|
||||||
|
const [crop, setCrop] = React.useState(initialCrop)
|
||||||
|
const [aspect, setAspect] = React.useState(initialAspect)
|
||||||
|
|
||||||
|
const isEmpty = !crop || (crop.width || crop.height) === 0
|
||||||
|
const isNew = initialCrop ? true : !isEmpty
|
||||||
|
|
||||||
|
const onPressSubmit = React.useCallback(async () => {
|
||||||
|
const result = await manipulateImage(image, {
|
||||||
|
crop: !isEmpty
|
||||||
|
? {
|
||||||
|
originX: crop.x,
|
||||||
|
originY: crop.y,
|
||||||
|
width: crop.width,
|
||||||
|
height: crop.height,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
onChange(result)
|
||||||
|
control.close()
|
||||||
|
}, [crop, isEmpty, image, control, onChange])
|
||||||
|
|
||||||
|
const changeAspect = (next: number) => {
|
||||||
|
if (next !== aspect) {
|
||||||
|
setAspect(next)
|
||||||
|
setCrop(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Inner label={_(msg`Edit image`)}>
|
||||||
|
<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
|
||||||
|
aspect={aspect}
|
||||||
|
crop={crop}
|
||||||
|
onChange={setCrop}
|
||||||
|
className="ReactCrop--no-animate">
|
||||||
|
<img src={source.path} style={{maxHeight: `50vh`}} />
|
||||||
|
</ReactCrop>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.mt_md, a.gap_md]}>
|
||||||
|
<View style={[a.flex_row]}>
|
||||||
|
<ToolbarButton
|
||||||
|
label={_(msg`Original`)}
|
||||||
|
onPress={() => changeAspect(sourceAspect)}>
|
||||||
|
<Svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path
|
||||||
|
d="M3 5H21V19H3V5Z"
|
||||||
|
stroke={
|
||||||
|
aspect === sourceAspect
|
||||||
|
? t.palette.primary_600
|
||||||
|
: t.palette.white
|
||||||
|
}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
label={_(msg`Wide`)}
|
||||||
|
onPress={() => changeAspect(16 / 9)}>
|
||||||
|
<Svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path
|
||||||
|
d="M1 7H23V17H1V7Z"
|
||||||
|
stroke={
|
||||||
|
aspect === 16 / 9 ? t.palette.primary_600 : t.palette.white
|
||||||
|
}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
label={_(msg`Square`)}
|
||||||
|
onPress={() => changeAspect(1 / 1)}>
|
||||||
|
<Svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path
|
||||||
|
d="M4 4H20V20H4V4Z"
|
||||||
|
stroke={
|
||||||
|
aspect === 1 / 1 ? t.palette.primary_600 : t.palette.white
|
||||||
|
}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={!isNew}
|
||||||
|
label={_(msg`Save`)}
|
||||||
|
size="medium"
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
onPress={onPressSubmit}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Dialog.Inner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToolbarButton = ({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
children: React.ReactElement
|
||||||
|
label: string
|
||||||
|
onPress?: () => void
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onPress={onPress}
|
||||||
|
label={label}
|
||||||
|
style={a.p_sm}
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
color="primary">
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
import {Path, Svg} from 'react-native-svg'
|
||||||
|
|
||||||
|
import {manipulateImage} from '#/state/gallery'
|
||||||
@@ -14,6 +14,7 @@ import {isNative} from 'platform/detection'
|
|||||||
import {Text} from 'view/com/util/text/Text'
|
import {Text} from 'view/com/util/text/Text'
|
||||||
import {useTheme} from '#/alf'
|
import {useTheme} from '#/alf'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {EditImageDialog} from './EditImageDialog'
|
||||||
import {ImageAltTextDialog} from './ImageAltTextDialog'
|
import {ImageAltTextDialog} from './ImageAltTextDialog'
|
||||||
|
|
||||||
const IMAGE_GAP = 8
|
const IMAGE_GAP = 8
|
||||||
@@ -137,12 +138,15 @@ const GalleryItem = ({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const altTextControl = Dialog.useDialogControl()
|
const altTextControl = Dialog.useDialogControl()
|
||||||
|
const editControl = Dialog.useDialogControl()
|
||||||
|
|
||||||
const onEdit = () => {
|
const onEdit = () => {
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
cropImage(image).then(next => {
|
cropImage(image).then(next => {
|
||||||
onChange(next)
|
onChange(next)
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
editControl.open()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,21 +177,15 @@ const GalleryItem = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={imageControlsStyle}>
|
<View style={imageControlsStyle}>
|
||||||
{isNative && (
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
testID="editPhotoButton"
|
||||||
testID="editPhotoButton"
|
accessibilityRole="button"
|
||||||
accessibilityRole="button"
|
accessibilityLabel={_(msg`Edit image`)}
|
||||||
accessibilityLabel={_(msg`Edit image`)}
|
accessibilityHint=""
|
||||||
accessibilityHint=""
|
onPress={onEdit}
|
||||||
onPress={onEdit}
|
style={styles.imageControl}>
|
||||||
style={styles.imageControl}>
|
<FontAwesomeIcon icon="pen" size={12} style={{color: colors.white}} />
|
||||||
<FontAwesomeIcon
|
</TouchableOpacity>
|
||||||
icon="pen"
|
|
||||||
size={12}
|
|
||||||
style={{color: colors.white}}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="removePhotoButton"
|
testID="removePhotoButton"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -225,6 +223,12 @@ const GalleryItem = ({
|
|||||||
image={image}
|
image={image}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EditImageDialog
|
||||||
|
control={editControl}
|
||||||
|
image={image}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18664,6 +18664,11 @@ react-freeze@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.3.tgz#5e3ca90e682fed1d73a7cb50c2c7402b3e85618d"
|
resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.3.tgz#5e3ca90e682fed1d73a7cb50c2c7402b3e85618d"
|
||||||
integrity sha512-ZnXwLQnGzrDpHBHiC56TXFXvmolPeMjTn1UOm610M4EXGzbEDR7oOIyS2ZiItgbs6eZc4oU/a0hpk8PrcKvv5g==
|
integrity sha512-ZnXwLQnGzrDpHBHiC56TXFXvmolPeMjTn1UOm610M4EXGzbEDR7oOIyS2ZiItgbs6eZc4oU/a0hpk8PrcKvv5g==
|
||||||
|
|
||||||
|
react-image-crop@^11.0.5:
|
||||||
|
version "11.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-11.0.5.tgz#c7abcf9cae28305d253d55d481158a594a937867"
|
||||||
|
integrity sha512-A/Y/kspOzki1zDL/bSgwWIY1X3CQ9F1QwpdnncWLBVAktnKfAZDIQnWmjXzuzEjZHDMsBlArytIcPBVi6DNklg==
|
||||||
|
|
||||||
"react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.0.0, react-is@^18.2.0:
|
"react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.0.0, react-is@^18.2.0:
|
||||||
version "18.2.0"
|
version "18.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
||||||
|
|||||||
Reference in New Issue
Block a user