fix right-hand spacing for portrait last images
This commit is contained in:
@@ -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,
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user