diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index 79c7af4a15..101c8b895c 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -28,6 +28,7 @@ import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers' +import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers' const CONTAINER_ASPECT_RATIO = 3 / 2 const ITEM_GAP = 8 // tokens.space.sm @@ -152,6 +153,17 @@ export function Gallery({ imageCount: images.length, }) + usePointerHandlers({ + flatListRef, + itemWidthsRef, + currentIndexRef, + onSettle(index: number) { + const el = itemRefsRef.current.get(index) as unknown as HTMLElement | null + el?.focus({preventScroll: true}) + }, + imageCount: images.length, + }) + return ( 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) + } + } +} diff --git a/src/components/images/Gallery/usePointerHandlers.ts b/src/components/images/Gallery/usePointerHandlers.ts new file mode 100644 index 0000000000..d0cdb131e2 --- /dev/null +++ b/src/components/images/Gallery/usePointerHandlers.ts @@ -0,0 +1,7 @@ +export function usePointerHandlers(_args: { + flatListRef: any + itemWidthsRef: any + currentIndexRef: any + onSettle: any + imageCount: any +}) {} diff --git a/src/components/images/Gallery/usePointerHandlers.web.ts b/src/components/images/Gallery/usePointerHandlers.web.ts new file mode 100644 index 0000000000..2bb68f9518 --- /dev/null +++ b/src/components/images/Gallery/usePointerHandlers.web.ts @@ -0,0 +1,212 @@ +import {useEffect} from 'react' +import {type FlatList} from 'react-native' + +import {tween} from '#/components/images/Gallery/tween' + +const ITEM_GAP = 8 +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 = 600 + +function getOffsetForIndex( + itemWidths: Map, + index: number, +): number { + let offset = 0 + for (let i = 0; i < index; i++) { + offset += (itemWidths.get(i) ?? 0) + ITEM_GAP + } + return offset +} + +function whichByDistance( + itemWidths: Map, + 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, + onSettle, + imageCount, +}: { + flatListRef: React.RefObject + itemWidthsRef: React.RefObject> + currentIndexRef: React.RefObject + 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 + + el.style.cursor = 'grab' + + const onMouseDown = (e: MouseEvent) => { + e.preventDefault() // prevent native image drag + + // Cancel any in-progress tween + if (stopTween) { + stopTween() + stopTween = null + } + + isMouseDown = true + isDragging = false + 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 + + el.scrollLeft = dragScrollLeft - delta + + // Update current index from scroll position + const offsetX = el.scrollLeft + let accumulated = 0 + for (let i = 0; i < imageCount; i++) { + const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP + if (offsetX < accumulated + w / 2) { + currentIndexRef.current = i + break + } + accumulated += w + if (i === imageCount - 1) currentIndexRef.current = 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, + }) + + // Estimate resting distance from velocity + 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, + currentIndexRef.current, + totalDistance, + direction, + imageCount, + ) + + // Tween from current scroll position to target + const from = el.scrollLeft + const to = getOffsetForIndex(itemWidthsRef.current, targetIndex) + + stopTween = tween(from, to, SETTLE_DURATION)( + v => { + el.scrollLeft = v + }, + () => { + stopTween = null + currentIndexRef.current = targetIndex + 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() + el.style.cursor = '' + el.style.userSelect = '' + } + }, [flatListRef, itemWidthsRef, currentIndexRef, onSettle, imageCount]) +}