diff options
| author | Adam <[email protected]> | 2026-01-04 04:24:32 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-04 04:24:37 -0600 |
| commit | 7ce0520f8da74a26f92fd7516d3f8c57dc3ac4c5 (patch) | |
| tree | 3d61246fb5a99aee7f6de0682454d8802f907cb6 /packages/ui/src | |
| parent | 4486174e4319b9523d134fda863677f35e740105 (diff) | |
| download | opencode-7ce0520f8da74a26f92fd7516d3f8c57dc3ac4c5.tar.gz opencode-7ce0520f8da74a26f92fd7516d3f8c57dc3ac4c5.zip | |
fix(app): auto-scroll behaviors
Diffstat (limited to 'packages/ui/src')
| -rw-r--r-- | packages/ui/src/hooks/create-auto-scroll.tsx | 253 |
1 files changed, 61 insertions, 192 deletions
diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index 1fb2e0c81..6dacc2795 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -1,4 +1,4 @@ -import { createEffect, onCleanup } from "solid-js" +import { createEffect, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { createResizeObserver } from "@solid-primitives/resize-observer" @@ -8,240 +8,109 @@ export interface AutoScrollOptions { } export function createAutoScroll(options: AutoScrollOptions) { - let scrollRef: HTMLElement | undefined + let scroll: HTMLElement | undefined + let settling = false + let settleTimer: ReturnType<typeof setTimeout> | undefined + const [store, setStore] = createStore({ contentRef: undefined as HTMLElement | undefined, userScrolled: false, }) - let lastScrollTop = 0 - let isAutoScrolling = false - let autoScrollTimeout: ReturnType<typeof setTimeout> | undefined - let isMouseDown = false - let cleanupListeners: (() => void) | undefined - let scheduledScroll = false - let scheduledForce = false - - function distanceFromBottom() { - if (!scrollRef) return 0 - return scrollRef.scrollHeight - scrollRef.clientHeight - scrollRef.scrollTop - } + const active = () => options.working() || settling - function startAutoScroll() { - isAutoScrolling = true - if (autoScrollTimeout) clearTimeout(autoScrollTimeout) - autoScrollTimeout = setTimeout(() => { - isAutoScrolling = false - }, 1000) + const distanceFromBottom = () => { + const el = scroll + if (!el) return 0 + return el.scrollHeight - el.clientHeight - el.scrollTop } - function scrollToBottomNow() { - if (!scrollRef || store.userScrolled || !options.working()) return - - const distance = distanceFromBottom() - if (distance < 2) return - - const behavior = distance > 96 ? "auto" : "smooth" - startAutoScroll() - scrollRef.scrollTo({ - top: scrollRef.scrollHeight, - behavior, - }) + const scrollToBottomNow = (behavior: ScrollBehavior) => { + const el = scroll + if (!el) return + el.scrollTo({ top: el.scrollHeight, behavior }) } - function forceScrollToBottomNow() { - if (!scrollRef) return + const scrollToBottom = (force: boolean) => { + if (!force && !active()) return + if (!scroll) return - if (store.userScrolled) setStore("userScrolled", false) + if (!force && store.userScrolled) return + if (force && store.userScrolled) setStore("userScrolled", false) const distance = distanceFromBottom() if (distance < 2) return - startAutoScroll() - scrollRef.scrollTo({ - top: scrollRef.scrollHeight, - behavior: "auto", - }) + const behavior: ScrollBehavior = force || distance > 96 ? "auto" : "smooth" + scrollToBottomNow(behavior) } - function scheduleScrollToBottom(force = false) { - if (typeof requestAnimationFrame === "undefined") { - if (force) { - forceScrollToBottomNow() - return - } - scrollToBottomNow() - return - } - - if (force) scheduledForce = true - if (scheduledScroll) return - - scheduledScroll = true - requestAnimationFrame(() => { - scheduledScroll = false - - const shouldForce = scheduledForce - scheduledForce = false - - if (shouldForce) { - forceScrollToBottomNow() - return - } + const handleScroll = () => { + if (!options.working()) return + if (!scroll) return - scrollToBottomNow() - }) - } - - function scrollToBottom() { - scheduleScrollToBottom(false) - } - - function forceScrollToBottom() { - scheduleScrollToBottom(true) - } - - function handleScroll() { - if (!scrollRef) return - - const { scrollTop, scrollHeight, clientHeight } = scrollRef - const atBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 10 - - if (isAutoScrolling) { - if (atBottom) { - isAutoScrolling = false - if (autoScrollTimeout) clearTimeout(autoScrollTimeout) - } - lastScrollTop = scrollTop - return - } - - if (atBottom) { - if (store.userScrolled) { - setStore("userScrolled", false) - } - lastScrollTop = scrollTop + if (distanceFromBottom() < 10) { + if (store.userScrolled) setStore("userScrolled", false) return } - const delta = scrollTop - lastScrollTop - if (delta < 0) { - if (isMouseDown && !store.userScrolled && options.working()) { - setStore("userScrolled", true) - options.onUserInteracted?.() - } - } - - lastScrollTop = scrollTop - } + if (store.userScrolled) return - function handleInteraction() { - if (options.working()) { - setStore("userScrolled", true) - options.onUserInteracted?.() - } + setStore("userScrolled", true) + options.onUserInteracted?.() } - function handleWheel(e: WheelEvent) { - if (e.deltaY < 0 && !store.userScrolled && options.working()) { - setStore("userScrolled", true) - options.onUserInteracted?.() - } - } + const handleInteraction = () => { + if (!options.working()) return + if (store.userScrolled) return - function handleTouchStart() { - if (!store.userScrolled && options.working()) { - setStore("userScrolled", true) - options.onUserInteracted?.() - } - } - - function handleKeyDown(e: KeyboardEvent) { - if (["ArrowUp", "PageUp", "Home"].includes(e.key)) { - if (!store.userScrolled && options.working()) { - setStore("userScrolled", true) - options.onUserInteracted?.() - } - } + setStore("userScrolled", true) + options.onUserInteracted?.() } - function handleMouseDown() { - isMouseDown = true - window.addEventListener("mouseup", handleMouseUp) - } + createResizeObserver( + () => store.contentRef, + () => { + if (!active()) return + if (store.userScrolled) return + scrollToBottom(false) + }, + ) - function handleMouseUp() { - isMouseDown = false - window.removeEventListener("mouseup", handleMouseUp) - } + createEffect( + on(options.working, (working) => { + settling = false + if (settleTimer) clearTimeout(settleTimer) + settleTimer = undefined - // Reset userScrolled when work completes - createEffect(() => { - if (!options.working()) { setStore("userScrolled", false) - } - }) - - // Ensure pinned-to-bottom stays pinned during heavy DOM updates - createEffect(() => { - const el = store.contentRef - if (!el) return - const observer = new MutationObserver(() => { - if (store.userScrolled) return - if (!options.working()) return - scheduleScrollToBottom(false) - }) - observer.observe(el, { childList: true, subtree: true, characterData: true }) - onCleanup(() => observer.disconnect()) - }) - - // Handle content resize - createResizeObserver( - () => store.contentRef, - () => { - if (options.working() && !store.userScrolled) { - scrollToBottom() + if (working) { + scrollToBottom(true) + return } - }, + + settling = true + settleTimer = setTimeout(() => { + settling = false + }, 300) + }), ) onCleanup(() => { - if (autoScrollTimeout) clearTimeout(autoScrollTimeout) - if (cleanupListeners) cleanupListeners() + if (settleTimer) clearTimeout(settleTimer) }) return { scrollRef: (el: HTMLElement | undefined) => { - if (cleanupListeners) { - cleanupListeners() - cleanupListeners = undefined - } - - scrollRef = el - if (el) { - lastScrollTop = el.scrollTop - el.style.overflowAnchor = "none" - - el.addEventListener("wheel", handleWheel, { passive: true }) - el.addEventListener("touchstart", handleTouchStart, { passive: true }) - el.addEventListener("keydown", handleKeyDown) - el.addEventListener("mousedown", handleMouseDown) - - cleanupListeners = () => { - el.removeEventListener("wheel", handleWheel) - el.removeEventListener("touchstart", handleTouchStart) - el.removeEventListener("keydown", handleKeyDown) - el.removeEventListener("mousedown", handleMouseDown) - window.removeEventListener("mouseup", handleMouseUp) - } - } + scroll = el + if (el) el.style.overflowAnchor = "none" }, contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el), handleScroll, handleInteraction, - scrollToBottom, - forceScrollToBottom, + scrollToBottom: () => scrollToBottom(false), + forceScrollToBottom: () => scrollToBottom(true), userScrolled: () => store.userScrolled, } } |
