Image editor mobile layout update (#613)

* Image editor mobile layout update

* Minor viewport fix
zio/stable
Ollie H 2023-05-15 14:54:14 -07:00 committed by GitHub
parent aa786068cf
commit e2055dfb78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 219 additions and 256 deletions

View File

@ -52,16 +52,14 @@ export class GalleryModel {
}
async edit(image: ImageModel) {
if (!isNative) {
if (isNative) {
this.crop(image)
} else {
this.rootStore.shell.openModal({
name: 'edit-image',
image,
gallery: this,
})
return
} else {
this.crop(image)
}
}

View File

@ -13,12 +13,12 @@ import {compressAndResizeImageForPost} from 'lib/media/manip'
// Cases to consider: ExternalEmbed
export interface ImageManipulationAttributes {
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
rotate?: number
scale?: number
position?: Position
flipHorizontal?: boolean
flipVertical?: boolean
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
}
export class ImageModel implements RNImage {
@ -34,14 +34,14 @@ export class ImageModel implements RNImage {
scaledHeight: number = POST_IMG_MAX.height
// Web manipulation
aspectRatio?: ImageManipulationAttributes['aspectRatio']
position?: Position = undefined
prev?: RNImage = undefined
rotation?: number = 0
scale?: number = 1
flipHorizontal?: boolean = false
flipVertical?: boolean = false
prev?: RNImage
attributes: ImageManipulationAttributes = {
aspectRatio: '1:1',
scale: 1,
flipHorizontal: false,
flipVertical: false,
rotate: 0,
}
prevAttributes: ImageManipulationAttributes = {}
constructor(public rootStore: RootStoreModel, image: RNImage) {
@ -65,6 +65,25 @@ export class ImageModel implements RNImage {
// : MAX_IMAGE_SIZE_IN_BYTES / this.size
// }
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
this.attributes.aspectRatio = aspectRatio
}
setRotate(degrees: number) {
this.attributes.rotate = degrees
this.manipulate({})
}
flipVertical() {
this.attributes.flipVertical = !this.attributes.flipVertical
this.manipulate({})
}
flipHorizontal() {
this.attributes.flipHorizontal = !this.attributes.flipHorizontal
this.manipulate({})
}
get ratioMultipliers() {
return {
'4:3': 4 / 3,
@ -162,33 +181,19 @@ export class ImageModel implements RNImage {
crop?: ActionCrop['crop']
} & ImageManipulationAttributes,
) {
const {aspectRatio, crop, flipHorizontal, flipVertical, rotate, scale} =
attributes
const {aspectRatio, crop, position, scale} = attributes
const modifiers = []
if (flipHorizontal !== undefined) {
this.flipHorizontal = flipHorizontal
}
if (flipVertical !== undefined) {
this.flipVertical = flipVertical
}
if (this.flipHorizontal) {
if (this.attributes.flipHorizontal) {
modifiers.push({flip: FlipType.Horizontal})
}
if (this.flipVertical) {
if (this.attributes.flipVertical) {
modifiers.push({flip: FlipType.Vertical})
}
// TODO: Fix rotation -- currently not functional
if (rotate !== undefined) {
this.rotation = rotate
}
if (this.rotation !== undefined) {
modifiers.push({rotate: this.rotation})
if (this.attributes.rotate !== undefined) {
modifiers.push({rotate: this.attributes.rotate})
}
if (crop !== undefined) {
@ -203,18 +208,21 @@ export class ImageModel implements RNImage {
}
if (scale !== undefined) {
this.scale = scale
this.attributes.scale = scale
}
if (position !== undefined) {
this.attributes.position = position
}
if (aspectRatio !== undefined) {
this.aspectRatio = aspectRatio
this.attributes.aspectRatio = aspectRatio
}
const ratioMultiplier = this.ratioMultipliers[this.aspectRatio ?? '1:1']
const ratioMultiplier =
this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1']
// TODO: Ollie - should support up to 2000 but smaller images that scale
// up need an updated compression factor calculation. Use 1000 for now.
const MAX_SIDE = 1000
const MAX_SIDE = 2000
const result = await ImageManipulator.manipulateAsync(
this.path,
@ -223,7 +231,7 @@ export class ImageModel implements RNImage {
{resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}},
],
{
compress: 0.7, // TODO: revisit compression calculation
compress: 0.9,
format: SaveFormat.JPEG,
},
)
@ -238,16 +246,12 @@ export class ImageModel implements RNImage {
})
}
resetCompressed() {
this.manipulate({})
}
previous() {
this.compressed = this.prev
const {flipHorizontal, flipVertical, rotate, position, scale} =
this.prevAttributes
this.scale = scale
this.rotation = rotate
this.flipHorizontal = flipHorizontal
this.flipVertical = flipVertical
this.position = position
this.attributes = this.prevAttributes
}
}

View File

@ -18,148 +18,114 @@ 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'
import {isDesktopWeb} from 'platform/detection'
export const snapPoints = ['80%']
const RATIOS = {
'4:3': {
Icon: RectWideIcon,
},
'1:1': {
Icon: SquareIcon,
},
'3:4': {
Icon: RectTallIcon,
},
None: {
label: 'None',
Icon: MaterialIcons,
name: 'do-not-disturb-alt',
},
} as const
type AspectRatio = keyof typeof RATIOS
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 store = useStores()
const windowDimensions = 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,
)
const {
aspectRatio,
// rotate = 0
} = image.attributes
// 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],
const [scale, setScale] = useState<number>(image.attributes.scale ?? 1)
const [position, setPosition] = useState<Position | undefined>(
image.attributes.position,
)
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 [altText, setAltText] = useState('')
const onFlipHorizontal = useCallback(() => {
setFlipHorizontal(!flipHorizontal)
image.manipulate({flipHorizontal})
}, [flipHorizontal, image])
image.flipHorizontal()
}, [image])
const onFlipVertical = useCallback(() => {
setFlipVertical(!flipVertical)
image.manipulate({flipVertical})
}, [flipVertical, image])
image.flipVertical()
}, [image])
// const onSetRotate = useCallback(
// (direction: 'left' | 'right') => {
// const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360
// image.setRotate(rotation)
// },
// [rotate, image],
// )
const onSetRatio = useCallback(
(ratio: AspectRatio) => {
image.setRatio(ratio)
},
[image],
)
const adjustments = useMemo(
() =>
[
() => [
// {
// name: 'rotate-left',
// name: 'rotate-left' as const,
// label: 'Rotate left',
// hint: 'Rotate image left',
// onPress: () => {
// const rotate = (rotation - 90) % 360
// setRotation(rotate)
// image.manipulate({rotate})
// onSetRotate('left')
// },
// },
// {
// name: 'rotate-right',
// name: 'rotate-right' as const,
// label: 'Rotate right',
// hint: 'Rotate image right',
// onPress: () => {
// const rotate = (rotation + 90) % 360
// setRotation(rotate)
// image.manipulate({rotate})
// onSetRotate('right')
// },
// },
{
name: 'flip',
name: 'flip' as const,
label: 'Flip horizontal',
hint: 'Flip image horizontally',
onPress: onFlipHorizontal,
},
{
name: 'flip',
name: 'flip' as const,
label: 'Flip vertically',
hint: 'Flip image vertically',
onPress: onFlipVertical,
},
] as const,
],
[onFlipHorizontal, onFlipVertical],
)
useEffect(() => {
image.prev = image.compressed
setIsEditing(true)
image.prevAttributes = image.attributes
image.resetCompressed()
}, [image])
const onCloseModal = useCallback(() => {
shell.closeModal()
setIsEditing(false)
}, [shell])
store.shell.closeModal()
}, [store.shell])
const onPressCancel = useCallback(async () => {
await gallery.previous(image)
@ -184,25 +150,12 @@ export const Component = observer(function ({image, gallery}: Props) {
...(position !== undefined ? {position} : {}),
}
: {}),
...manipulationAttributes,
aspectRatio,
})
image.prevAttributes = manipulationAttributes
image.prev = image.compressed
image.prevAttributes = image.attributes
onCloseModal()
}, [
altText,
aspectRatio,
image,
manipulationAttributes,
position,
scale,
onCloseModal,
])
const onPressRatio = useCallback((as: AspectRatio) => {
setAspectRatio(as)
}, [])
}, [altText, image, position, scale, onCloseModal])
const getLabelIconSize = useCallback((as: AspectRatio) => {
switch (as) {
@ -220,26 +173,35 @@ export const Component = observer(function ({image, gallery}: Props) {
return null
}
const {width, height} = image.getDisplayDimensions(
aspectRatio,
imgEditorStyles.width,
)
const computedWidth =
windowDimensions.width > 500 ? 410 : windowDimensions.width - 80
const sideLength = isDesktopWeb ? 300 : computedWidth
const dimensions = image.getDisplayDimensions(aspectRatio, sideLength)
const imgContainerStyles = {width: sideLength, height: sideLength}
const imgControlStyles = {
alignItems: 'center' as const,
flexDirection: isDesktopWeb ? ('row' as const) : ('column' as const),
gap: isDesktopWeb ? 5 : 0,
}
return (
<View testID="editImageModal" style={[pal.view, styles.container, s.flex1]}>
<Text style={[styles.title, pal.text]}>Edit image</Text>
<View style={[styles.gap18, s.flexRow]}>
<View>
<View style={[styles.imgContainer, imgEditorStyles, pal.borderDark]}>
<View
style={[styles.imgContainer, pal.borderDark, imgContainerStyles]}>
<ImageEditor
ref={editorRef}
style={styles.imgEditor}
image={isEditing ? image.compressed.path : image.path}
width={width}
height={height}
image={image.compressed.path}
scale={scale}
border={0}
position={position}
onPositionChange={setPosition}
{...dimensions}
/>
</View>
<Slider
@ -250,10 +212,16 @@ export const Component = observer(function ({image, gallery}: Props) {
minimumValue={1}
maximumValue={3}
/>
<View style={[s.flexRow, styles.gap18]}>
<View style={styles.imgControls}>
{getKeys(ratios).map(ratio => {
const {hint, Icon, ...props} = ratios[ratio]
</View>
<View>
{isDesktopWeb ? (
<Text type="sm-bold" style={pal.text}>
Ratios
</Text>
) : null}
<View style={imgControlStyles}>
{getKeys(RATIOS).map(ratio => {
const {Icon, ...props} = RATIOS[ratio]
const labelIconSize = getLabelIconSize(ratio)
const isSelected = aspectRatio === ratio
@ -261,10 +229,10 @@ export const Component = observer(function ({image, gallery}: Props) {
<Pressable
key={ratio}
onPress={() => {
onPressRatio(ratio)
onSetRatio(ratio)
}}
accessibilityLabel={ratio}
accessibilityHint={hint}>
accessibilityHint="">
<Icon
size={labelIconSize}
style={[styles.imgControl, isSelected ? s.blue3 : pal.text]}
@ -281,18 +249,22 @@ export const Component = observer(function ({image, gallery}: Props) {
)
})}
</View>
<View style={[styles.verticalSep, pal.border]} />
<View style={styles.imgControls}>
{adjustments.map(({label, hint, name, onPress}) => (
{isDesktopWeb ? (
<Text type="sm-bold" style={[pal.text, styles.subsection]}>
Transformations
</Text>
) : null}
<View style={imgControlStyles}>
{adjustments.map(({label, name, onPress}) => (
<Pressable
key={label}
onPress={onPress}
accessibilityLabel={label}
accessibilityHint={hint}
accessibilityHint=""
style={styles.flipBtn}>
<MaterialIcons
name={name}
size={label.startsWith('Flip') ? 22 : 24}
size={label?.startsWith('Flip') ? 22 : 24}
style={[
pal.text,
label === 'Flip vertically'
@ -305,7 +277,10 @@ export const Component = observer(function ({image, gallery}: Props) {
</View>
</View>
</View>
<View style={[styles.gap18]}>
<View style={[styles.gap18, styles.bottomSection, pal.border]}>
<Text type="sm-bold" style={pal.text} nativeID="alt-text">
Accessibility
</Text>
<TextInput
testID="altTextImageInput"
style={[styles.textArea, pal.border, pal.text]}
@ -313,11 +288,9 @@ export const Component = observer(function ({image, gallery}: Props) {
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"
accessibilityLabel="Alt text"
accessibilityHint=""
accessibilityLabelledBy="alt-text"
/>
</View>
<View style={styles.btns}>
@ -345,30 +318,16 @@ export const Component = observer(function ({image, gallery}: Props) {
const styles = StyleSheet.create({
container: {
gap: 18,
paddingVertical: 18,
paddingHorizontal: 12,
paddingHorizontal: isDesktopWeb ? undefined : 16,
height: '100%',
width: '100%',
},
gap18: {
gap: 18,
},
subsection: {marginTop: 12},
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',
@ -379,28 +338,12 @@ const styles = StyleSheet.create({
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%',
},
@ -408,11 +351,29 @@ const styles = StyleSheet.create({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: 425,
width: 425,
borderWidth: 1,
borderRadius: 8,
borderStyle: 'solid',
overflow: 'hidden',
marginBottom: 4,
},
flipVertical: {
transform: [{rotate: '90deg'}],
},
flipBtn: {
paddingHorizontal: 4,
paddingVertical: 8,
},
textArea: {
borderWidth: 1,
borderRadius: 6,
paddingTop: 10,
paddingHorizontal: 12,
fontSize: 16,
height: 100,
textAlignVertical: 'top',
maxHeight: isDesktopWeb ? undefined : 50,
},
bottomSection: {
borderTopWidth: 1,
paddingTop: 18,
},
})