Add overscroll

This commit is contained in:
Eric Bailey
2026-04-15 15:38:16 -05:00
parent a7ba9dcbbb
commit 7cdfc6c933
2 changed files with 98 additions and 47 deletions
+3 -1
View File
@@ -19,7 +19,9 @@ export function tween(start: number, end: number, duration: number) {
const te = t - ts const te = t - ts
const next = Math.round(ease(te, start, end - start, duration)) const next = Math.round(ease(te, start, end - start, duration))
if ( if (
(end > start ? next < end && last <= end : next > end && last >= end) && (end > start
? next < end && last <= end
: next > end && last >= end) &&
te <= duration te <= duration
) { ) {
frame = tick(next) frame = tick(next)
@@ -11,6 +11,8 @@ const FLICK_MIN_VELOCITY = 0.1
const ADVANCE_THRESHOLD = 0.15 const ADVANCE_THRESHOLD = 0.15
const FRAME_MS = 1000 / 60 const FRAME_MS = 1000 / 60
const SETTLE_DURATION = 600 const SETTLE_DURATION = 600
const OVERSCROLL_RESISTANCE = 0.3
const BOUNCE_DURATION = 400
function whichByDistance( function whichByDistance(
itemWidths: Map<number, number>, itemWidths: Map<number, number>,
@@ -70,9 +72,15 @@ export function usePointerHandlers({
let t = 0 let t = 0
let stopTween: (() => void) | null = null let stopTween: (() => void) | null = null
let localIndex = currentIndexRef.current let localIndex = currentIndexRef.current
let overscrollX = 0
el.style.cursor = 'grab' el.style.cursor = 'grab'
const clearOverscroll = () => {
overscrollX = 0
el.style.transform = ''
}
const onMouseDown = (e: MouseEvent) => { const onMouseDown = (e: MouseEvent) => {
e.preventDefault() // prevent native image drag e.preventDefault() // prevent native image drag
@@ -81,6 +89,7 @@ export function usePointerHandlers({
stopTween() stopTween()
stopTween = null stopTween = null
} }
clearOverscroll()
isMouseDown = true isMouseDown = true
isDragging = false isDragging = false
@@ -121,10 +130,28 @@ export function usePointerHandlers({
velo = (delta - prevDelta) / (elapsed * FRAME_MS) velo = (delta - prevDelta) / (elapsed * FRAME_MS)
t = e.timeStamp t = e.timeStamp
scrollTo(dragScrollLeft - delta) const desiredScroll = dragScrollLeft - delta
const maxScroll = el.scrollWidth - el.clientWidth
// Update local index from scroll position if (desiredScroll < 0) {
const offsetX = dragScrollLeft - delta // Overscroll at start — rubber band
scrollTo(0)
overscrollX = desiredScroll * OVERSCROLL_RESISTANCE
el.style.transform = `translateX(${-overscrollX}px)`
} else if (desiredScroll > maxScroll) {
// Overscroll at end — rubber band
scrollTo(maxScroll)
overscrollX = (desiredScroll - maxScroll) * OVERSCROLL_RESISTANCE
el.style.transform = `translateX(${-overscrollX}px)`
} else {
// Normal scroll range
scrollTo(desiredScroll)
if (overscrollX !== 0) clearOverscroll()
}
// Update local index from scroll position (only in normal range)
if (overscrollX === 0) {
const offsetX = desiredScroll
let accumulated = 0 let accumulated = 0
for (let i = 0; i < imageCount; i++) { for (let i = 0; i < imageCount; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
@@ -136,6 +163,7 @@ export function usePointerHandlers({
if (i === imageCount - 1) localIndex = i if (i === imageCount - 1) localIndex = i
} }
} }
}
const onMouseUp = () => { const onMouseUp = () => {
if (!isMouseDown) return if (!isMouseDown) return
@@ -154,7 +182,27 @@ export function usePointerHandlers({
capture: true, capture: true,
}) })
// Estimate resting distance from velocity if (overscrollX !== 0) {
// Bounce back from overscroll
const targetIndex = overscrollX > 0 ? imageCount - 1 : 0
const fromOverscroll = overscrollX
stopTween = tween(
fromOverscroll,
0,
BOUNCE_DURATION,
)(
v => {
el.style.transform = `translateX(${-v}px)`
},
() => {
stopTween = null
clearOverscroll()
onSettle(targetIndex)
},
)
} else {
// Normal flick settle
let v = Math.abs(velo) let v = Math.abs(velo)
let restingDistance = 0 let restingDistance = 0
while (v > FLICK_MIN_VELOCITY) { while (v > FLICK_MIN_VELOCITY) {
@@ -173,7 +221,6 @@ export function usePointerHandlers({
imageCount, imageCount,
) )
// Tween from current scroll position to target
const from = el.scrollLeft const from = el.scrollLeft
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex) const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
@@ -197,6 +244,7 @@ export function usePointerHandlers({
) )
} }
} }
}
el.addEventListener('mousedown', onMouseDown) el.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove) window.addEventListener('mousemove', onMouseMove)
@@ -207,6 +255,7 @@ export function usePointerHandlers({
window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp) window.removeEventListener('mouseup', onMouseUp)
if (stopTween) stopTween() if (stopTween) stopTween()
clearOverscroll()
el.style.cursor = '' el.style.cursor = ''
el.style.userSelect = '' el.style.userSelect = ''
} }