[APP-1934] replace image grid layout with carousel (#10157)

Co-authored-by: RetroSunstar <57507616+RetroSunstar@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Spence Pope
2026-04-15 18:53:01 -04:00
committed by GitHub
parent d3f5093817
commit e804546809
20 changed files with 1697 additions and 511 deletions
+17
View File
@@ -12,8 +12,10 @@ import {useLightboxControls} from '#/state/lightbox'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {atoms as a} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
@@ -23,8 +25,10 @@ export function ImageEmbed({
}: CommonProps & {
embed: EmbedType<'images'>
}) {
const ax = useAnalytics()
const {openLightbox} = useLightboxControls()
const {images} = embed.view
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
if (images.length > 0) {
const items = images.map(img => ({
@@ -95,6 +99,19 @@ export function ImageEmbed({
)
}
if (galleryEnabled) {
return (
<View style={[a.mt_sm, rest.style]}>
<Gallery
images={images}
onPress={onPress}
onPressIn={onPressIn}
viewContext={rest.viewContext}
/>
</View>
)
}
return (
<View style={[a.mt_sm, rest.style]}>
<ImageLayoutGrid
+42 -38
View File
@@ -19,6 +19,7 @@ import {Link} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {GalleryBleed} from '#/components/images/Gallery'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {RichText} from '#/components/RichText'
@@ -308,6 +309,7 @@ export function QuoteEmbed({
<Embed
embed={quote.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
@@ -319,43 +321,45 @@ export function QuoteEmbed({
)
return (
<View
style={[a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
activeStyle={[a.p_md, a.pt_sm]}
childContainerStyle={[a.pt_sm]}>
{({active}) => (
<>
{!active && !linkDisabled && (
<SubtleHover
native
hover={hover || pressed}
style={[a.rounded_md]}
/>
)}
{linkDisabled ? (
<View style={[!active && a.p_md]} pointerEvents="none">
{contents}
</View>
) : (
<Link
style={[!active && a.p_md]}
hoverStyle={t.atoms.border_contrast_high}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
{contents}
</Link>
)}
</>
)}
</ContentHider>
</View>
<GalleryBleed>
<View
style={[a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
activeStyle={[a.p_md, a.pt_sm]}
childContainerStyle={[a.pt_sm]}>
{({active}) => (
<>
{!active && !linkDisabled && (
<SubtleHover
native
hover={hover || pressed}
style={[a.rounded_md]}
/>
)}
{linkDisabled ? (
<View style={[!active && a.p_md]} pointerEvents="none">
{contents}
</View>
) : (
<Link
style={[!active && a.p_md]}
hoverStyle={t.atoms.border_contrast_high}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
{contents}
</Link>
)}
</>
)}
</ContentHider>
</View>
</GalleryBleed>
)
}
+3
View File
@@ -0,0 +1,3 @@
export const ITEM_GAP = 8 // tokens.space.sm
export const MIN_ASPECT_RATIO = 2 / 3 // portrait limit
export const MAX_ASPECT_RATIO = 3 / 2 // landscape limit
+531
View File
@@ -0,0 +1,531 @@
import {
cloneElement,
createContext,
isValidElement,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {FlatList, Pressable, useWindowDimensions, View} from 'react-native'
import Animated, {
type AnimatedRef,
useAnimatedRef,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {utils} from '@bsky.app/alf'
import {Trans, useLingui} from '@lingui/react/macro'
import debounce from 'lodash.debounce'
import {type Dimensions} from '#/lib/media/types'
import {mergeRefs} from '#/lib/merge-refs'
import {useA11y} from '#/state/a11y'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {
ITEM_GAP,
MAX_ASPECT_RATIO,
MIN_ASPECT_RATIO,
} from '#/components/images/Gallery/const'
import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers'
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
import {getAspectRatio} from '#/components/images/Gallery/utils'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
export * from './const'
export * from './maybeApplyGalleryOffsetStyles'
interface GalleryProps {
images: AppBskyEmbedImages.ViewImage[]
onPress?: (
index: number,
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
}
const Context = createContext<{
bleedRef: React.RefObject<View | null>
bleedWidth: number
}>({
bleedRef: {current: null},
bleedWidth: 0,
})
export function GalleryBleed({children}: {children: React.ReactNode}) {
const ref = useRef<View>(null)
const [bleedWidth, setBleedWidth] = useState(0)
if (!isValidElement(children)) {
throw new Error('GalleryBleed children must be a single React element')
}
const node = children as React.ReactElement<any>
return (
<Context.Provider value={{bleedRef: ref, bleedWidth}}>
{cloneElement(node, {
ref: mergeRefs([ref, node?.props?.ref]),
onLayout: (e: {nativeEvent: {layout: {width: number}}}) => {
setBleedWidth(e.nativeEvent.layout.width)
node.props.onLayout?.(e)
},
style: [node.props.style, a.overflow_hidden],
})}
</Context.Provider>
)
}
export function useGalleryBleed() {
return useContext(Context)
}
export function Gallery({
images,
onPress,
onPressIn,
viewContext,
}: GalleryProps) {
const {t: l} = useLingui()
const ax = useAnalytics()
const {screenReaderEnabled} = useA11y()
const largeAltBadge = useLargeAltBadgeEnabled()
const bps = useBreakpoints()
const window = useWindowDimensions()
const contentHeight = useMemo(() => {
if (bps.gtMobile) {
return 300
} else if (bps.gtPhone) {
return 260
} else {
return 200
}
}, [bps])
const isWithinQuote =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const hideBadges = isWithinQuote
/*
* Container overflow styles
*
* Uses measureLayout to get the Gallery's offset relative to the GalleryBleed
* ancestor. This is a layout-relative measurement that doesn't depend on
* scroll position, so it works correctly for off-screen FlatList items.
*/
const {bleedRef, bleedWidth} = useGalleryBleed()
const contentRef = useRef<View>(null)
const [contentDims, setContentDims] = useState<{x: number; width: number}>()
const measure = () => {
if (contentRef.current && bleedRef.current) {
contentRef.current.measureLayout(
bleedRef.current,
(x, _y, w) => {
setContentDims({x, width: w})
},
() => {},
)
}
}
const width = bleedWidth || Math.min(600, window.width)
const insetLeft = contentDims?.x ?? 0
const insetRight =
bleedWidth > 0
? bleedWidth - (contentDims?.x ?? 0) - (contentDims?.width ?? 0)
: 0
/* End container overflow styles */
const flatListRef = useRef<FlatList>(null)
const itemWidthsRef = useRef<Map<number, number>>(new Map())
const itemRefsRef = useRef<Map<number, View>>(new Map())
const containerRefsRef = useRef<Map<number, AnimatedRef<any>>>(new Map())
const thumbDimsRef = useRef<Map<number, Dimensions>>(new Map())
const currentIndexRef = useRef(0)
const emitSwipeMetric = useMemo(
() =>
debounce((fromIndex: number, toIndex: number) => {
ax.metric('post:gallery:swipe', {
fromImage: fromIndex + 1, // convert to 1-based index for easier analysis
toImage: toIndex + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
})
}, 200),
[ax, images.length],
)
const setCurrentIndex = (index: number) => {
const prev = currentIndexRef.current
if (prev !== index) {
currentIndexRef.current = index
emitSwipeMetric(prev, index)
}
}
const scrollTo = (offset: number) => {
flatListRef.current?.scrollToOffset({offset, animated: false})
}
const onSettle = (index: number) => {
setCurrentIndex(index)
if (!IS_WEB) return
// Update tabIndex: only the active image is tab-focusable
itemRefsRef.current.forEach((node, i) => {
const el = node as unknown as HTMLElement
el.tabIndex = i === index ? 0 : -1
})
const el = itemRefsRef.current.get(index) as unknown as HTMLElement | null
el?.focus({preventScroll: true})
}
useKeyboardHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount: images.length,
})
usePointerHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount: images.length,
})
if (screenReaderEnabled) {
return (
<View style={[a.relative, a.gap_sm]}>
{images.map((image, index) => (
<AutoSizedImage
key={image.thumb + index}
crop={
viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) =>
onPress?.(index, [containerRef], [dims])
}
onPressIn={() => onPressIn?.(index)}
hideBadge={
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
))}
</View>
)
}
return (
<View
ref={contentRef}
style={[
a.w_full,
{
height: contentHeight,
overflow: 'visible',
},
]}
onLayout={measure}>
<BlockDrawerGesture>
<FlatList
ref={flatListRef}
role="group"
aria-roledescription={l`carousel`}
aria-label={l`Image gallery, ${images.length} images`}
horizontal
pagingEnabled={false}
showsHorizontalScrollIndicator={false}
decelerationRate={0.993}
directionalLockEnabled
nestedScrollEnabled
alwaysBounceVertical={false}
scrollEventThrottle={16}
data={images}
keyExtractor={(item, index) => item.thumb + index}
renderItem={({item, index}) => {
return (
<GalleryImage
hideBadges={hideBadges}
largeAltBadge={largeAltBadge}
image={item}
contentHeight={contentHeight}
index={index}
imageCount={images.length}
onWidthChange={(i, w) => {
itemWidthsRef.current.set(i, w)
}}
itemRef={node => {
if (node) {
itemRefsRef.current.set(index, node)
} else {
itemRefsRef.current.delete(index)
}
}}
onContainerRef={(i, ref) => {
containerRefsRef.current.set(i, ref)
}}
onThumbDims={(i, dims) => {
thumbDimsRef.current.set(i, dims)
}}
onPress={
onPress
? () => {
ax.metric('post:gallery:openLightbox', {
fromImage: index + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
})
const refs: AnimatedRef<any>[] = []
const dims: (Dimensions | null)[] = []
for (let i = 0; i < images.length; i++) {
refs.push(containerRefsRef.current.get(i)!)
dims.push(thumbDimsRef.current.get(i) ?? null)
}
onPress(index, refs, dims)
}
: undefined
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
/>
)
}}
onScroll={e => {
// web handles via onSettle in the web hooks
if (IS_WEB) return
const offsetX = e.nativeEvent.contentOffset.x
let accumulated = 0
for (let i = 0; i < images.length; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
if (offsetX < accumulated + w / 2) {
setCurrentIndex(i)
break
}
accumulated += w
if (i === images.length - 1) {
setCurrentIndex(i)
}
}
}}
style={[
{
height: contentHeight,
marginLeft: -insetLeft,
width,
},
]}
contentContainerStyle={{
gap: ITEM_GAP,
paddingLeft: insetLeft,
paddingRight: insetRight,
}}
/>
</BlockDrawerGesture>
</View>
)
}
function computeDims({
height,
aspectRatio,
}: {
height: number
aspectRatio?: number
}) {
/*
* Old images, or images from other clients can sometimes not have
* aspectRatio populated. In these cases, default to square and we'll
* resize once the image loads.
*
* Clamp between MIN_ASPECT_RATIO (portrait) and MAX_ASPECT_RATIO
* (landscape) so items stay a reasonable size in the carousel.
*/
const raw = aspectRatio ?? 1
const clamped = Math.max(MIN_ASPECT_RATIO, Math.min(raw, MAX_ASPECT_RATIO))
const width = Math.floor(height * clamped)
return {width, height, aspectRatio: clamped, isCropped: raw !== clamped}
}
function GalleryImage({
contentHeight: height,
image,
index,
imageCount,
onWidthChange,
itemRef,
hideBadges,
largeAltBadge,
onContainerRef,
onThumbDims,
onPress,
onPressIn,
}: {
contentHeight: number
image: AppBskyEmbedImages.ViewImage
index: number
imageCount: number
onWidthChange: (index: number, width: number) => void
itemRef: (node: View | null) => void
hideBadges?: boolean
largeAltBadge?: boolean
onContainerRef: (index: number, ref: AnimatedRef<any>) => void
onThumbDims: (index: number, dims: Dimensions) => void
onPress?: () => void
onPressIn?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const [focused, setFocused] = useState(false)
const containerRef = useAnimatedRef()
const [aspectRatio, setAspectRatio] = useState(() =>
getAspectRatio(image.aspectRatio),
)
const {isCropped, ...dims} = computeDims({height, aspectRatio})
const hasAlt = !!image.alt
useEffect(() => {
onWidthChange(index, dims.width)
}, [index, dims.width, onWidthChange])
useEffect(() => {
onContainerRef(index, containerRef)
}, [index, containerRef, onContainerRef])
return (
<Animated.View
ref={containerRef}
collapsable={false}
aria-roledescription={l`slide`}
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
<Pressable
ref={itemRef}
tabIndex={index === 0 ? 0 : -1}
onPress={onPress}
onPressIn={onPressIn}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
accessibilityRole="button"
accessibilityLabel={image.alt || l`Image ${index + 1}`}
accessibilityHint={l`Opens full image`}
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={[
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
web({
cursor: 'inherit',
outline: 0,
border: 0,
}),
]}>
<Image
source={{uri: image.thumb}}
contentFit="cover"
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
loading={index === 0 ? 'eager' : 'lazy'}
style={[dims]}
onLoad={e => {
const ar = getAspectRatio(e.source)
if (ar && ar !== aspectRatio) {
setAspectRatio(ar)
}
onThumbDims(index, {
width: e.source.width,
height: e.source.height,
})
}}
/>
{(hasAlt || isCropped) && !hideBadges ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
},
largeAltBadge && {
gap: 4,
},
]}>
{isCropped && (
<View
style={[
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Fullscreen
fill={t.atoms.text_contrast_high.color}
width={largeAltBadge ? 18 : 12}
/>
</View>
)}
{hasAlt && (
<View
style={[
a.justify_center,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
<Trans>ALT</Trans>
</Text>
</View>
)}
</View>
) : null}
<MediaInsetBorder
style={
focused && {
borderWidth: 2,
}
}
/>
</Pressable>
</Animated.View>
)
}
@@ -0,0 +1,102 @@
import {
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
type AppBskyFeedDefs,
AppBskyFeedPost,
type ModerationCause,
type ModerationUI,
} from '@atproto/api'
import {unique} from '#/lib/moderation'
import {type AppModerationCause} from '#/components/Pills'
import {Features, features} from '#/analytics/features'
import * as bsky from '#/types/bsky'
export const POST_META_NO_CONTENT_OFFSET = {paddingTop: 10}
export const POST_EMBED_NO_CONTENT_OFFSET = {paddingTop: 6}
export function maybeApplyGalleryOffsetStyles(
placement: 'meta' | 'embed',
{
post,
modui,
additionalCauses,
}: {
post: AppBskyFeedDefs.PostView
modui: ModerationUI
additionalCauses?: ModerationCause[] | AppModerationCause[]
},
) {
// don't ever check gates like this, except this one time
if (!features.isOn(Features.PostGalleryEmbedEnable)) return
if (
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
return
}
/*
* First check if we even have images
*/
const embed = post.record.embed
const isImageEmbed =
embed &&
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
embed,
AppBskyEmbedImages.isMain,
)
const isRecordWithMedia =
embed &&
bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>(
embed,
AppBskyEmbedRecordWithMedia.isMain,
)
let hasImages = false
if (isImageEmbed) {
// one image, not a gallery
if (embed.images.length === 1) return
hasImages = true
}
if (isRecordWithMedia) {
if (
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
embed.media,
AppBskyEmbedImages.isMain,
)
) {
// one image, not a gallery
if (embed.media.images.length === 1) return
}
hasImages = true
}
if (!hasImages) return
/*
* Then check if we have any text
*/
let hasLabels = false
if (modui.alert) {
hasLabels = modui.alerts.filter(unique).length > 0
}
if (modui.inform) {
hasLabels = hasLabels || modui.informs.filter(unique).length > 0
}
if (additionalCauses?.length) {
hasLabels = true
}
/*
* If no text or labels, then we need a lil bump
*/
const shouldApplyOffset = !post.record.text && !hasLabels
return shouldApplyOffset
? placement === 'meta'
? POST_META_NO_CONTENT_OFFSET
: POST_EMBED_NO_CONTENT_OFFSET
: {}
}
+40
View File
@@ -0,0 +1,40 @@
function ease(t: number, b: number, c: number, d: number) {
return t === d ? b + c : c * (-Math.pow(2, (-10 * t) / d) + 1) + b
}
/**
* Tween from `start` to `end` over `duration` ms using an exponential ease-out.
* Returns a function that starts the tween. That function returns a stop handle.
*
* Adapted from tinkerbell.
*/
export function tween(start: number, end: number, duration: number) {
return function run(cb: (v: number) => void, done?: () => void) {
let ts: number | undefined
let frame: number
frame = (function tick(last: number) {
return requestAnimationFrame(t => {
if (!ts) ts = t
const te = t - ts
const next = Math.round(ease(te, start, end - start, duration))
if (
(end > start
? next < end && last <= end
: next > end && last >= end) &&
te <= duration
) {
frame = tick(next)
cb(next)
} else {
cb(end)
done?.()
}
})
})(start)
return function stop() {
cancelAnimationFrame(frame)
}
}
}
@@ -0,0 +1,8 @@
export function useKeyboardHandlers(_args: {
flatListRef: any
itemWidthsRef: any
currentIndexRef: any
scrollTo: any
onSettle: any
imageCount: any
}) {}
@@ -0,0 +1,91 @@
import {useEffect} from 'react'
import {type FlatList} from 'react-native'
import {tween} from '#/components/images/Gallery/tween'
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
const SETTLE_DURATION = 700
export function useKeyboardHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
}: {
flatListRef: React.RefObject<FlatList | null>
itemWidthsRef: React.RefObject<Map<number, number>>
currentIndexRef: React.RefObject<number>
scrollTo: (offset: number) => void
onSettle: (index: number) => void
imageCount: number
}) {
useEffect(() => {
if (imageCount <= 1) return
let stopTween: (() => void) | null = null
let pendingIndex: number | null = null
const onKeyDown = (e: KeyboardEvent) => {
const el =
flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null
if (!el || !el.contains(document.activeElement)) return
const current = pendingIndex ?? currentIndexRef.current
let targetIndex: number | undefined
if (e.key === 'ArrowRight') {
if (current < imageCount - 1) {
targetIndex = current + 1
}
} else if (e.key === 'ArrowLeft') {
if (current > 0) {
targetIndex = current - 1
}
}
if (targetIndex != null) {
e.preventDefault()
if (stopTween) {
stopTween()
stopTween = null
}
pendingIndex = targetIndex
const from = el.scrollLeft
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
const idx = targetIndex
stopTween = tween(
from,
to,
SETTLE_DURATION,
)(
v => {
scrollTo(v)
},
() => {
stopTween = null
pendingIndex = null
onSettle(idx)
},
)
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
if (stopTween) stopTween()
}
}, [
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
])
}
@@ -0,0 +1,8 @@
export function usePointerHandlers(_args: {
flatListRef: any
itemWidthsRef: any
currentIndexRef: any
scrollTo: any
onSettle: any
imageCount: any
}) {}
@@ -0,0 +1,270 @@
import {useEffect} from 'react'
import {type FlatList} from 'react-native'
import {ITEM_GAP} from '#/components/images/Gallery/const'
import {tween} from '#/components/images/Gallery/tween'
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
const DRAG_THRESHOLD = 3
const FLICK_DECAY = 0.85
const FLICK_MIN_VELOCITY = 0.1
const ADVANCE_THRESHOLD = 0.15
const FRAME_MS = 1000 / 60
const SETTLE_DURATION = 700
const OVERSCROLL_RESISTANCE = 0.4
const BOUNCE_DURATION = 700
function whichByDistance(
itemWidths: Map<number, number>,
currentIndex: number,
distance: number,
direction: -1 | 1,
imageCount: number,
): number {
let remaining = distance
let i = currentIndex
while (remaining > 0 && i >= 0 && i < imageCount) {
const w = (itemWidths.get(i) ?? 0) + ITEM_GAP
if (remaining > w) {
remaining -= w
i -= direction
} else if (remaining > w * ADVANCE_THRESHOLD) {
i -= direction
break
} else {
break
}
}
return Math.max(0, Math.min(i, imageCount - 1))
}
export function usePointerHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
}: {
flatListRef: React.RefObject<FlatList | null>
itemWidthsRef: React.RefObject<Map<number, number>>
currentIndexRef: React.RefObject<number>
scrollTo: (offset: number) => void
onSettle: (index: number) => void
imageCount: number
}) {
useEffect(() => {
if (imageCount <= 1) return
const el =
flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null
if (!el) return
let isDragging = false
let isMouseDown = false
let startX = 0
let dragScrollLeft = 0
let delta = 0
let prevDelta = 0
let velo = 0
let t = 0
let stopTween: (() => void) | null = null
let localIndex = currentIndexRef.current
let overscrollX = 0
el.style.cursor = 'grab'
const clearOverscroll = () => {
overscrollX = 0
el.style.transform = ''
}
const onMouseDown = (e: MouseEvent) => {
e.preventDefault() // prevent native image drag
// Cancel any in-progress tween
if (stopTween) {
stopTween()
stopTween = null
}
clearOverscroll()
isMouseDown = true
isDragging = false
localIndex = currentIndexRef.current
startX = e.pageX
dragScrollLeft = el.scrollLeft
delta = 0
prevDelta = 0
velo = 0
t = e.timeStamp
}
const onMouseMove = (e: MouseEvent) => {
if (!isMouseDown) return
const x = e.pageX - startX
// Require minimum movement before starting drag
if (!isDragging && Math.abs(x) < DRAG_THRESHOLD) return
if (!isDragging) {
isDragging = true
el.style.cursor = 'grabbing'
el.style.userSelect = 'none'
// Blur focused element within the gallery
if (el.contains(document.activeElement)) {
;(document.activeElement as HTMLElement)?.blur?.()
}
}
e.preventDefault()
// Track velocity
const elapsed = e.timeStamp - t || 1
prevDelta = delta
delta = x
velo = (delta - prevDelta) / (elapsed * FRAME_MS)
t = e.timeStamp
const desiredScroll = dragScrollLeft - delta
const maxScroll = el.scrollWidth - el.clientWidth
if (desiredScroll < 0) {
// Overscroll at start — rubber band
scrollTo(0)
overscrollX = desiredScroll * OVERSCROLL_RESISTANCE
el.style.transform = `translateX(${-overscrollX}px)`
} else if (desiredScroll > maxScroll) {
// Overscroll at end — rubber band
scrollTo(maxScroll)
overscrollX = (desiredScroll - maxScroll) * OVERSCROLL_RESISTANCE
el.style.transform = `translateX(${-overscrollX}px)`
} else {
// Normal scroll range
scrollTo(desiredScroll)
if (overscrollX !== 0) clearOverscroll()
}
// Update local index from scroll position (only in normal range)
if (overscrollX === 0) {
const offsetX = desiredScroll
let accumulated = 0
for (let i = 0; i < imageCount; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
if (offsetX < accumulated + w / 2) {
localIndex = i
break
}
accumulated += w
if (i === imageCount - 1) localIndex = i
}
}
}
const onMouseUp = () => {
if (!isMouseDown) return
const wasDragging = isDragging
isMouseDown = false
isDragging = false
el.style.cursor = 'grab'
el.style.userSelect = ''
if (wasDragging) {
// Suppress the click that follows mouseup after a drag
el.addEventListener('click', e => e.stopPropagation(), {
once: true,
capture: true,
})
if (overscrollX !== 0) {
// Bounce back from overscroll
const targetIndex = overscrollX > 0 ? imageCount - 1 : 0
const fromOverscroll = overscrollX
stopTween = tween(
fromOverscroll,
0,
BOUNCE_DURATION,
)(
v => {
el.style.transform = `translateX(${-v}px)`
},
() => {
stopTween = null
clearOverscroll()
onSettle(targetIndex)
},
)
} else {
// Normal flick settle
let v = Math.abs(velo)
let restingDistance = 0
while (v > FLICK_MIN_VELOCITY) {
v *= FLICK_DECAY
restingDistance += v
}
const direction: -1 | 1 = delta < 0 ? -1 : 1
const totalDistance = Math.abs(delta) + restingDistance
const targetIndex = whichByDistance(
itemWidthsRef.current,
localIndex,
totalDistance,
direction,
imageCount,
)
const from = el.scrollLeft
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
if (from === to) {
onSettle(targetIndex)
return
}
stopTween = tween(
from,
to,
SETTLE_DURATION,
)(
v => {
scrollTo(v)
},
() => {
stopTween = null
onSettle(targetIndex)
},
)
}
}
}
el.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
return () => {
el.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
if (stopTween) stopTween()
clearOverscroll()
el.style.cursor = ''
el.style.userSelect = ''
}
}, [
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
])
}
+22
View File
@@ -0,0 +1,22 @@
import {ITEM_GAP} from '#/components/images/Gallery/const'
export function getOffsetForIndex(
itemWidths: Map<number, number>,
index: number,
): number {
let offset = 0
for (let i = 0; i < index; i++) {
offset += (itemWidths.get(i) ?? 0) + ITEM_GAP
}
return offset
}
export function getAspectRatio({
width,
height,
}: {width?: number; height?: number} = {}) {
if (width && width > 0 && height && height > 0) {
return width / height
}
return undefined
}
+1 -1
View File
@@ -6,7 +6,7 @@ import {type AppBskyEmbedImages} from '@atproto/api'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {atoms as a, useBreakpoints} from '#/alf'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {GalleryItem} from './Gallery'
import {GalleryItem} from './ImageLayoutGridItem'
interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[]