Web in a good place

This commit is contained in:
Eric Bailey
2026-04-14 15:14:11 -05:00
parent da4f3ac88b
commit 59a9f3f729
4 changed files with 269 additions and 0 deletions
+12
View File
@@ -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 (
<View
ref={contentRef}
+38
View File
@@ -0,0 +1,38 @@
function ease(t: number, b: number, c: number, d: number) {
return t === d ? b + c : c * (-Math.pow(2, (-10 * t) / d) + 1) + b
}
/**
* Tween from `start` to `end` over `duration` ms using an exponential ease-out.
* Returns a function that starts the tween. That function returns a stop handle.
*
* Adapted from tinkerbell.
*/
export function tween(start: number, end: number, duration: number) {
return function run(cb: (v: number) => 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)
}
}
}
@@ -0,0 +1,7 @@
export function usePointerHandlers(_args: {
flatListRef: any
itemWidthsRef: any
currentIndexRef: any
onSettle: any
imageCount: any
}) {}
@@ -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<number, number>,
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<number, number>,
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<FlatList | null>
itemWidthsRef: React.RefObject<Map<number, number>>
currentIndexRef: React.RefObject<number>
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])
}