summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/hooks/create-auto-scroll.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui/src/hooks/create-auto-scroll.tsx')
-rw-r--r--packages/ui/src/hooks/create-auto-scroll.tsx245
1 files changed, 187 insertions, 58 deletions
diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx
index 3dc520c62..d36102590 100644
--- a/packages/ui/src/hooks/create-auto-scroll.tsx
+++ b/packages/ui/src/hooks/create-auto-scroll.tsx
@@ -1,6 +1,8 @@
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
@@ -9,13 +11,28 @@ export interface AutoScrollOptions {
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 autoTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
- let auto: { top: number; time: number } | 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
@@ -27,77 +44,160 @@ export function createAutoScroll(options: AutoScrollOptions) {
const active = () => options.working() || settling
const distanceFromBottom = (el: HTMLElement) => {
- return el.scrollHeight - el.clientHeight - el.scrollTop
+ // 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
}
- // 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(),
- }
+ const markProgrammatic = () => {
+ programmaticUntil = Date.now() + AUTO_SCROLL_GRACE_MS
+ }
- if (autoTimer) clearTimeout(autoTimer)
- autoTimer = setTimeout(() => {
- auto = undefined
- autoTimer = undefined
- }, 1500)
+ const clearHold = () => {
+ const next = hold
+ if (!next) return
+ if (next.frame !== undefined) cancelAnimationFrame(next.frame)
+ hold = undefined
}
- const isAuto = (el: HTMLElement) => {
- const a = auto
- if (!a) return false
+ 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
+ }
- if (Date.now() - a.time > 1500) {
- auto = undefined
+ const current = next.el.getBoundingClientRect().top
+ if (!Number.isFinite(current)) {
+ clearHold()
return false
}
- return Math.abs(el.scrollTop - a.top) < 2
+ 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 scrollToBottomNow = (behavior: ScrollBehavior) => {
+ const preserve = (target: HTMLElement) => {
const el = scroll
if (!el) return
- markAuto(el)
- if (behavior === "smooth") {
- el.scrollTo({ top: el.scrollHeight, behavior })
- return
+
+ if (!store.userScrolled) {
+ setStore("userScrolled", true)
+ options.onUserInteracted?.()
}
- // `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`.
- el.scrollTop = el.scrollHeight
+ 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
- const distance = distanceFromBottom(el)
- if (distance < 2) {
- markAuto(el)
+ // With column-reverse, scrollTop=0 is at the bottom
+ if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
+ markProgrammatic()
return
}
- // For auto-following content we prefer immediate updates to avoid
- // visible "catch up" animations while content is still settling.
- scrollToBottomNow("auto")
+ el.scrollTop = 0
+ markProgrammatic()
+ }
+
+ const cancelSmooth = () => {
+ if (scrollAnim) {
+ scrollAnim.stop()
+ scrollAnim = undefined
+ }
}
- const stop = () => {
+ 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)) {
@@ -106,15 +206,25 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
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
- // 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`.
+ cancelSmooth()
const el = scroll
const target = e.target instanceof Element ? e.target : undefined
const nested = target?.closest("[data-scrollable]")
@@ -126,23 +236,27 @@ export function createAutoScroll(options: AutoScrollOptions) {
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
}
- // Ignore scroll events triggered by our own scrollToBottom calls.
- if (!store.userScrolled && isAuto(el)) {
- scrollToBottom(false)
- return
- }
+ if (!store.userScrolled && Date.now() < programmaticUntil) return
- stop()
+ stop({ hold: false })
}
const handleInteraction = () => {
@@ -154,6 +268,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
const updateOverflowAnchor = (el: HTMLElement) => {
+ if (hold) {
+ el.style.overflowAnchor = "none"
+ return
+ }
+
const mode = options.overflowAnchor ?? "dynamic"
if (mode === "none") {
@@ -173,15 +292,17 @@ export function createAutoScroll(options: AutoScrollOptions) {
() => 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
- // 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)
},
)
@@ -200,13 +321,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
settling = true
settleTimer = setTimeout(() => {
settling = false
- }, 300)
+ }, SETTLE_MS)
}),
)
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
@@ -215,7 +334,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
onCleanup(() => {
if (settleTimer) clearTimeout(settleTimer)
- if (autoTimer) clearTimeout(autoTimer)
+ clearHold()
+ cancelSmooth()
if (cleanup) cleanup()
})
@@ -228,8 +348,12 @@ export function createAutoScroll(options: AutoScrollOptions) {
scroll = el
- if (!el) return
+ if (!el) {
+ clearHold()
+ return
+ }
+ markProgrammatic()
updateOverflowAnchor(el)
el.addEventListener("wheel", handleWheel, { passive: true })
@@ -240,13 +364,18 @@ export function createAutoScroll(options: AutoScrollOptions) {
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
handleScroll,
handleInteraction,
+ preserve,
pause: stop,
- resume: () => {
+ forceScrollToBottom: () => scrollToBottom(true),
+ smoothScrollToBottom,
+ snapToBottom: () => {
+ const el = scroll
+ if (!el) return
if (store.userScrolled) setStore("userScrolled", false)
- scrollToBottom(true)
+ // With column-reverse, scrollTop=0 is at the bottom
+ el.scrollTop = 0
+ markProgrammatic()
},
- scrollToBottom: () => scrollToBottom(false),
- forceScrollToBottom: () => scrollToBottom(true),
userScrolled: () => store.userScrolled,
}
}