Composer: preview 5+ photos as a swipeable carousel
Render the composer photo preview as the existing grid for 1-4 images and a horizontal carousel for more than 4, matching the viewing-side display rule. - >4 selected images render a horizontal ScrollView carousel (aspect-ratio tiles, next image peeking from the right), reusing GalleryItem so per-image edit / remove / + ALT controls keep working. Auto-scrolls to a newly-added tile when crossing into / within carousel mode. - Screen-reader users get the wrapped grid layout instead of the carousel (mirrors the viewing-side gallery), keeping per-image controls navigable. - One-time dismissible admonition (Nux.ComposerCarouselAnnouncement) when the preview enters carousel mode. - Add a clamped tile-width helper (galleryLayout.ts) with unit tests, reusing the viewing-side MIN/MAX_ASPECT_RATIO bounds. APP-2288
This commit is contained in:
@@ -15,6 +15,7 @@ export enum Nux {
|
|||||||
LiveNowBetaDialog = 'LiveNowBetaDialog',
|
LiveNowBetaDialog = 'LiveNowBetaDialog',
|
||||||
LiveNowBetaNudge = 'LiveNowBetaNudge',
|
LiveNowBetaNudge = 'LiveNowBetaNudge',
|
||||||
DraftsAnnouncement = 'DraftsAnnouncement',
|
DraftsAnnouncement = 'DraftsAnnouncement',
|
||||||
|
ComposerCarouselAnnouncement = 'ComposerCarouselAnnouncement',
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Blocking announcements. New IDs are required for each new announcement.
|
* Blocking announcements. New IDs are required for each new announcement.
|
||||||
@@ -77,6 +78,10 @@ export type AppNux = BaseNux<
|
|||||||
id: Nux.DraftsAnnouncement
|
id: Nux.DraftsAnnouncement
|
||||||
data: undefined
|
data: undefined
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
id: Nux.ComposerCarouselAnnouncement
|
||||||
|
data: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
|
||||||
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
|
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
|
||||||
@@ -93,4 +98,5 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
|
|||||||
[Nux.LiveNowBetaDialog]: undefined,
|
[Nux.LiveNowBetaDialog]: undefined,
|
||||||
[Nux.LiveNowBetaNudge]: undefined,
|
[Nux.LiveNowBetaNudge]: undefined,
|
||||||
[Nux.DraftsAnnouncement]: undefined,
|
[Nux.DraftsAnnouncement]: undefined,
|
||||||
|
[Nux.ComposerCarouselAnnouncement]: undefined,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import {memo, useMemo, useState} from 'react'
|
import {memo, useEffect, useMemo, useRef, useState} from 'react'
|
||||||
import {
|
import {
|
||||||
findNodeHandle,
|
findNodeHandle,
|
||||||
type ImageStyle,
|
type ImageStyle,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
Platform,
|
Platform,
|
||||||
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
@@ -18,9 +19,12 @@ import {Trans} from '@lingui/react/macro'
|
|||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {type Dimensions} from '#/lib/media/types'
|
import {type Dimensions} from '#/lib/media/types'
|
||||||
import {colors} from '#/lib/styles'
|
import {colors} from '#/lib/styles'
|
||||||
|
import {useA11y} from '#/state/a11y'
|
||||||
import {type ComposerImage, cropImage} from '#/state/gallery'
|
import {type ComposerImage, cropImage} from '#/state/gallery'
|
||||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
|
||||||
|
import {atoms as a, tokens, useTheme, web} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||||
import {Pencil_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
|
import {Pencil_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
|
||||||
@@ -32,10 +36,27 @@ import {useAnalytics} from '#/analytics'
|
|||||||
import {IS_IOS, IS_NATIVE} from '#/env'
|
import {IS_IOS, IS_NATIVE} from '#/env'
|
||||||
import {type PostAction} from '../state/composer'
|
import {type PostAction} from '../state/composer'
|
||||||
import {EditImageDialog} from './EditImageDialog'
|
import {EditImageDialog} from './EditImageDialog'
|
||||||
|
import {getCarouselTileWidth} from './galleryLayout'
|
||||||
import {ImageAltTextDialog} from './ImageAltTextDialog'
|
import {ImageAltTextDialog} from './ImageAltTextDialog'
|
||||||
|
|
||||||
const IMAGE_GAP = 8
|
const IMAGE_GAP = 8
|
||||||
|
|
||||||
|
// Posts with more than this many images preview as a horizontal carousel
|
||||||
|
// instead of the grid (matches the viewing-side display rule).
|
||||||
|
const GALLERY_CAROUSEL_THRESHOLD = 4
|
||||||
|
// Fixed height for carousel tiles; widths derive from each image aspect ratio.
|
||||||
|
const CAROUSEL_TILE_HEIGHT = 171
|
||||||
|
const CAROUSEL_CONTROLS_STYLE = {
|
||||||
|
display: 'flex' as const,
|
||||||
|
flexDirection: 'row' as const,
|
||||||
|
position: 'absolute' as const,
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
gap: 4,
|
||||||
|
zIndex: 1,
|
||||||
|
}
|
||||||
|
const CAROUSEL_ALT_STYLE = {left: 4, bottom: 4}
|
||||||
|
|
||||||
interface GalleryProps {
|
interface GalleryProps {
|
||||||
images: ComposerImage[]
|
images: ComposerImage[]
|
||||||
dispatch: (action: PostAction) => void
|
dispatch: (action: PostAction) => void
|
||||||
@@ -67,7 +88,21 @@ interface GalleryInnerProps extends GalleryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
||||||
|
const {_} = useLingui()
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
const {screenReaderEnabled} = useA11y()
|
||||||
|
const isCarousel =
|
||||||
|
images.length > GALLERY_CAROUSEL_THRESHOLD && !screenReaderEnabled
|
||||||
|
|
||||||
|
const scrollRef = useRef<ScrollView>(null)
|
||||||
|
const prevCountRef = useRef(images.length)
|
||||||
|
useEffect(() => {
|
||||||
|
// When a new image is added in carousel mode, reveal it.
|
||||||
|
if (isCarousel && images.length > prevCountRef.current) {
|
||||||
|
scrollRef.current?.scrollToEnd({animated: true})
|
||||||
|
}
|
||||||
|
prevCountRef.current = images.length
|
||||||
|
}, [images.length, isCarousel])
|
||||||
|
|
||||||
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => {
|
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => {
|
||||||
// Cap columns at 4 so tiles stay tappable when MAX_GALLERY_IMAGES is high;
|
// Cap columns at 4 so tiles stay tappable when MAX_GALLERY_IMAGES is high;
|
||||||
@@ -104,7 +139,62 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
|||||||
}
|
}
|
||||||
}, [images.length, containerInfo, isMobile])
|
}, [images.length, containerInfo, isMobile])
|
||||||
|
|
||||||
return images.length !== 0 ? (
|
if (images.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const altTextReminder = images.some(image => !image.alt) ? (
|
||||||
|
<Admonition type="info" style={[a.mt_sm]}>
|
||||||
|
<Trans>
|
||||||
|
Alt text describes images for blind and low-vision users, and helps give
|
||||||
|
context to everyone.
|
||||||
|
</Trans>
|
||||||
|
</Admonition>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
if (isCarousel) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
testID="selectedPhotosView"
|
||||||
|
accessibilityLabel={_(msg`Selected photos, ${images.length} images`)}
|
||||||
|
accessibilityHint=""
|
||||||
|
role="group"
|
||||||
|
aria-roledescription={_(msg`carousel`)}
|
||||||
|
style={[{marginTop: 16}, web({overscrollBehaviorX: 'contain'})]}
|
||||||
|
contentContainerStyle={{gap: IMAGE_GAP, paddingRight: IMAGE_GAP}}>
|
||||||
|
{images.map(image => (
|
||||||
|
<GalleryItem
|
||||||
|
key={image.source.id}
|
||||||
|
image={image}
|
||||||
|
altTextControlStyle={CAROUSEL_ALT_STYLE}
|
||||||
|
imageControlsStyle={CAROUSEL_CONTROLS_STYLE}
|
||||||
|
imageStyle={{
|
||||||
|
height: CAROUSEL_TILE_HEIGHT,
|
||||||
|
width: getCarouselTileWidth(
|
||||||
|
image.transformed ?? image.source,
|
||||||
|
CAROUSEL_TILE_HEIGHT,
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
onChange={next => {
|
||||||
|
dispatch({type: 'embed_update_image', image: next})
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
dispatch({type: 'embed_remove_image', image})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
<CarouselAdmonition />
|
||||||
|
{altTextReminder}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<View testID="selectedPhotosView" style={styles.gallery}>
|
<View testID="selectedPhotosView" style={styles.gallery}>
|
||||||
{images.map(image => {
|
{images.map(image => {
|
||||||
@@ -125,16 +215,9 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
{images.some(image => !image.alt) && (
|
{altTextReminder}
|
||||||
<Admonition type="info" style={[a.mt_sm]}>
|
|
||||||
<Trans>
|
|
||||||
Alt text describes images for blind and low-vision users, and helps
|
|
||||||
give context to everyone.
|
|
||||||
</Trans>
|
|
||||||
</Admonition>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : null
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type GalleryItemProps = {
|
type GalleryItemProps = {
|
||||||
@@ -270,6 +353,42 @@ const GalleryItem = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CarouselAdmonition() {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {nux} = useNux(Nux.ComposerCarouselAnnouncement)
|
||||||
|
const {mutate: save, variables} = useSaveNux()
|
||||||
|
|
||||||
|
// Optimistically hide while the completion is saving.
|
||||||
|
if (variables) return null
|
||||||
|
if (nux && nux.completed) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.mt_sm]}>
|
||||||
|
<Admonition type="info">
|
||||||
|
<Trans>
|
||||||
|
Posts with more than 4 photos are shown as a swipeable carousel.
|
||||||
|
</Trans>
|
||||||
|
</Admonition>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Dismiss`)}
|
||||||
|
size="tiny"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary_inverted"
|
||||||
|
shape="round"
|
||||||
|
onPress={() =>
|
||||||
|
save({
|
||||||
|
id: Nux.ComposerCarouselAnnouncement,
|
||||||
|
completed: true,
|
||||||
|
data: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={[a.absolute, {top: 8, right: 8}]}>
|
||||||
|
<ButtonIcon icon={TimesIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
gallery: {
|
gallery: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {getCarouselTileWidth} from './galleryLayout'
|
||||||
|
|
||||||
|
describe('getCarouselTileWidth', () => {
|
||||||
|
it('clamps wide landscape images to the max aspect ratio (3/2)', () => {
|
||||||
|
// 200/100 = 2.0 -> clamped to 1.5 -> 100 * 1.5 = 150
|
||||||
|
expect(getCarouselTileWidth({width: 200, height: 100}, 100)).toBe(150)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps tall portrait images to the min aspect ratio (2/3)', () => {
|
||||||
|
// 100/200 = 0.5 -> clamped to 0.6667 -> round(100 * 0.6667) = 67
|
||||||
|
expect(getCarouselTileWidth({width: 100, height: 200}, 100)).toBe(67)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps in-range aspect ratios unchanged', () => {
|
||||||
|
// 400/300 = 1.333 (in [0.667, 1.5]) -> 120 * 4/3 = 160
|
||||||
|
expect(getCarouselTileWidth({width: 400, height: 300}, 120)).toBe(160)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to square when dimensions are missing or zero', () => {
|
||||||
|
expect(getCarouselTileWidth({width: 0, height: 0}, 100)).toBe(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
MAX_ASPECT_RATIO,
|
||||||
|
MIN_ASPECT_RATIO,
|
||||||
|
} from '#/components/images/Gallery/const'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of a carousel tile at a fixed height, derived from the image aspect
|
||||||
|
* ratio and clamped to the same range the viewing-side carousel uses so tiles
|
||||||
|
* stay a reasonable size. Falls back to square when dimensions are missing.
|
||||||
|
*/
|
||||||
|
export function getCarouselTileWidth(
|
||||||
|
dims: {width: number; height: number},
|
||||||
|
tileHeight: number,
|
||||||
|
): number {
|
||||||
|
const raw = dims.width > 0 && dims.height > 0 ? dims.width / dims.height : 1
|
||||||
|
const clamped = Math.max(MIN_ASPECT_RATIO, Math.min(raw, MAX_ASPECT_RATIO))
|
||||||
|
return Math.round(tileHeight * clamped)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user