mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
fix(ui): use column-reverse for jitter-free bottom-anchored scrolling
Switch the scroll viewport to flex-direction: column-reverse so the browser natively anchors to the bottom (scrollTop=0 = bottom). This eliminates the 1-frame jitter between content height changes and scroll position updates. Update all scrollTop math across auto-scroll, scroll spy, gesture detection, hash scroll, and custom thumb to account for the inverted coordinate system.
This commit is contained in:
@@ -125,7 +125,10 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
requestAnimationFrame(() => {
|
||||
const delta = el.scrollHeight - beforeHeight
|
||||
if (!delta) return
|
||||
el.scrollTop = beforeTop + delta
|
||||
// With column-reverse, adding content at the top doesn't shift the
|
||||
// viewport because scroll origin is at the bottom. Subtract delta
|
||||
// to maintain position (beforeTop is negative or zero).
|
||||
el.scrollTop = beforeTop - delta
|
||||
})
|
||||
}
|
||||
|
||||
@@ -209,7 +212,8 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
if (!input.userScrolled()) return
|
||||
const el = input.scroller()
|
||||
if (!el) return
|
||||
if (el.scrollTop >= turnScrollThreshold) return
|
||||
// With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop
|
||||
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
|
||||
|
||||
const start = turnStart()
|
||||
if (start > 0) {
|
||||
@@ -1028,7 +1032,8 @@ export default function Page() {
|
||||
const overflow = max > 1
|
||||
// If auto-scroll is tracking the bottom, always report bottom: true
|
||||
// to prevent the scroll-down arrow from flashing during height animations
|
||||
const bottom = !overflow || el.scrollTop >= max - 2 || !autoScroll.userScrolled()
|
||||
// With column-reverse, scrollTop=0 is at the bottom
|
||||
const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
|
||||
|
||||
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
|
||||
setUi("scroll", { overflow, bottom })
|
||||
@@ -1119,9 +1124,8 @@ export default function Page() {
|
||||
|
||||
const el = scroller
|
||||
const delta = next - dockHeight
|
||||
const stick = el
|
||||
? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
|
||||
: false
|
||||
// With column-reverse, near bottom = scrollTop near 0
|
||||
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
|
||||
|
||||
dockHeight = next
|
||||
|
||||
|
||||
@@ -28,10 +28,11 @@ describe("shouldMarkBoundaryGesture", () => {
|
||||
})
|
||||
|
||||
test("marks when scrolling beyond top boundary", () => {
|
||||
// column-reverse: scrollTop=-590 means 590px from bottom (10px from top, max=600)
|
||||
expect(
|
||||
shouldMarkBoundaryGesture({
|
||||
delta: -40,
|
||||
scrollTop: 10,
|
||||
scrollTop: -590,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 400,
|
||||
}),
|
||||
@@ -39,10 +40,11 @@ describe("shouldMarkBoundaryGesture", () => {
|
||||
})
|
||||
|
||||
test("marks when scrolling beyond bottom boundary", () => {
|
||||
// column-reverse: scrollTop=-20 means 20px from bottom
|
||||
expect(
|
||||
shouldMarkBoundaryGesture({
|
||||
delta: 50,
|
||||
scrollTop: 580,
|
||||
scrollTop: -20,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 400,
|
||||
}),
|
||||
@@ -50,10 +52,11 @@ describe("shouldMarkBoundaryGesture", () => {
|
||||
})
|
||||
|
||||
test("does not mark when nested scroller can consume movement", () => {
|
||||
// column-reverse: scrollTop=-400 means 400px from bottom (middle of scroll)
|
||||
expect(
|
||||
shouldMarkBoundaryGesture({
|
||||
delta: 20,
|
||||
scrollTop: 200,
|
||||
scrollTop: -400,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 400,
|
||||
}),
|
||||
|
||||
@@ -14,8 +14,9 @@ export const shouldMarkBoundaryGesture = (input: {
|
||||
if (max <= 1) return true
|
||||
if (!input.delta) return false
|
||||
|
||||
if (input.delta < 0) return input.scrollTop + input.delta <= 0
|
||||
// With column-reverse: scrollTop=0 at bottom, -max at top
|
||||
if (input.delta < 0) return input.scrollTop + input.delta <= -max
|
||||
|
||||
const remaining = max - input.scrollTop
|
||||
const remaining = -input.scrollTop // distance from bottom
|
||||
return input.delta > remaining
|
||||
}
|
||||
|
||||
@@ -574,6 +574,7 @@ export function MessageTimeline(props: {
|
||||
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Show when={showHeader()}>
|
||||
<div
|
||||
data-session-title
|
||||
@@ -789,6 +790,7 @@ export function MessageTimeline(props: {
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -47,7 +47,8 @@ export const useSessionHashScroll = (input: {
|
||||
const b = root.getBoundingClientRect()
|
||||
const sticky = root.querySelector("[data-session-title]")
|
||||
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
|
||||
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
|
||||
// With column-reverse, scrollTop is negative — don't clamp to 0
|
||||
const top = a.top - b.top + root.scrollTop - inset
|
||||
root.scrollTo({ top, behavior })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
outline: none;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.scroll-view__viewport::-webkit-scrollbar {
|
||||
|
||||
@@ -57,9 +57,12 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
const maxScrollTop = scrollHeight - clientHeight
|
||||
const maxThumbTop = trackHeight - height
|
||||
|
||||
const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
|
||||
// With column-reverse: scrollTop=0 is at bottom, negative = scrolled up
|
||||
// Normalize so 0 = at top, maxScrollTop = at bottom
|
||||
const normalizedScrollTop = maxScrollTop + scrollTop
|
||||
const top = maxScrollTop > 0 ? (normalizedScrollTop / maxScrollTop) * maxThumbTop : 0
|
||||
|
||||
// Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
|
||||
// Ensure thumb stays within bounds
|
||||
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
|
||||
|
||||
setThumbHeight(height)
|
||||
@@ -147,11 +150,13 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
break
|
||||
case "Home":
|
||||
e.preventDefault()
|
||||
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
|
||||
// With column-reverse, top of content = -(scrollHeight - clientHeight)
|
||||
viewportRef.scrollTo({ top: -(viewportRef.scrollHeight - viewportRef.clientHeight), behavior: "smooth" })
|
||||
break
|
||||
case "End":
|
||||
e.preventDefault()
|
||||
viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
|
||||
// With column-reverse, bottom of content = 0
|
||||
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
|
||||
break
|
||||
case "ArrowUp":
|
||||
e.preventDefault()
|
||||
|
||||
@@ -32,7 +32,8 @@ 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) => {
|
||||
@@ -54,13 +55,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
if (scrollAnim) cancelSmooth()
|
||||
if (!force && store.userScrolled) return
|
||||
|
||||
const next = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
if (Math.abs(el.scrollTop - next) <= AUTO_SCROLL_EPSILON) {
|
||||
// With column-reverse, scrollTop=0 is at the bottom
|
||||
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
|
||||
markProgrammatic()
|
||||
return
|
||||
}
|
||||
|
||||
el.scrollTop = next
|
||||
el.scrollTop = 0
|
||||
markProgrammatic()
|
||||
}
|
||||
|
||||
@@ -78,13 +79,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
cancelSmooth()
|
||||
if (store.userScrolled) setStore("userScrolled", false)
|
||||
|
||||
const next = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
if (Math.abs(el.scrollTop - next) <= AUTO_SCROLL_EPSILON) {
|
||||
// With column-reverse, scrollTop=0 is at the bottom
|
||||
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
|
||||
markProgrammatic()
|
||||
return
|
||||
}
|
||||
|
||||
scrollAnim = animate(el.scrollTop, next, {
|
||||
scrollAnim = animate(el.scrollTop, 0, {
|
||||
type: "spring",
|
||||
visualDuration: 0.35,
|
||||
bounce: 0,
|
||||
@@ -248,7 +249,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
if (store.userScrolled) setStore("userScrolled", false)
|
||||
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
// With column-reverse, scrollTop=0 is at the bottom
|
||||
el.scrollTop = 0
|
||||
markProgrammatic()
|
||||
},
|
||||
userScrolled: () => store.userScrolled,
|
||||
|
||||
Reference in New Issue
Block a user