summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-24 08:37:33 -0600
committerAdam <[email protected]>2025-12-24 08:37:49 -0600
commit0f270c3da437b4710c398d009d5676c9e5a0efcd (patch)
tree2e754d71851119484ad061102ea45626e2f6a5e3 /packages/ui/src
parent376019e347afa997a50123566da4bd1648fce8ad (diff)
downloadopencode-0f270c3da437b4710c398d009d5676c9e5a0efcd.tar.gz
opencode-0f270c3da437b4710c398d009d5676c9e5a0efcd.zip
refactor(ui): rewrite createAutoScroll with robust event tracking to fix sticky behavior
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/hooks/create-auto-scroll.tsx199
1 files changed, 117 insertions, 82 deletions
diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx
index 3bba7aec6..b11bdb3c7 100644
--- a/packages/ui/src/hooks/create-auto-scroll.tsx
+++ b/packages/ui/src/hooks/create-auto-scroll.tsx
@@ -1,4 +1,4 @@
-import { batch, createEffect } from "solid-js"
+import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
@@ -9,134 +9,169 @@ export interface AutoScrollOptions {
export function createAutoScroll(options: AutoScrollOptions) {
let scrollRef: HTMLElement | undefined
+ // We use a store for refs to be compatible with the existing API,
+ // but strictly speaking signals would work too.
const [store, setStore] = createStore({
contentRef: undefined as HTMLElement | undefined,
- lastScrollTop: 0,
- lastScrollHeight: 0,
- lastContentWidth: 0,
- autoScrolled: false,
userScrolled: false,
- reflowing: false,
})
+ // Internal state
+ let lastScrollTop = 0
+ let isAutoScrolling = false
+ let autoScrollTimeout: ReturnType<typeof setTimeout> | undefined
+ let isMouseDown = false
+ let cleanupListeners: (() => void) | undefined
+
function scrollToBottom() {
if (!scrollRef || store.userScrolled || !options.working()) return
- setStore("autoScrolled", true)
- const targetHeight = scrollRef.scrollHeight
- scrollRef.scrollTo({ top: targetHeight, behavior: "smooth" })
-
- // Wait for scroll to complete before clearing autoScrolled
- const checkScrollComplete = () => {
- if (!scrollRef) {
- setStore("autoScrolled", false)
- return
- }
- const atBottom = scrollRef.scrollTop + scrollRef.clientHeight >= scrollRef.scrollHeight - 10
- const reachedTarget = scrollRef.scrollTop >= targetHeight - scrollRef.clientHeight - 10
- if (atBottom || reachedTarget) {
- batch(() => {
- setStore("lastScrollTop", scrollRef?.scrollTop ?? 0)
- setStore("lastScrollHeight", scrollRef?.scrollHeight ?? 0)
- setStore("autoScrolled", false)
- })
- } else {
- requestAnimationFrame(checkScrollComplete)
- }
+
+ isAutoScrolling = true
+ if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
+ // Safety timeout to clear auto-scrolling state
+ autoScrollTimeout = setTimeout(() => {
+ isAutoScrolling = false
+ }, 1000)
+
+ try {
+ scrollRef.scrollTo({
+ top: scrollRef.scrollHeight,
+ behavior: "smooth",
+ })
+ } catch {
+ // Fallback for environments where scrollTo options might fail
+ if (scrollRef) scrollRef.scrollTop = scrollRef.scrollHeight
+ isAutoScrolling = false
}
- requestAnimationFrame(checkScrollComplete)
}
function handleScroll() {
- if (!scrollRef || store.autoScrolled) return
+ if (!scrollRef) return
- const scrollTop = scrollRef.scrollTop
- const scrollHeight = scrollRef.scrollHeight
+ const { scrollTop, scrollHeight, clientHeight } = scrollRef
+ // Use a small tolerance for "at bottom" detection
+ const atBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 10
- if (store.reflowing) {
- batch(() => {
- setStore("lastScrollTop", scrollTop)
- setStore("lastScrollHeight", scrollHeight)
- })
+ if (isAutoScrolling) {
+ if (atBottom) {
+ isAutoScrolling = false
+ if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
+ }
+ lastScrollTop = scrollTop
return
}
- const scrollHeightChanged = Math.abs(scrollHeight - store.lastScrollHeight) > 10
- const scrollTopDelta = scrollTop - store.lastScrollTop
-
- // Handle reflow-caused scroll position changes
- if (scrollHeightChanged && scrollTopDelta < 0) {
- const heightRatio = store.lastScrollHeight > 0 ? scrollHeight / store.lastScrollHeight : 1
- const expectedScrollTop = store.lastScrollTop * heightRatio
- if (Math.abs(scrollTop - expectedScrollTop) < 100) {
- batch(() => {
- setStore("lastScrollTop", scrollTop)
- setStore("lastScrollHeight", scrollHeight)
- })
- return
+ if (atBottom) {
+ // We reached the bottom, so we're "locked" again.
+ if (store.userScrolled) {
+ setStore("userScrolled", false)
}
+ lastScrollTop = scrollTop
+ return
}
- // Handle reset to top while working
- const reset = scrollTop <= 0 && store.lastScrollTop > 0 && options.working() && !store.userScrolled
- if (reset) {
- batch(() => {
- setStore("lastScrollTop", scrollTop)
- setStore("lastScrollHeight", scrollHeight)
- })
- requestAnimationFrame(scrollToBottom)
- return
+ // Check for user intention to scroll up.
+ // We rely on explicit interaction events (wheel, touch, keys) for most cases,
+ // and use mousedown + scroll delta for scrollbar dragging.
+ const delta = scrollTop - lastScrollTop
+ if (delta < 0) {
+ if (isMouseDown && !store.userScrolled && options.working()) {
+ setStore("userScrolled", true)
+ options.onUserInteracted?.()
+ }
}
- // Detect intentional scroll up
- const scrolledUp = scrollTop < store.lastScrollTop - 50 && !scrollHeightChanged
- if (scrolledUp && options.working()) {
+ lastScrollTop = scrollTop
+ }
+
+ function handleInteraction() {
+ if (options.working()) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
+ }
- batch(() => {
- setStore("lastScrollTop", scrollTop)
- setStore("lastScrollHeight", scrollHeight)
- })
+ function handleWheel(e: WheelEvent) {
+ if (e.deltaY < 0 && !store.userScrolled && options.working()) {
+ setStore("userScrolled", true)
+ options.onUserInteracted?.()
+ }
}
- function handleInteraction() {
- if (options.working()) {
+ 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?.()
+ }
+ }
+ }
+
+ function handleMouseDown() {
+ isMouseDown = true
+ window.addEventListener("mouseup", handleMouseUp)
+ }
+
+ function handleMouseUp() {
+ isMouseDown = false
+ window.removeEventListener("mouseup", handleMouseUp)
+ }
+
// Reset userScrolled when work completes
createEffect(() => {
- if (!options.working()) setStore("userScrolled", false)
+ if (!options.working()) {
+ setStore("userScrolled", false)
+ }
})
// Handle content resize
createResizeObserver(
() => store.contentRef,
- ({ width }) => {
- const widthChanged = Math.abs(width - store.lastContentWidth) > 5
- if (widthChanged && store.lastContentWidth > 0) {
- setStore("reflowing", true)
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- setStore("reflowing", false)
- if (options.working() && !store.userScrolled) {
- scrollToBottom()
- }
- })
- })
- } else if (!store.reflowing) {
+ () => {
+ // When content changes size, if we are sticky, scroll to bottom.
+ if (options.working() && !store.userScrolled) {
scrollToBottom()
}
- setStore("lastContentWidth", width)
},
)
+ onCleanup(() => {
+ if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
+ if (cleanupListeners) cleanupListeners()
+ })
+
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)
+ }
+ }
},
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
handleScroll,