| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224 |
- import { createEffect, on, onCleanup } from "solid-js"
- import { createStore } from "solid-js/store"
- import { createResizeObserver } from "@solid-primitives/resize-observer"
- export interface AutoScrollOptions {
- working: () => boolean
- onUserInteracted?: () => void
- overflowAnchor?: "none" | "auto" | "dynamic"
- bottomThreshold?: number
- }
- export function createAutoScroll(options: AutoScrollOptions) {
- let scroll: HTMLElement | undefined
- let settling = false
- let settleTimer: ReturnType<typeof setTimeout> | undefined
- let autoTimer: ReturnType<typeof setTimeout> | undefined
- let cleanup: (() => void) | undefined
- let auto: { top: number; time: number } | 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) => {
- return el.scrollHeight - el.clientHeight - el.scrollTop
- }
- // Browsers can dispatch scroll events asynchronously. If new content arrives
- // between us calling `scrollTo()` and the subsequent `scroll` event firing,
- // the handler can see a non-zero `distanceFromBottom` and incorrectly assume
- // the user scrolled.
- const markAuto = (el: HTMLElement) => {
- auto = {
- top: Math.max(0, el.scrollHeight - el.clientHeight),
- time: Date.now(),
- }
- if (autoTimer) clearTimeout(autoTimer)
- autoTimer = setTimeout(() => {
- auto = undefined
- autoTimer = undefined
- }, 250)
- }
- const isAuto = (el: HTMLElement) => {
- const a = auto
- if (!a) return false
- if (Date.now() - a.time > 250) {
- auto = undefined
- return false
- }
- return Math.abs(el.scrollTop - a.top) < 2
- }
- const scrollToBottomNow = (behavior: ScrollBehavior) => {
- const el = scroll
- if (!el) return
- markAuto(el)
- if (behavior === "smooth") {
- el.scrollTo({ top: el.scrollHeight, behavior })
- return
- }
- // `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`.
- el.scrollTop = el.scrollHeight
- }
- const scrollToBottom = (force: boolean) => {
- if (!force && !active()) return
- const el = scroll
- if (!el) return
- if (!force && store.userScrolled) return
- if (force && store.userScrolled) setStore("userScrolled", false)
- const distance = distanceFromBottom(el)
- if (distance < 2) return
- // For auto-following content we prefer immediate updates to avoid
- // visible "catch up" animations while content is still settling.
- scrollToBottomNow("auto")
- }
- const stop = () => {
- if (store.userScrolled) return
- setStore("userScrolled", true)
- options.onUserInteracted?.()
- }
- const handleWheel = (e: WheelEvent) => {
- if (e.deltaY >= 0) return
- // If the user is scrolling within a nested scrollable region (tool output,
- // code block, etc), don't treat it as leaving the "follow bottom" mode.
- // Those regions opt in via `data-scrollable`.
- 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 (distanceFromBottom(el) < threshold()) {
- if (store.userScrolled) setStore("userScrolled", false)
- return
- }
- // Ignore scroll events triggered by our own scrollToBottom calls.
- if (!store.userScrolled && isAuto(el)) {
- scrollToBottom(false)
- return
- }
- stop()
- }
- const handleInteraction = () => {
- if (!active()) return
- stop()
- }
- const updateOverflowAnchor = (el: HTMLElement) => {
- 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,
- () => {
- if (!active()) return
- if (store.userScrolled) return
- // ResizeObserver fires after layout, before paint.
- // Keep the bottom locked in the same frame to avoid visible
- // "jump up then catch up" artifacts while streaming content.
- scrollToBottom(false)
- },
- )
- createEffect(
- on(options.working, (working) => {
- settling = false
- if (settleTimer) clearTimeout(settleTimer)
- settleTimer = undefined
- if (working) {
- if (!store.userScrolled) scrollToBottom(true)
- return
- }
- settling = true
- settleTimer = setTimeout(() => {
- settling = false
- }, 300)
- }),
- )
- createEffect(() => {
- // Track `userScrolled` even before `scrollRef` is attached, so we can
- // update overflow anchoring once the element exists.
- store.userScrolled
- const el = scroll
- if (!el) return
- updateOverflowAnchor(el)
- })
- onCleanup(() => {
- if (settleTimer) clearTimeout(settleTimer)
- if (autoTimer) clearTimeout(autoTimer)
- if (cleanup) cleanup()
- })
- return {
- scrollRef: (el: HTMLElement | undefined) => {
- if (cleanup) {
- cleanup()
- cleanup = undefined
- }
- scroll = el
- if (!el) return
- updateOverflowAnchor(el)
- el.addEventListener("wheel", handleWheel, { passive: true })
- cleanup = () => {
- el.removeEventListener("wheel", handleWheel)
- }
- },
- contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
- handleScroll,
- handleInteraction,
- pause: stop,
- resume: () => {
- if (store.userScrolled) setStore("userScrolled", false)
- scrollToBottom(true)
- },
- scrollToBottom: () => scrollToBottom(false),
- forceScrollToBottom: () => scrollToBottom(true),
- userScrolled: () => store.userScrolled,
- }
- }
|