From 01cd962c09a15d0844f02abd383deded741113a6 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Wed, 1 Apr 2026 14:59:45 -0400 Subject: [PATCH] update scrolling and bleeding edges --- src/components/Post/Embed/index.tsx | 1 + src/components/images/Gallery/index.tsx | 124 +++++++++++-------- src/view/com/composer/photos/Gallery.tsx | 149 ++++++++++------------- 3 files changed, 142 insertions(+), 132 deletions(-) diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index 53e64485a3..927f8bf98b 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -308,6 +308,7 @@ export function QuoteEmbed({ []>([]).current @@ -61,11 +61,34 @@ export function Gallery({ containerRefs[i] = refs[i] } - const hideBadges = + const isWithinQuote = viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + const hideBadges = isWithinQuote - const itemWidth = containerWidth > 0 ? containerWidth - PEEK_WIDTH : 0 - const snapInterval = itemWidth + ITEM_GAP + const containerHeight = + containerWidth > 0 ? containerWidth / CONTAINER_ASPECT_RATIO : 0 + // Bleed: full-width carousel that extends to screen edges + // In quotes: small bleed to the quote card border (p_md = 12px) + const QUOTE_PADDING = 12 + const bleed = !isWithinQuote + const insetLeft = bleed + ? leftOffset || windowWidth - containerWidth + : QUOTE_PADDING + const insetRight = bleed + ? windowWidth - insetLeft - containerWidth + : QUOTE_PADDING + + const getItemWidth = (image: AppBskyEmbedImages.ViewImage) => { + const ar = image.aspectRatio + if (ar && ar.width > 0 && ar.height > 0) { + const ratio = ar.width / ar.height + // Width derived from image's own aspect ratio at the fixed container height + const w = containerHeight * ratio + // Clamp: at least 40% of content width, at most the full content width + return Math.max(containerWidth * 0.4, Math.min(w, containerWidth)) + } + return containerWidth + } if (screenReaderEnabled) { return ( @@ -125,8 +148,22 @@ export function Gallery({ return ( setContainerWidth(e.nativeEvent.layout.width)}> + style={ + containerWidth > 0 + ? {height: containerHeight, overflow: 'visible'} + : {aspectRatio: CONTAINER_ASPECT_RATIO} + } + onLayout={e => { + const w = e.nativeEvent.layout.width + if (w > 0) { + setContainerWidth(w) + } + e.target.measureInWindow((x: number) => { + if (x > 0) { + setLeftOffset(x) + } + }) + }}> {containerWidth > 0 && ( i * snapInterval)} decelerationRate="normal" - disableIntervalMomentum - contentContainerStyle={{gap: ITEM_GAP}} + style={{ + width: bleed ? windowWidth : containerWidth + QUOTE_PADDING * 2, + height: containerHeight, + marginLeft: -insetLeft, + }} + contentContainerStyle={{ + gap: ITEM_GAP, + paddingLeft: insetLeft, + paddingRight: insetRight, + }} onScroll={e => { const offsetX = e.nativeEvent.contentOffset.x - if (snapInterval > 0) { - const page = Math.round(offsetX / snapInterval) - if (page !== currentPageRef.current) { - ax.metric('post:gallery:swipe', { - fromIndex: currentPageRef.current, - toIndex: page, - totalImages: images.length, - }) - currentPageRef.current = page - setCurrentPage(page) + // Determine which item is most visible based on scroll position + let accumulated = insetLeft // account for left content padding + let page = 0 + for (let i = 0; i < images.length; i++) { + const w = getItemWidth(images[i]) + ITEM_GAP + if (offsetX < accumulated + w / 2) { + page = i + break } + accumulated += w + page = i + } + if (page !== currentPageRef.current) { + ax.metric('post:gallery:swipe', { + fromIndex: currentPageRef.current, + toIndex: page, + totalImages: images.length, + }) + currentPageRef.current = page } }} scrollEventThrottle={16} @@ -161,8 +213,8 @@ export function Gallery({ collapsable={false} style={[ { - width: itemWidth, - aspectRatio: CONTAINER_ASPECT_RATIO, + width: getItemWidth(image), + height: containerHeight, }, ]}> )} - {images.length > 1 && ( - - - {currentPage + 1}/{images.length} - - - )} ) } diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 2ad91fa594..45bf1bbc76 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -1,14 +1,13 @@ -import {memo, useMemo, useState} from 'react' +import {memo, useState} from 'react' import { findNodeHandle, - type ImageStyle, Keyboard, type LayoutChangeEvent, Platform, + ScrollView, StyleSheet, TouchableOpacity, View, - type ViewStyle, } from 'react-native' import {Image} from 'expo-image' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' @@ -16,7 +15,6 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' 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 {type ComposerImage, cropImage} from '#/state/gallery' @@ -32,6 +30,7 @@ import {EditImageDialog} from './EditImageDialog' import {ImageAltTextDialog} from './ImageAltTextDialog' const IMAGE_GAP = 8 +const CONTAINER_HEIGHT = 200 interface GalleryProps { images: ComposerImage[] @@ -63,53 +62,33 @@ interface GalleryInnerProps extends GalleryProps { containerInfo: Dimensions } -const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { - const {isMobile} = useWebMediaQueries() - - const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => { - const side = - images.length === 1 - ? 250 - : (containerInfo.width - IMAGE_GAP * (images.length - 1)) / - images.length - - const isOverflow = isMobile && images.length > 2 - - return { - altTextControlStyle: isOverflow - ? {left: 4, bottom: 4} - : !isMobile && images.length < 3 - ? {left: 8, top: 8} - : {left: 4, top: 4}, - imageControlsStyle: { - display: 'flex' as const, - flexDirection: 'row' as const, - position: 'absolute' as const, - ...(isOverflow - ? {top: 4, right: 4, gap: 4} - : !isMobile && images.length < 3 - ? {top: 8, right: 8, gap: 8} - : {top: 4, right: 4, gap: 4}), - zIndex: 1, - }, - imageStyle: { - height: side, - width: side, - }, - } - }, [images.length, containerInfo, isMobile]) +const getItemWidth = (image: ComposerImage, height: number) => { + const source = image.transformed ?? image.source + if (source.width > 0 && source.height > 0) { + const ratio = source.width / source.height + const w = height * ratio + // Clamp: at least 60% of height, at most 1.5x height + return Math.max(height * 0.6, Math.min(w, height * 1.5)) + } + return height +} +const GalleryInner = ({images, dispatch}: GalleryInnerProps) => { return images.length !== 0 ? ( <> - + {images.map(image => { return ( { dispatch({type: 'embed_update_image', image: next}) }} @@ -119,7 +98,7 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { /> ) })} - + Alt text describes images for blind and low-vision users, and helps @@ -132,18 +111,16 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { type GalleryItemProps = { image: ComposerImage - altTextControlStyle?: ViewStyle - imageControlsStyle?: ViewStyle - imageStyle?: ImageStyle + itemWidth: number + itemHeight: number onChange: (next: ComposerImage) => void onRemove: () => void } const GalleryItem = ({ image, - altTextControlStyle, - imageControlsStyle, - imageStyle, + itemWidth, + itemHeight, onChange, onRemove, }: GalleryItemProps): React.ReactNode => { @@ -156,7 +133,6 @@ const GalleryItem = ({ const [altBtnViewTag, setAltBtnViewTag] = useState() const altBtnRef = (node: View | null) => { - // for iOS 26 fluid transition if (IS_IOS && node) { const tag = findNodeHandle(node) if (tag != null) setAltBtnViewTag(tag) @@ -185,33 +161,8 @@ const GalleryItem = ({ return ( - - {image.alt.length !== 0 ? ( - - ) : ( - - )} - - ALT - - + + + {image.alt.length !== 0 ? ( + + ) : ( + + )} + + ALT + + +