125 lines
3.5 KiB
TypeScript
125 lines
3.5 KiB
TypeScript
type ScrollOptions = {
|
|
topPad?: number
|
|
bottomPad?: number
|
|
keyboardWindowMs?: number
|
|
getScrollContainer?: () => HTMLElement | null
|
|
}
|
|
|
|
export function createKeyboardFollowScroll(options: ScrollOptions = {}) {
|
|
const {
|
|
topPad = 84,
|
|
bottomPad = 84,
|
|
keyboardWindowMs = 260,
|
|
getScrollContainer = () =>
|
|
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
|
} = options
|
|
|
|
let scrollAnimationFrame: number | null = null
|
|
let scrollTargetY: number | null = null
|
|
let scrollVelocity = 0
|
|
let keyboardFollowUntil = 0
|
|
|
|
const markKeyboardFollow = () => {
|
|
keyboardFollowUntil = performance.now() + keyboardWindowMs
|
|
}
|
|
|
|
const keyboardFollowActive = () => performance.now() < keyboardFollowUntil
|
|
|
|
const clampScrollY = (y: number, scroller: HTMLElement) => {
|
|
const maxY = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
|
|
return Math.min(maxY, Math.max(0, y))
|
|
}
|
|
|
|
const cursorScrollTarget = (el: HTMLElement): number | null => {
|
|
const scroller = getScrollContainer() ?? document.documentElement
|
|
const rect = el.getBoundingClientRect()
|
|
const scrollerRect = scroller.getBoundingClientRect()
|
|
const visibleTop = scrollerRect.top + topPad
|
|
const visibleBottom = scrollerRect.bottom - bottomPad
|
|
|
|
if (rect.top >= visibleTop && rect.bottom <= visibleBottom) return null
|
|
|
|
if (rect.top < visibleTop) {
|
|
return clampScrollY(scroller.scrollTop + (rect.top - visibleTop), scroller)
|
|
}
|
|
|
|
return clampScrollY(scroller.scrollTop + (rect.bottom - visibleBottom), scroller)
|
|
}
|
|
|
|
const runSmoothCursorScroll = () => {
|
|
if (scrollAnimationFrame != null) return
|
|
|
|
const step = () => {
|
|
const scroller = getScrollContainer() ?? document.documentElement
|
|
|
|
if (scrollTargetY == null) {
|
|
scrollVelocity *= 0.68
|
|
if (Math.abs(scrollVelocity) > 0.05) {
|
|
const next = clampScrollY(scroller.scrollTop + scrollVelocity, scroller)
|
|
scroller.scrollTop = next
|
|
scrollAnimationFrame = requestAnimationFrame(step)
|
|
return
|
|
}
|
|
scrollVelocity = 0
|
|
scrollAnimationFrame = null
|
|
return
|
|
}
|
|
|
|
const current = scroller.scrollTop
|
|
const delta = scrollTargetY - current
|
|
const absDelta = Math.abs(delta)
|
|
if (absDelta < 0.6 && Math.abs(scrollVelocity) < 0.08) {
|
|
scroller.scrollTop = scrollTargetY
|
|
scrollVelocity = 0
|
|
scrollTargetY = null
|
|
scrollAnimationFrame = null
|
|
return
|
|
}
|
|
|
|
const stiffness = Math.min(0.022, 0.01 + absDelta / 10000)
|
|
const damping = 0.76
|
|
scrollVelocity += delta * stiffness
|
|
scrollVelocity *= damping
|
|
|
|
const next = clampScrollY(current + scrollVelocity, scroller)
|
|
if (next === current) scrollVelocity = 0
|
|
scroller.scrollTop = next
|
|
scrollAnimationFrame = requestAnimationFrame(step)
|
|
}
|
|
|
|
scrollAnimationFrame = requestAnimationFrame(step)
|
|
}
|
|
|
|
const keepVisible = (el: HTMLElement | null) => {
|
|
if (!keyboardFollowActive()) {
|
|
scrollTargetY = null
|
|
scrollVelocity = 0
|
|
return
|
|
}
|
|
|
|
if (!el) {
|
|
scrollTargetY = null
|
|
return
|
|
}
|
|
|
|
const target = cursorScrollTarget(el)
|
|
if (target == null) {
|
|
scrollTargetY = null
|
|
return
|
|
}
|
|
|
|
scrollTargetY = target
|
|
runSmoothCursorScroll()
|
|
}
|
|
|
|
const cancel = () => {
|
|
if (scrollAnimationFrame != null) cancelAnimationFrame(scrollAnimationFrame)
|
|
scrollAnimationFrame = null
|
|
scrollTargetY = null
|
|
scrollVelocity = 0
|
|
keyboardFollowUntil = 0
|
|
}
|
|
|
|
return { markKeyboardFollow, keepVisible, cancel }
|
|
}
|