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,19 +130,38 @@ 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
let accumulated = 0 scrollTo(0)
for (let i = 0; i < imageCount; i++) { overscrollX = desiredScroll * OVERSCROLL_RESISTANCE
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP el.style.transform = `translateX(${-overscrollX}px)`
if (offsetX < accumulated + w / 2) { } else if (desiredScroll > maxScroll) {
localIndex = i // Overscroll at end — rubber band
break 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
for (let i = 0; i < imageCount; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
if (offsetX < accumulated + w / 2) {
localIndex = i
break
}
accumulated += w
if (i === imageCount - 1) localIndex = i
} }
accumulated += w
if (i === imageCount - 1) localIndex = i
} }
} }
@@ -154,47 +182,67 @@ export function usePointerHandlers({
capture: true, capture: true,
}) })
// Estimate resting distance from velocity if (overscrollX !== 0) {
let v = Math.abs(velo) // Bounce back from overscroll
let restingDistance = 0 const targetIndex = overscrollX > 0 ? imageCount - 1 : 0
while (v > FLICK_MIN_VELOCITY) { const fromOverscroll = overscrollX
v *= FLICK_DECAY
restingDistance += v
}
const direction: -1 | 1 = delta < 0 ? -1 : 1 stopTween = tween(
const totalDistance = Math.abs(delta) + restingDistance 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 restingDistance = 0
while (v > FLICK_MIN_VELOCITY) {
v *= FLICK_DECAY
restingDistance += v
}
const targetIndex = whichByDistance( const direction: -1 | 1 = delta < 0 ? -1 : 1
itemWidthsRef.current, const totalDistance = Math.abs(delta) + restingDistance
localIndex,
totalDistance,
direction,
imageCount,
)
// Tween from current scroll position to target const targetIndex = whichByDistance(
const from = el.scrollLeft itemWidthsRef.current,
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex) localIndex,
totalDistance,
direction,
imageCount,
)
if (from === to) { const from = el.scrollLeft
onSettle(targetIndex) const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
return
}
stopTween = tween( if (from === to) {
from,
to,
SETTLE_DURATION,
)(
v => {
scrollTo(v)
},
() => {
stopTween = null
onSettle(targetIndex) onSettle(targetIndex)
}, return
) }
stopTween = tween(
from,
to,
SETTLE_DURATION,
)(
v => {
scrollTo(v)
},
() => {
stopTween = null
onSettle(targetIndex)
},
)
}
} }
} }
@@ -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 = ''
} }