fix right-hand spacing for portrait last images

This commit is contained in:
vineyardbovines
2026-04-07 10:49:52 -04:00
parent cf18b87135
commit ea51ffd69b
2 changed files with 81 additions and 20 deletions
+13 -5
View File
@@ -19,6 +19,7 @@ import {useAnalytics} from '#/analytics'
const CONTAINER_ASPECT_RATIO = 3 / 2 const CONTAINER_ASPECT_RATIO = 3 / 2
const ITEM_GAP = 8 // tokens.space.sm const ITEM_GAP = 8 // tokens.space.sm
const MIN_PEEK = 40
interface GalleryProps { interface GalleryProps {
images: AppBskyEmbedImages.ViewImage[] images: AppBskyEmbedImages.ViewImage[]
@@ -76,16 +77,21 @@ export function Gallery({
? windowWidth - insetLeft - containerWidth ? windowWidth - insetLeft - containerWidth
: QUOTE_PADDING : QUOTE_PADDING
const getItemWidth = (image: AppBskyEmbedImages.ViewImage) => { const getItemWidth = (image: AppBskyEmbedImages.ViewImage, index: number) => {
const ar = image.aspectRatio const ar = image.aspectRatio
let width = containerHeight // default to square-ish
if (ar && ar.width > 0 && ar.height > 0) { if (ar && ar.width > 0 && ar.height > 0) {
const ratio = ar.width / ar.height const ratio = ar.width / ar.height
// Width derived from image's own aspect ratio at the fixed container height // Width derived from image's own aspect ratio at the fixed container height
// Clamp aspect ratio between 2:3 (portrait) and 3:2 (landscape) // Clamp aspect ratio between 2:3 (portrait) and 3:2 (landscape)
const clamped = Math.max(2 / 3, Math.min(ratio, 3 / 2)) const clamped = Math.max(2 / 3, Math.min(ratio, 3 / 2))
return containerHeight * clamped width = containerHeight * clamped
} }
return containerHeight // default to square-ish // Ensure the first image leaves room for a peek of the next
if (index === 0 && images.length > 1) {
width = Math.min(width, containerWidth - MIN_PEEK)
}
return width
} }
if (screenReaderEnabled) { if (screenReaderEnabled) {
@@ -170,6 +176,8 @@ export function Gallery({
pagingEnabled={false} pagingEnabled={false}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
decelerationRate={0.993} decelerationRate={0.993}
directionalLockEnabled
alwaysBounceVertical={false}
style={{ style={{
width: bleed ? windowWidth : containerWidth + QUOTE_PADDING * 2, width: bleed ? windowWidth : containerWidth + QUOTE_PADDING * 2,
height: containerHeight, height: containerHeight,
@@ -186,7 +194,7 @@ export function Gallery({
let accumulated = insetLeft // account for left content padding let accumulated = insetLeft // account for left content padding
let page = 0 let page = 0
for (let i = 0; i < images.length; i++) { for (let i = 0; i < images.length; i++) {
const w = getItemWidth(images[i]) + ITEM_GAP const w = getItemWidth(images[i], i) + ITEM_GAP
if (offsetX < accumulated + w / 2) { if (offsetX < accumulated + w / 2) {
page = i page = i
break break
@@ -211,7 +219,7 @@ export function Gallery({
collapsable={false} collapsable={false}
style={[ style={[
{ {
width: getItemWidth(image), width: getItemWidth(image, index),
height: containerHeight, height: containerHeight,
}, },
]}> ]}>
+68 -15
View File
@@ -16,6 +16,7 @@ import {useAnalytics} from '#/analytics'
const CONTAINER_ASPECT_RATIO = 3 / 2 const CONTAINER_ASPECT_RATIO = 3 / 2
const ITEM_GAP = 8 const ITEM_GAP = 8
const MIN_PEEK = 40
interface GalleryProps { interface GalleryProps {
images: AppBskyEmbedImages.ViewImage[] images: AppBskyEmbedImages.ViewImage[]
@@ -65,20 +66,25 @@ export function Gallery({
const containerHeight = const containerHeight =
containerWidth > 0 ? containerWidth / CONTAINER_ASPECT_RATIO : 0 containerWidth > 0 ? containerWidth / CONTAINER_ASPECT_RATIO : 0
const getItemWidth = (image: AppBskyEmbedImages.ViewImage) => { const getItemWidth = (image: AppBskyEmbedImages.ViewImage, index: number) => {
const ar = image.aspectRatio const ar = image.aspectRatio
let width = containerHeight
if (ar && ar.width > 0 && ar.height > 0) { if (ar && ar.width > 0 && ar.height > 0) {
const ratio = ar.width / ar.height const ratio = ar.width / ar.height
const clamped = Math.max(2 / 3, Math.min(ratio, 3 / 2)) const clamped = Math.max(2 / 3, Math.min(ratio, 3 / 2))
return containerHeight * clamped width = containerHeight * clamped
} }
return containerHeight // Ensure the first image leaves room for a peek of the next
if (index === 0 && images.length > 1) {
width = Math.min(width, containerWidth - MIN_PEEK)
}
return width
} }
// Embla carousel // Embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({ const [emblaRef, emblaApi] = useEmblaCarousel({
align: () => insetLeftRef.current, align: () => insetLeftRef.current,
containScroll: false, containScroll: 'trimSnaps',
dragFree: true, dragFree: true,
}) })
@@ -96,9 +102,17 @@ export function Gallery({
currentPageRef.current = page currentPageRef.current = page
} }
} }
const onDragStart = () => setIsDragging(true)
const onDragEnd = () => setIsDragging(false)
emblaApi.on('select', onSelect) emblaApi.on('select', onSelect)
emblaApi.on('pointerDown', onDragStart)
emblaApi.on('pointerUp', onDragEnd)
emblaApi.on('settle', onDragEnd)
return () => { return () => {
emblaApi.off('select', onSelect) emblaApi.off('select', onSelect)
emblaApi.off('pointerDown', onDragStart)
emblaApi.off('pointerUp', onDragEnd)
emblaApi.off('settle', onDragEnd)
} }
}, [emblaApi, ax, images.length]) }, [emblaApi, ax, images.length])
@@ -110,6 +124,7 @@ export function Gallery({
// Suppress click after drag // Suppress click after drag
const pointerDown = useRef(false) const pointerDown = useRef(false)
const dragged = useRef(false) const dragged = useRef(false)
const [isDragging, setIsDragging] = useState(false)
useEffect(() => { useEffect(() => {
if (!emblaApi) return if (!emblaApi) return
@@ -206,31 +221,32 @@ export function Gallery({
ref={emblaRef} ref={emblaRef}
style={{ style={{
overflow: 'visible', overflow: 'visible',
width: isBleed ? containerWidth + insetLeft : '100%', width: isBleed ? containerWidth + insetLeft + insetRight : '100%',
height: containerHeight, height: containerHeight,
cursor: 'grab', cursor: isDragging ? 'grabbing' : 'grab',
userSelect: 'none',
}}> }}>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
gap: ITEM_GAP, gap: ITEM_GAP,
paddingLeft: isWithinQuote ? QUOTE_PADDING : 0, paddingLeft: isWithinQuote ? QUOTE_PADDING : insetLeft,
paddingRight: isWithinQuote ? QUOTE_PADDING : 0, paddingRight: isWithinQuote ? QUOTE_PADDING : 0,
height: containerHeight, height: containerHeight,
}}> }}>
{images.map((image, index) => ( {images.map((image, index) => (
<div <Slide
key={index} key={index}
style={{ width={getItemWidth(image, index)}
flex: `0 0 ${getItemWidth(image)}px`, height={containerHeight}>
minWidth: 0,
height: containerHeight,
}}>
<View <View
ref={containerRefs[index]} ref={containerRefs[index]}
collapsable={false} collapsable={false}
style={[ style={[
{width: getItemWidth(image), height: containerHeight}, {
width: getItemWidth(image, index),
height: containerHeight,
},
]} ]}
aria-roledescription="slide" aria-roledescription="slide"
aria-label={ aria-label={
@@ -314,8 +330,17 @@ export function Gallery({
</View> </View>
) : null} ) : null}
</View> </View>
</div> </Slide>
))} ))}
{isBleed && insetRight > 0 && (
<div
aria-hidden
style={{
flex: `0 0 ${Math.max(0, insetRight - ITEM_GAP)}px`,
minWidth: 0,
}}
/>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -323,3 +348,31 @@ export function Gallery({
</View> </View>
) )
} }
function Slide({
width,
height,
children,
}: {
width: number
height: number
children: React.ReactNode
}) {
const [pressed, setPressed] = useState(false)
return (
<div
onPointerDown={() => setPressed(true)}
onPointerUp={() => setPressed(false)}
onPointerLeave={() => setPressed(false)}
onPointerMove={() => setPressed(false)}
style={{
flex: `0 0 ${width}px`,
minWidth: 0,
height,
transition: 'transform 0.15s ease-out',
transform: pressed ? 'scale(0.975)' : undefined,
}}>
{children}
</div>
)
}