diff options
author | Ollie H <renahlee@outlook.com> | 2023-05-09 12:55:44 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-05-09 14:55:44 -0500 |
commit | b0ebb6c9d17f9f6f78bf13fd2a0ba89d83a7c2a8 (patch) | |
tree | 45e68261fe810ea19a1aec76674c1091faa00fb8 /src/view/com/modals/EditImage.tsx | |
parent | 8f6b5d3df9b5a5bb61514497f3f25289513ef119 (diff) | |
download | voidsky-b0ebb6c9d17f9f6f78bf13fd2a0ba89d83a7c2a8.tar.zst |
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>
Diffstat (limited to 'src/view/com/modals/EditImage.tsx')
-rw-r--r-- | src/view/com/modals/EditImage.tsx | 418 |
1 files changed, 418 insertions, 0 deletions
diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx new file mode 100644 index 000000000..4a5d9bfde --- /dev/null +++ b/src/view/com/modals/EditImage.tsx @@ -0,0 +1,418 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {Pressable, StyleSheet, View} from 'react-native' +import {usePalette} from 'lib/hooks/usePalette' +import {useWindowDimensions} from 'react-native' +import {gradients, s} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {Text} from '../util/text/Text' +import LinearGradient from 'react-native-linear-gradient' +import {useStores} from 'state/index' +import ImageEditor, {Position} from 'react-avatar-editor' +import {TextInput} from './util' +import {enforceLen} from 'lib/strings/helpers' +import {MAX_ALT_TEXT} from 'lib/constants' +import {GalleryModel} from 'state/models/media/gallery' +import {ImageModel} from 'state/models/media/image' +import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons' +import {Slider} from '@miblanchard/react-native-slider' +import {MaterialIcons} from '@expo/vector-icons' +import {observer} from 'mobx-react-lite' +import {getKeys} from 'lib/type-assertions' + +export const snapPoints = ['80%'] + +interface Props { + image: ImageModel + gallery: GalleryModel +} + +// This is only used for desktop web +export const Component = observer(function ({image, gallery}: Props) { + const pal = usePalette('default') + const store = useStores() + const {shell} = store + const theme = useTheme() + const winDim = useWindowDimensions() + + const [altText, setAltText] = useState(image.altText) + const [aspectRatio, setAspectRatio] = useState<AspectRatio>( + image.aspectRatio ?? 'None', + ) + const [flipHorizontal, setFlipHorizontal] = useState<boolean>( + image.flipHorizontal ?? false, + ) + const [flipVertical, setFlipVertical] = useState<boolean>( + image.flipVertical ?? false, + ) + + // TODO: doesn't seem to be working correctly with crop + // const [rotation, setRotation] = useState(image.rotation ?? 0) + const [scale, setScale] = useState<number>(image.scale ?? 1) + const [position, setPosition] = useState<Position>() + const [isEditing, setIsEditing] = useState(false) + const editorRef = useRef<ImageEditor>(null) + + const imgEditorStyles = useMemo(() => { + const dim = Math.min(425, winDim.width - 24) + return {width: dim, height: dim} + }, [winDim.width]) + + const manipulationAttributes = useMemo( + () => ({ + // TODO: doesn't seem to be working correctly with crop + // ...(rotation !== undefined ? {rotate: rotation} : {}), + ...(flipHorizontal !== undefined ? {flipHorizontal} : {}), + ...(flipVertical !== undefined ? {flipVertical} : {}), + }), + [flipHorizontal, flipVertical], + ) + + useEffect(() => { + const manipulateImage = async () => { + await image.manipulate(manipulationAttributes) + } + + manipulateImage() + }, [image, manipulationAttributes]) + + const ratios = useMemo( + () => + ({ + '4:3': { + hint: 'Sets image aspect ratio to wide', + Icon: RectWideIcon, + }, + '1:1': { + hint: 'Sets image aspect ratio to square', + Icon: SquareIcon, + }, + '3:4': { + hint: 'Sets image aspect ratio to tall', + Icon: RectTallIcon, + }, + None: { + label: 'None', + hint: 'Sets image aspect ratio to tall', + Icon: MaterialIcons, + name: 'do-not-disturb-alt', + }, + } as const), + [], + ) + + type AspectRatio = keyof typeof ratios + + const onFlipHorizontal = useCallback(() => { + setFlipHorizontal(!flipHorizontal) + image.manipulate({flipHorizontal}) + }, [flipHorizontal, image]) + + const onFlipVertical = useCallback(() => { + setFlipVertical(!flipVertical) + image.manipulate({flipVertical}) + }, [flipVertical, image]) + + const adjustments = useMemo( + () => + [ + // { + // name: 'rotate-left', + // label: 'Rotate left', + // hint: 'Rotate image left', + // onPress: () => { + // const rotate = (rotation - 90) % 360 + // setRotation(rotate) + // image.manipulate({rotate}) + // }, + // }, + // { + // name: 'rotate-right', + // label: 'Rotate right', + // hint: 'Rotate image right', + // onPress: () => { + // const rotate = (rotation + 90) % 360 + // setRotation(rotate) + // image.manipulate({rotate}) + // }, + // }, + { + name: 'flip', + label: 'Flip horizontal', + hint: 'Flip image horizontally', + onPress: onFlipHorizontal, + }, + { + name: 'flip', + label: 'Flip vertically', + hint: 'Flip image vertically', + onPress: onFlipVertical, + }, + ] as const, + [onFlipHorizontal, onFlipVertical], + ) + + useEffect(() => { + image.prev = image.compressed + setIsEditing(true) + }, [image]) + + const onCloseModal = useCallback(() => { + shell.closeModal() + setIsEditing(false) + }, [shell]) + + const onPressCancel = useCallback(async () => { + await gallery.previous(image) + onCloseModal() + }, [onCloseModal, gallery, image]) + + const onPressSave = useCallback(async () => { + image.setAltText(altText) + + const crop = editorRef.current?.getCroppingRect() + + await image.manipulate({ + ...(crop !== undefined + ? { + crop: { + originX: crop.x, + originY: crop.y, + width: crop.width, + height: crop.height, + }, + ...(scale !== 1 ? {scale} : {}), + ...(position !== undefined ? {position} : {}), + } + : {}), + ...manipulationAttributes, + aspectRatio, + }) + + image.prevAttributes = manipulationAttributes + onCloseModal() + }, [ + altText, + aspectRatio, + image, + manipulationAttributes, + position, + scale, + onCloseModal, + ]) + + const onPressRatio = useCallback((as: AspectRatio) => { + setAspectRatio(as) + }, []) + + const getLabelIconSize = useCallback((as: AspectRatio) => { + switch (as) { + case 'None': + return 22 + case '1:1': + return 32 + default: + return 26 + } + }, []) + + // Prevents preliminary flash when transformations are being applied + if (image.compressed === undefined) { + return null + } + + const {width, height} = image.getDisplayDimensions( + aspectRatio, + imgEditorStyles.width, + ) + + return ( + <View testID="editImageModal" style={[pal.view, styles.container, s.flex1]}> + <Text style={[styles.title, pal.text]}>Edit image</Text> + <View> + <View style={[styles.imgContainer, imgEditorStyles, pal.borderDark]}> + <ImageEditor + ref={editorRef} + style={styles.imgEditor} + image={isEditing ? image.compressed.path : image.path} + width={width} + height={height} + scale={scale} + border={0} + position={position} + onPositionChange={setPosition} + /> + </View> + <Slider + value={scale} + onValueChange={(v: number | number[]) => + setScale(Array.isArray(v) ? v[0] : v) + } + minimumValue={1} + maximumValue={3} + /> + <View style={[s.flexRow, styles.gap18]}> + <View style={styles.imgControls}> + {getKeys(ratios).map(ratio => { + const {hint, Icon, ...props} = ratios[ratio] + const labelIconSize = getLabelIconSize(ratio) + const isSelected = aspectRatio === ratio + + return ( + <Pressable + key={ratio} + onPress={() => { + onPressRatio(ratio) + }} + accessibilityLabel={ratio} + accessibilityHint={hint}> + <Icon + size={labelIconSize} + style={[styles.imgControl, isSelected ? s.blue3 : pal.text]} + color={(isSelected ? s.blue3 : pal.text).color} + {...props} + /> + + <Text + type={isSelected ? 'xs-bold' : 'xs-medium'} + style={[isSelected ? s.blue3 : pal.text, s.textCenter]}> + {ratio} + </Text> + </Pressable> + ) + })} + </View> + <View style={[styles.verticalSep, pal.border]} /> + <View style={styles.imgControls}> + {adjustments.map(({label, hint, name, onPress}) => ( + <Pressable + key={label} + onPress={onPress} + accessibilityLabel={label} + accessibilityHint={hint} + style={styles.flipBtn}> + <MaterialIcons + name={name} + size={label.startsWith('Flip') ? 22 : 24} + style={[ + pal.text, + label === 'Flip vertically' + ? styles.flipVertical + : undefined, + ]} + /> + </Pressable> + ))} + </View> + </View> + </View> + <View style={[styles.gap18]}> + <TextInput + testID="altTextImageInput" + style={[styles.textArea, pal.border, pal.text]} + keyboardAppearance={theme.colorScheme} + multiline + value={altText} + onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} + placeholder="Image description" + placeholderTextColor={pal.colors.textLight} + accessibilityLabel="Image alt text" + accessibilityHint="Sets image alt text for screenreaders" + accessibilityLabelledBy="imageAltText" + /> + </View> + <View style={styles.btns}> + <Pressable onPress={onPressCancel} accessibilityRole="button"> + <Text type="xl" style={pal.link}> + Cancel + </Text> + </Pressable> + <Pressable onPress={onPressSave} accessibilityRole="button"> + <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}> + Done + </Text> + </LinearGradient> + </Pressable> + </View> + </View> + ) +}) + +const styles = StyleSheet.create({ + container: { + gap: 18, + paddingVertical: 18, + paddingHorizontal: 12, + height: '100%', + width: '100%', + }, + gap18: { + gap: 18, + }, + + title: { + fontWeight: 'bold', + fontSize: 24, + }, + + textArea: { + borderWidth: 1, + borderRadius: 6, + paddingTop: 10, + paddingHorizontal: 12, + fontSize: 16, + height: 100, + textAlignVertical: 'top', + }, + + btns: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + btn: { + borderRadius: 4, + paddingVertical: 8, + paddingHorizontal: 24, + }, + + verticalSep: { + borderLeftWidth: 1, + }, + + imgControls: { + flexDirection: 'row', + gap: 5, + }, + imgControl: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: 40, + }, + flipVertical: { + transform: [{rotate: '90deg'}], + }, + flipBtn: { + paddingHorizontal: 4, + paddingVertical: 8, + }, + imgEditor: { + maxWidth: '100%', + }, + imgContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: 425, + width: 425, + borderWidth: 1, + borderRadius: 8, + borderStyle: 'solid', + overflow: 'hidden', + }, +}) |