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',
|
||||
LiveNowBetaNudge = 'LiveNowBetaNudge',
|
||||
DraftsAnnouncement = 'DraftsAnnouncement',
|
||||
ComposerCarouselAnnouncement = 'ComposerCarouselAnnouncement',
|
||||
|
||||
/*
|
||||
* Blocking announcements. New IDs are required for each new announcement.
|
||||
@@ -77,6 +78,10 @@ export type AppNux = BaseNux<
|
||||
id: Nux.DraftsAnnouncement
|
||||
data: undefined
|
||||
}
|
||||
| {
|
||||
id: Nux.ComposerCarouselAnnouncement
|
||||
data: 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.LiveNowBetaNudge]: 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 {
|
||||
findNodeHandle,
|
||||
type ImageStyle,
|
||||
Keyboard,
|
||||
type LayoutChangeEvent,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
@@ -18,9 +19,12 @@ import {Trans} from '@lingui/react/macro'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {type Dimensions} from '#/lib/media/types'
|
||||
import {colors} from '#/lib/styles'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
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 {Button, ButtonIcon} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||
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 {type PostAction} from '../state/composer'
|
||||
import {EditImageDialog} from './EditImageDialog'
|
||||
import {getCarouselTileWidth} from './galleryLayout'
|
||||
import {ImageAltTextDialog} from './ImageAltTextDialog'
|
||||
|
||||
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 {
|
||||
images: ComposerImage[]
|
||||
dispatch: (action: PostAction) => void
|
||||
@@ -67,7 +88,21 @@ interface GalleryInnerProps extends GalleryProps {
|
||||
}
|
||||
|
||||
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
||||
const {_} = useLingui()
|
||||
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(() => {
|
||||
// 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])
|
||||
|
||||
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}>
|
||||
{images.map(image => {
|
||||
@@ -125,16 +215,9 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
{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>
|
||||
)}
|
||||
{altTextReminder}
|
||||
</>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
|
||||
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({
|
||||
gallery: {
|
||||
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