From e162e1d2c32787a6d6e2c034a8d52bc2c5b8b041 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Fri, 3 Apr 2026 14:36:42 -0400 Subject: [PATCH] use embla for web --- package.json | 1 + src/components/images/Gallery/index.web.tsx | 483 ++++++++---------- .../moderation/ReportDialog/index.tsx | 14 +- src/view/com/composer/photos/Gallery.tsx | 7 +- yarn.lock | 64 +-- 5 files changed, 265 insertions(+), 304 deletions(-) diff --git a/package.json b/package.json index 36f7ec6152..9e47e05ea1 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "bcp-47-match": "^2.0.3", "date-fns": "^2.30.0", "email-validator": "^2.0.4", + "embla-carousel-react": "^8.6.0", "emoji-mart": "^5.6.0", "emoji-regex": "^10.4.0", "eventemitter3": "^5.0.1", diff --git a/src/components/images/Gallery/index.web.tsx b/src/components/images/Gallery/index.web.tsx index d877e8ee13..ad1fc1a5e3 100644 --- a/src/components/images/Gallery/index.web.tsx +++ b/src/components/images/Gallery/index.web.tsx @@ -1,20 +1,21 @@ -import {useEffect, useRef, useState} from 'react' -import {Pressable, ScrollView, View} from 'react-native' +import {useCallback, useEffect, useRef, useState} from 'react' +import {Pressable, View} from 'react-native' import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' import {type AppBskyEmbedImages} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' +import useEmblaCarousel from 'embla-carousel-react' import {type Dimensions} from '#/lib/media/types' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' -import {atoms as a, useTheme, web} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' const CONTAINER_ASPECT_RATIO = 3 / 2 -const ITEM_GAP = 8 // tokens.space.sm +const ITEM_GAP = 8 interface GalleryProps { images: AppBskyEmbedImages.ViewImage[] @@ -38,11 +39,11 @@ export function Gallery({ const ax = useAnalytics() const largeAltBadge = useLargeAltBadgeEnabled() const currentPageRef = useRef(0) - const scrollRef = useRef(null) const containerRef = useRef(null) const [containerWidth, setContainerWidth] = useState(0) const [insetLeft, setInsetLeft] = useState(0) const [insetRight, setInsetRight] = useState(0) + const insetLeftRef = useRef(0) const containerRefs = useRef[]>([]).current const thumbDimsRef = useRef<(Dimensions | null)[]>([]) @@ -63,291 +64,261 @@ export function Gallery({ const QUOTE_PADDING = 12 const containerHeight = containerWidth > 0 ? containerWidth / CONTAINER_ASPECT_RATIO : 0 - const scrollWidth = isWithinQuote - ? containerWidth + QUOTE_PADDING * 2 - : insetLeft + insetRight > 0 - ? containerWidth + insetLeft + insetRight - : containerWidth const getItemWidth = (image: AppBskyEmbedImages.ViewImage) => { const ar = image.aspectRatio if (ar && ar.width > 0 && ar.height > 0) { const ratio = ar.width / ar.height - // Clamp aspect ratio between 2:3 (portrait) and 3:2 (landscape) const clamped = Math.max(2 / 3, Math.min(ratio, 3 / 2)) return containerHeight * clamped } - return containerHeight // default to square-ish + return containerHeight } - // Click-and-drag scrolling via DOM listeners - const hasDragged = useRef(false) + // Embla carousel + const [emblaRef, emblaApi] = useEmblaCarousel({ + align: () => insetLeftRef.current, + containScroll: false, + dragFree: true, + }) + + // Track page changes for analytics + useEffect(() => { + if (!emblaApi) return + const onSelect = () => { + const page = emblaApi.selectedScrollSnap() + if (page !== currentPageRef.current) { + ax.metric('post:gallery:swipe', { + fromIndex: currentPageRef.current, + toIndex: page, + totalImages: images.length, + }) + currentPageRef.current = page + } + } + emblaApi.on('select', onSelect) + return () => { + emblaApi.off('select', onSelect) + } + }, [emblaApi, ax, images.length]) + + // Re-initialize Embla when bleed measurements change + useEffect(() => { + if (emblaApi) emblaApi.reInit() + }, [emblaApi, insetLeft, insetRight]) + + // Suppress click after drag + const pointerDown = useRef(false) + const dragged = useRef(false) useEffect(() => { - const el = scrollRef.current as unknown as HTMLElement - if (!el) return + if (!emblaApi) return + const root = emblaApi.rootNode() - let isDragging = false - let startX = 0 - let scrollStart = 0 - let prevX = 0 - let prevTime = 0 - let velocity = 0 - let momentumId = 0 - - el.style.cursor = 'grab' - - const onMouseDown = (e: MouseEvent) => { - if (e.button !== 0) return - cancelAnimationFrame(momentumId) - isDragging = true - hasDragged.current = false - startX = e.pageX - el.offsetLeft - scrollStart = el.scrollLeft - prevX = e.pageX - prevTime = Date.now() - velocity = 0 - el.style.cursor = 'grabbing' - e.preventDefault() // prevents native image drag + const onPointerDown = () => { + pointerDown.current = true + dragged.current = false + } + const onPointerMove = () => { + if (pointerDown.current) dragged.current = true + } + const onPointerUp = () => { + pointerDown.current = false + } + const onClick = (e: MouseEvent) => { + if (dragged.current) { + e.stopPropagation() + e.preventDefault() + } } - const onMouseMove = (e: MouseEvent) => { - if (!isDragging) return - const x = e.pageX - el.offsetLeft - if (Math.abs(x - startX) > 3) { - hasDragged.current = true - } - // Track velocity from recent movement - const now = Date.now() - const dt = now - prevTime - if (dt > 0) { - velocity = (e.pageX - prevX) / dt - } - prevX = e.pageX - prevTime = now - el.scrollLeft = scrollStart - (x - startX) - } - - const onMouseUp = () => { - if (!isDragging) return - if (hasDragged.current) { - el.addEventListener('click', e => e.stopPropagation(), {once: true}) - } - isDragging = false - el.style.cursor = 'grab' - - // Apply momentum with friction - const friction = 0.95 - let v = -velocity * 15 // scale velocity to px/frame - const coast = () => { - if (Math.abs(v) < 0.5) return - el.scrollLeft += v - v *= friction - momentumId = requestAnimationFrame(coast) - } - coast() - } - - el.addEventListener('mousedown', onMouseDown) - window.addEventListener('mousemove', onMouseMove) - window.addEventListener('mouseup', onMouseUp) - + root.addEventListener('pointerdown', onPointerDown) + root.addEventListener('pointermove', onPointerMove) + root.addEventListener('pointerup', onPointerUp) + root.addEventListener('click', onClick, true) return () => { - el.removeEventListener('mousedown', onMouseDown) - window.removeEventListener('mousemove', onMouseMove) - window.removeEventListener('mouseup', onMouseUp) + root.removeEventListener('pointerdown', onPointerDown) + root.removeEventListener('pointermove', onPointerMove) + root.removeEventListener('pointerup', onPointerUp) + root.removeEventListener('click', onClick, true) } - }, [containerWidth]) // re-attach when scroll view mounts + }, [emblaApi]) + + // Bleed measurement + const measureBleed = useCallback(() => { + if (isWithinQuote) return + requestAnimationFrame(() => { + const el = containerRef.current as unknown as HTMLElement + if (!el) return + const galleryRect = el.getBoundingClientRect() + let parent: HTMLElement | null = el.parentElement + while (parent) { + const ps = window.getComputedStyle(parent) + const pl = parseFloat(ps.paddingLeft) + const pr = parseFloat(ps.paddingRight) + if (pl >= 8 && pr >= 8 && ps.cursor === 'pointer') { + const parentRect = parent.getBoundingClientRect() + const il = galleryRect.left - parentRect.left + insetLeftRef.current = il + setInsetLeft(il) + setInsetRight(parentRect.right - galleryRect.right) + break + } + parent = parent.parentElement + } + }) + }, [isWithinQuote]) + + const isBleed = !isWithinQuote && (insetLeft > 0 || insetRight > 0) return ( 0 - ? {height: containerHeight, overflow: 'visible'} + ? isWithinQuote + ? { + height: containerHeight, + overflow: 'hidden' as const, + width: containerWidth + QUOTE_PADDING * 2, + marginLeft: -QUOTE_PADDING, + } + : {height: containerHeight, overflow: 'visible' as const} : {aspectRatio: CONTAINER_ASPECT_RATIO} } ref={containerRef} onLayout={e => { const w = e.nativeEvent.layout.width - if (w > 0) { - setContainerWidth(w) - } - // Measure distance to post edges for bleed - if (!isWithinQuote) { - requestAnimationFrame(() => { - const el = containerRef.current as unknown as HTMLElement - if (!el) return - const galleryRect = el.getBoundingClientRect() - // Walk up to find the post outer container (has paddingLeft/paddingRight) - let parent: HTMLElement | null = el.parentElement - while (parent) { - const ps = window.getComputedStyle(parent) - const pl = parseFloat(ps.paddingLeft) - const pr = parseFloat(ps.paddingRight) - if (pl >= 8 && pr >= 8 && ps.cursor === 'pointer') { - const parentRect = parent.getBoundingClientRect() - setInsetLeft(galleryRect.left - parentRect.left) - setInsetRight(parentRect.right - galleryRect.right) - break - } - parent = parent.parentElement - } - }) - } + if (w > 0 && containerWidth === 0) setContainerWidth(w) + measureBleed() }} role="group" aria-roledescription="carousel" aria-label={l`Image gallery, ${images.length} images`}> {containerWidth > 0 && ( - 0 - ? -insetLeft - : 0, - }, - web({ - WebkitOverflowScrolling: 'touch', - }), - ]} - contentContainerStyle={{ - gap: ITEM_GAP, - paddingLeft: isWithinQuote - ? QUOTE_PADDING - : insetLeft > 0 - ? insetLeft - : 0, - paddingRight: isWithinQuote - ? QUOTE_PADDING - : insetRight > 0 - ? insetRight - : 0, - }} - onScroll={e => { - const offsetX = e.nativeEvent.contentOffset.x - let accumulated = 0 - 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 - } +
- {images.map((image, index) => ( - - { - if (hasDragged.current) return - ax.metric('post:gallery:openLightbox', { - imageIndex: index, - totalImages: images.length, - }) - onPress( - index, - containerRefs.slice(0, images.length), - thumbDimsRef.current.slice(), - ) - } - : undefined - } - onPressIn={onPressIn ? () => onPressIn(index) : undefined} - accessibilityRole="button" - accessibilityLabel={ - image.alt || l`Image ${index + 1} of ${images.length}` - } - accessibilityHint={l`Opens full image`} - style={[ - a.flex_1, - a.rounded_md, - a.overflow_hidden, - t.atoms.bg_contrast_25, - ]}> - { - thumbDimsRef.current[index] = { - width: e.source.width, - height: e.source.height, - } - }} - loading={index === 0 ? 'eager' : 'lazy'} - /> - - - {image.alt && !hideBadges ? ( - - +
+ {images.map((image, index) => ( +
+ - ALT - - - ) : null} - - ))} - + {width: getItemWidth(image), height: containerHeight}, + ]} + aria-roledescription="slide" + aria-label={ + image.alt || l`Image ${index + 1} of ${images.length}` + }> + { + if (dragged.current) return + ax.metric('post:gallery:openLightbox', { + imageIndex: index, + totalImages: images.length, + }) + onPress( + index, + containerRefs.slice(0, images.length), + thumbDimsRef.current.slice(), + ) + } + : undefined + } + onPressIn={onPressIn ? () => onPressIn(index) : undefined} + accessibilityRole="button" + accessibilityLabel={ + image.alt || l`Image ${index + 1} of ${images.length}` + } + accessibilityHint={l`Opens full image`} + style={[ + a.flex_1, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + ]}> + { + thumbDimsRef.current[index] = { + width: e.source.width, + height: e.source.height, + } + }} + loading={index === 0 ? 'eager' : 'lazy'} + /> + + + {image.alt && !hideBadges ? ( + + + ALT + + + ) : null} + +
+ ))} +
+
+ )}
) diff --git a/src/components/moderation/ReportDialog/index.tsx b/src/components/moderation/ReportDialog/index.tsx index ec6484ae6f..678fd8886a 100644 --- a/src/components/moderation/ReportDialog/index.tsx +++ b/src/components/moderation/ReportDialog/index.tsx @@ -184,7 +184,7 @@ function Inner(props: ReportDialogProps) { ) }) }, [ - props, + props.subject, allLabelers, state.selectedOption, isBskyOnlyReason, @@ -241,7 +241,17 @@ function Inner(props: ReportDialogProps) { } finally { setPending(false) } - }, [_, submitReport, state, dispatch, props, setPending, setSuccess]) + }, [ + _, + submitReport, + state, + dispatch, + props.subject, + props.control, + props.onAfterSubmit, + setPending, + setSuccess, + ]) useCallOnce(() => { ax.metric('reportDialog:open', { diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index c087c172fc..28e7c859c4 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -4,7 +4,6 @@ import { Keyboard, type LayoutChangeEvent, Platform, - ScrollView, StyleSheet, TouchableOpacity, View, @@ -18,6 +17,7 @@ import {Trans} from '@lingui/react/macro' import {type Dimensions} from '#/lib/media/types' import {colors} from '#/lib/styles' import {type ComposerImage, cropImage} from '#/state/gallery' +import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' import {atoms as a, tokens, useTheme} from '#/alf' import {Admonition} from '#/components/Admonition' import * as Dialog from '#/components/Dialog' @@ -76,9 +76,8 @@ const getItemWidth = (image: ComposerImage, height: number) => { const GalleryInner = ({images, dispatch}: GalleryInnerProps) => { return images.length !== 0 ? ( <> - @@ -98,7 +97,7 @@ const GalleryInner = ({images, dispatch}: GalleryInnerProps) => { /> ) })} - + {images.some(image => !image.alt) && ( diff --git a/yarn.lock b/yarn.lock index 4a5d8dc52e..36d2ac37d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2174,7 +2174,7 @@ "@babel/parser" "^7.28.6" "@babel/types" "^7.28.6" -"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3": +"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== @@ -2235,19 +2235,6 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" - integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== - dependencies: - "@babel/code-frame" "^7.25.9" - "@babel/generator" "^7.25.9" - "@babel/parser" "^7.25.9" - "@babel/template" "^7.25.9" - "@babel/types" "^7.25.9" - debug "^4.3.1" - globals "^11.1.0" - "@babel/traverse@^7.26.10": version "7.26.10" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.10.tgz#43cca33d76005dbaa93024fae536cc1946a4c380" @@ -8061,6 +8048,24 @@ email-validator@^2.0.4: resolved "https://registry.yarnpkg.com/email-validator/-/email-validator-2.0.4.tgz#b8dfaa5d0dae28f1b03c95881d904d4e40bfe7ed" integrity sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ== +embla-carousel-react@^8.6.0: + version "8.6.0" + resolved "https://registry.yarnpkg.com/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz#b737042a32761c38d6614593653b3ac619477bd1" + integrity sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA== + dependencies: + embla-carousel "8.6.0" + embla-carousel-reactive-utils "8.6.0" + +embla-carousel-reactive-utils@8.6.0: + version "8.6.0" + resolved "https://registry.yarnpkg.com/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz#607f1d8ab9921c906a555c206251b2c6db687223" + integrity sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A== + +embla-carousel@8.6.0: + version "8.6.0" + resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-8.6.0.tgz#abcedff2bff36992ea8ac27cd30080ca5b6a3f58" + integrity sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA== + emittery@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" @@ -15218,16 +15223,7 @@ string-length@^5.0.1: char-regex "^2.0.0" strip-ansi "^7.0.1" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -15372,7 +15368,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15386,13 +15382,6 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -16542,7 +16531,7 @@ wonka@^6.3.2: resolved "https://registry.yarnpkg.com/wonka/-/wonka-6.3.4.tgz#76eb9316e3d67d7febf4945202b5bdb2db534594" integrity sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -16560,15 +16549,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"