feat: alf'd image alt text dialog

This commit is contained in:
Mary
2024-05-07 22:05:40 +07:00
parent d7ee6a9ef4
commit a82402cda5
2 changed files with 294 additions and 150 deletions
+170 -150
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
import {ImageStyle, LayoutChangeEvent, ViewStyle} from 'react-native'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {Image} from 'expo-image'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -7,13 +7,14 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ComposerImage, cropImage} from '#/state/gallery'
import {useModalControls} from '#/state/modals'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Dimensions} from 'lib/media/types'
import {colors, s} from 'lib/styles'
import {isNative} from 'platform/detection'
import {Text} from 'view/com/util/text/Text'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {ImageAltTextDialog} from './ImageAltTextDialog'
const IMAGE_GAP = 8
@@ -48,165 +49,65 @@ interface GalleryInnerProps extends GalleryProps {
}
const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => {
const {_} = useLingui()
const {isMobile} = useWebMediaQueries()
const {openModal} = useModalControls()
const t = useTheme()
let side: number
const {altTextControlStyle, imageControlsStyle, imageStyle} =
React.useMemo(() => {
const side =
images.length === 1
? 250
: (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
images.length
if (images.length === 1) {
side = 250
} else {
side =
(containerInfo.width - IMAGE_GAP * (images.length - 1)) / images.length
}
const isOverflow = isMobile && images.length > 2
const imageStyle = {
height: side,
width: side,
}
const isOverflow = isMobile && images.length > 2
const altTextControlStyle = isOverflow
? {left: 4, bottom: 4}
: !isMobile && images.length < 3
? {left: 8, top: 8}
: {left: 4, top: 4}
const imageControlsStyle = {
display: 'flex' as const,
flexDirection: 'row' as const,
position: 'absolute' as const,
...(isOverflow
? {top: 4, right: 4, gap: 4}
: !isMobile && images.length < 3
? {top: 8, right: 8, gap: 8}
: {top: 4, right: 4, gap: 4}),
zIndex: 1,
}
return {
altTextControlStyle: isOverflow
? {left: 4, bottom: 4}
: !isMobile && images.length < 3
? {left: 8, top: 8}
: {left: 4, top: 4},
imageControlsStyle: {
display: 'flex' as const,
flexDirection: 'row' as const,
position: 'absolute' as const,
...(isOverflow
? {top: 4, right: 4, gap: 4}
: !isMobile && images.length < 3
? {top: 8, right: 8, gap: 8}
: {top: 4, right: 4, gap: 4}),
zIndex: 1,
},
imageStyle: {
height: side,
width: side,
},
}
}, [images.length, containerInfo, isMobile])
return images.length !== 0 ? (
<>
<View testID="selectedPhotosView" style={styles.gallery}>
{images.map((image, index) => {
const onRemove = () => {
const next = images.slice()
next.splice(index, 1)
onChange(next)
}
const onAltText = () => {
Keyboard.dismiss()
openModal({
name: 'alt-text-image',
image,
onChange(next) {
onChange(
images.map(i => (i.source === image.source ? next : i)),
)
},
})
}
const onEdit = () => {
if (isNative) {
cropImage(image).then(next => {
if (next === image) {
return
}
onChange(
images.map(i => (i.source === image.source ? next : i)),
)
})
} else {
// openModal({
// name: 'edit-image',
// image,
// gallery,
// })
}
}
return (
<View
key={`selected-image-${image.source.path}`}
style={[imageStyle]}>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
onPress={onAltText}
style={[styles.altTextControl, altTextControlStyle]}>
{image.alt.length !== 0 ? (
<FontAwesomeIcon
icon="check"
size={10}
style={{color: t.palette.white}}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={10}
style={{color: t.palette.white}}
/>
)}
<Text style={styles.altTextControlLabel} accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<View style={imageControlsStyle}>
{isNative && (
<TouchableOpacity
testID="editPhotoButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Edit image`)}
accessibilityHint=""
onPress={onEdit}
style={styles.imageControl}>
<FontAwesomeIcon
icon="pen"
size={12}
style={{color: colors.white}}
/>
</TouchableOpacity>
)}
<TouchableOpacity
testID="removePhotoButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Remove image`)}
accessibilityHint=""
onPress={onRemove}
style={styles.imageControl}>
<FontAwesomeIcon
icon="xmark"
size={16}
style={{color: colors.white}}
/>
</TouchableOpacity>
</View>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
onPress={onAltText}
style={styles.altTextHiddenRegion}
/>
<GalleryItem
key={index}
image={image}
altTextControlStyle={altTextControlStyle}
imageControlsStyle={imageControlsStyle}
imageStyle={imageStyle}
onChange={next => {
onChange(
images.map(i => (i.source === image.source ? next : i)),
)
}}
onRemove={() => {
const next = images.slice()
next.splice(index, 1)
<Image
testID="selectedPhotoImage"
style={[styles.image, imageStyle] as ImageStyle}
source={{
uri: (image.transformed ?? image.source).path,
}}
accessible={true}
accessibilityIgnoresInvertColors
/>
</View>
onChange(next)
}}
/>
)
})}
</View>
@@ -215,6 +116,125 @@ const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => {
) : null
}
type GalleryItemProps = {
image: ComposerImage
altTextControlStyle?: ViewStyle
imageControlsStyle?: ViewStyle
imageStyle?: ViewStyle
onChange: (next: ComposerImage) => void
onRemove: () => void
}
const GalleryItem = ({
image,
altTextControlStyle,
imageControlsStyle,
imageStyle,
onChange,
onRemove,
}: GalleryItemProps): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
const altTextControl = Dialog.useDialogControl()
const onEdit = () => {
if (isNative) {
cropImage(image).then(next => {
onChange(next)
})
} else {
// openModal({
// name: 'edit-image',
// image,
// gallery,
// })
}
}
return (
<View style={imageStyle}>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
onPress={altTextControl.open}
style={[styles.altTextControl, altTextControlStyle]}>
{image.alt.length !== 0 ? (
<FontAwesomeIcon
icon="check"
size={10}
style={{color: t.palette.white}}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={10}
style={{color: t.palette.white}}
/>
)}
<Text style={styles.altTextControlLabel} accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<View style={imageControlsStyle}>
{isNative && (
<TouchableOpacity
testID="editPhotoButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Edit image`)}
accessibilityHint=""
onPress={onEdit}
style={styles.imageControl}>
<FontAwesomeIcon
icon="pen"
size={12}
style={{color: colors.white}}
/>
</TouchableOpacity>
)}
<TouchableOpacity
testID="removePhotoButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Remove image`)}
accessibilityHint=""
onPress={onRemove}
style={styles.imageControl}>
<FontAwesomeIcon
icon="xmark"
size={16}
style={{color: colors.white}}
/>
</TouchableOpacity>
</View>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
onPress={altTextControl.open}
style={styles.altTextHiddenRegion}
/>
<Image
testID="selectedPhotoImage"
style={[styles.image, imageStyle] as ImageStyle}
source={{
uri: (image.transformed ?? image.source).path,
}}
accessible={true}
accessibilityIgnoresInvertColors
/>
<ImageAltTextDialog
control={altTextControl}
image={image}
onChange={onChange}
/>
</View>
)
}
export function AltTextReminder() {
const t = useTheme()
return (
@@ -0,0 +1,124 @@
import React from 'react'
import {ImageStyle, useWindowDimensions, View} from 'react-native'
import {Image} from 'expo-image'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from '#/platform/detection'
import {ComposerImage} from '#/state/gallery'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Text} from '#/components/Typography'
type Props = {
control: Dialog.DialogOuterProps['control']
image: ComposerImage
onChange: (next: ComposerImage) => void
}
export const ImageAltTextDialog = (props: Props): React.ReactNode => {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<ImageAltTextInner {...props} />
</Dialog.Outer>
)
}
const ImageAltTextInner = ({
control,
image,
onChange,
}: Props): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
const windim = useWindowDimensions()
const [altText, setAltText] = React.useState(image.alt)
const trimmed = React.useMemo(() => altText.trim(), [altText])
const onPressSubmit = React.useCallback(() => {
control.close()
onChange({...image, alt: trimmed})
}, [control, image, trimmed, onChange])
const imageStyle = React.useMemo<ImageStyle>(() => {
const maxWidth = isWeb ? 450 : windim.width
const source = image.transformed ?? image.source
if (source.height > source.width) {
return {
resizeMode: 'contain',
width: '100%',
aspectRatio: 1,
borderRadius: 8,
}
}
return {
width: '100%',
height: (maxWidth / source.width) * source.height,
borderRadius: 8,
}
}, [image, windim])
return (
<Dialog.ScrollableInner label={_(msg`Add alt text`)}>
<Dialog.Close />
<View style={a.flex_col_reverse}>
<View style={[a.mt_md, a.gap_md]}>
<View>
<TextField.LabelText>
<Trans>Descriptive alt text</Trans>
</TextField.LabelText>
<TextField.Root>
<Dialog.Input
label={_(msg`Alt text`)}
onChangeText={text => setAltText(text)}
value={altText}
multiline
numberOfLines={3}
autoFocus
/>
</TextField.Root>
</View>
<Button
label={_(msg`Save`)}
size="medium"
color="primary"
variant="solid"
onPress={onPressSubmit}>
<ButtonText>
<Trans>Save</Trans>
</ButtonText>
</Button>
</View>
{/* below the text input to force tab order */}
<View>
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
<Trans>Add ALT text</Trans>
</Text>
<View
style={[t.atoms.bg_contrast_50, a.rounded_sm, a.overflow_hidden]}>
<Image
style={imageStyle}
source={{
uri: (image.transformed ?? image.source).path,
}}
contentFit="contain"
accessible={true}
accessibilityIgnoresInvertColors
enableLiveTextInteraction
/>
</View>
</View>
</View>
</Dialog.ScrollableInner>
)
}