Files
neuron/packages/ui/src/hooks/create-auto-scroll.tsx
T
2026-03-07 05:25:22 -06:00

382 lines
8.6 KiB
TypeScript

import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { animate, type AnimationPlaybackControls } from "motion"
import { FAST_SPRING } from "../components/motion"
export interface AutoScrollOptions {
working: () => boolean
onUserInteracted?: () => void
overflowAnchor?: "none" | "auto" | "dynamic"
bottomThreshold?: number
}
const SETTLE_MS = 500
const AUTO_SCROLL_GRACE_MS = 120
const AUTO_SCROLL_EPSILON = 0.5
const MANUAL_ANCHOR_MS = 3000
const MANUAL_ANCHOR_QUIET_FRAMES = 24
export function createAutoScroll(options: AutoScrollOptions) {
let scroll: HTMLElement | undefined
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
let programmaticUntil = 0
let scrollAnim: AnimationPlaybackControls | undefined
let hold:
| {
el: HTMLElement
top: number
until: number
quiet: number
frame: number | undefined
}
| undefined
const threshold = () => options.bottomThreshold ?? 10
const [store, setStore] = createStore({
contentRef: undefined as HTMLElement | undefined,
userScrolled: false,
})
const active = () => options.working() || settling
const distanceFromBottom = (el: HTMLElement) => {
// With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up
return Math.abs(el.scrollTop)
}
const canScroll = (el: HTMLElement) => {
return el.scrollHeight - el.clientHeight > 1
}
const markProgrammatic = () => {
programmaticUntil = Date.now() + AUTO_SCROLL_GRACE_MS
}
const clearHold = () => {
const next = hold
if (!next) return
if (next.frame !== undefined) cancelAnimationFrame(next.frame)
hold = undefined
}
const tickHold = () => {
const next = hold
const el = scroll
if (!next || !el) return false
if (Date.now() > next.until) {
clearHold()
return false
}
if (!next.el.isConnected) {
clearHold()
return false
}
const current = next.el.getBoundingClientRect().top
if (!Number.isFinite(current)) {
clearHold()
return false
}
const delta = current - next.top
if (Math.abs(delta) <= AUTO_SCROLL_EPSILON) {
next.quiet += 1
if (next.quiet > MANUAL_ANCHOR_QUIET_FRAMES) {
clearHold()
return false
}
return true
}
next.quiet = 0
if (!store.userScrolled) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
el.scrollTop += delta
markProgrammatic()
return true
}
const scheduleHold = () => {
const next = hold
if (!next) return
if (next.frame !== undefined) return
next.frame = requestAnimationFrame(() => {
const value = hold
if (!value) return
value.frame = undefined
if (!tickHold()) return
scheduleHold()
})
}
const preserve = (target: HTMLElement) => {
const el = scroll
if (!el) return
if (!store.userScrolled) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
const top = target.getBoundingClientRect().top
if (!Number.isFinite(top)) return
clearHold()
hold = {
el: target,
top,
until: Date.now() + MANUAL_ANCHOR_MS,
quiet: 0,
frame: undefined,
}
scheduleHold()
}
const scrollToBottom = (force: boolean) => {
if (!force && !active()) return
clearHold()
if (force && store.userScrolled) setStore("userScrolled", false)
const el = scroll
if (!el) return
if (scrollAnim) cancelSmooth()
if (!force && store.userScrolled) return
// With column-reverse, scrollTop=0 is at the bottom
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
markProgrammatic()
return
}
el.scrollTop = 0
markProgrammatic()
}
const cancelSmooth = () => {
if (scrollAnim) {
scrollAnim.stop()
scrollAnim = undefined
}
}
const smoothScrollToBottom = () => {
const el = scroll
if (!el) return
cancelSmooth()
if (store.userScrolled) setStore("userScrolled", false)
// With column-reverse, scrollTop=0 is at the bottom
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
markProgrammatic()
return
}
scrollAnim = animate(el.scrollTop, 0, {
...FAST_SPRING,
onUpdate: (v) => {
markProgrammatic()
el.scrollTop = v
},
onComplete: () => {
scrollAnim = undefined
markProgrammatic()
},
})
}
const stop = (input?: { hold?: boolean }) => {
if (input?.hold !== false) clearHold()
const el = scroll
if (!el) return
if (!canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
return
}
if (store.userScrolled) return
markProgrammatic()
setStore("userScrolled", true)
options.onUserInteracted?.()
}
const handleWheel = (e: WheelEvent) => {
if (e.deltaY !== 0) clearHold()
if (e.deltaY > 0) {
const el = scroll
if (!el) return
if (distanceFromBottom(el) >= threshold()) return
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (e.deltaY >= 0) return
cancelSmooth()
const el = scroll
const target = e.target instanceof Element ? e.target : undefined
const nested = target?.closest("[data-scrollable]")
if (el && nested && nested !== el) return
stop()
}
const handleScroll = () => {
const el = scroll
if (!el) return
if (hold) {
if (Date.now() < programmaticUntil) return
clearHold()
}
if (!canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (distanceFromBottom(el) < threshold()) {
if (Date.now() < programmaticUntil) return
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (!store.userScrolled && Date.now() < programmaticUntil) return
stop({ hold: false })
}
const handleInteraction = () => {
if (!active()) return
const selection = window.getSelection()
if (selection && selection.toString().length > 0) {
stop()
}
}
const updateOverflowAnchor = (el: HTMLElement) => {
if (hold) {
el.style.overflowAnchor = "none"
return
}
const mode = options.overflowAnchor ?? "dynamic"
if (mode === "none") {
el.style.overflowAnchor = "none"
return
}
if (mode === "auto") {
el.style.overflowAnchor = "auto"
return
}
el.style.overflowAnchor = store.userScrolled ? "auto" : "none"
}
createResizeObserver(
() => store.contentRef,
() => {
const el = scroll
if (hold) {
scheduleHold()
return
}
if (el && !canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (!active()) return
if (store.userScrolled) return
scrollToBottom(false)
},
)
createEffect(
on(options.working, (working: boolean) => {
settling = false
if (settleTimer) clearTimeout(settleTimer)
settleTimer = undefined
if (working) {
if (!store.userScrolled) scrollToBottom(true)
return
}
settling = true
settleTimer = setTimeout(() => {
settling = false
}, SETTLE_MS)
}),
)
createEffect(() => {
store.userScrolled
const el = scroll
if (!el) return
updateOverflowAnchor(el)
})
onCleanup(() => {
if (settleTimer) clearTimeout(settleTimer)
clearHold()
cancelSmooth()
if (cleanup) cleanup()
})
return {
scrollRef: (el: HTMLElement | undefined) => {
if (cleanup) {
cleanup()
cleanup = undefined
}
scroll = el
if (!el) {
clearHold()
return
}
markProgrammatic()
updateOverflowAnchor(el)
el.addEventListener("wheel", handleWheel, { passive: true })
cleanup = () => {
el.removeEventListener("wheel", handleWheel)
}
},
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
handleScroll,
handleInteraction,
preserve,
pause: stop,
forceScrollToBottom: () => scrollToBottom(true),
smoothScrollToBottom,
snapToBottom: () => {
const el = scroll
if (!el) return
if (store.userScrolled) setStore("userScrolled", false)
// With column-reverse, scrollTop=0 is at the bottom
el.scrollTop = 0
markProgrammatic()
},
userScrolled: () => store.userScrolled,
}
}