Compare commits

...

1 Commits

Author SHA1 Message Date
Luke Parker 67e4c6916b fix(ui): accumulate page key scrolling
Replace Chromium smooth scrolling for PageUp and PageDown with a short owned tween. Repeated key presses now extend the active target instead of cancelling and restarting browser easing.
2026-08-07 06:37:27 +00:00
3 changed files with 124 additions and 3 deletions
@@ -134,6 +134,22 @@ test("does not pull a keyboard-scrolled user during shell remeasurement", async
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
})
test("accumulates rapid page key presses", async ({ page }) => {
await setupTimeline(page, {
messages: history(80),
viewport: { width: 1400, height: 700 },
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await scroller.focus()
const before = await scroller.evaluate((element) => ({ top: element.scrollTop, height: element.clientHeight }))
for (let index = 0; index < 3; index++) await scroller.press("PageUp")
await page.waitForTimeout(150)
expect(before.top - (await scroller.evaluate((element) => element.scrollTop))).toBeGreaterThan(before.height * 2.2)
})
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
const shellID = "prt_descendant_keyboard_01_shell"
const timeline = await setupTimeline(page, {
+48 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { canScrollKey, scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
import { canScrollKey, createKeyboardScroll, scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
describe("scrollKey", () => {
test("maps plain navigation keys", () => {
@@ -39,6 +39,53 @@ describe("canScrollKey", () => {
})
})
describe("createKeyboardScroll", () => {
test("accumulates repeated page movement and settles quickly", () => {
const harness = keyboardScrollHarness(0)
harness.scroll.move(800)
harness.advance(30)
harness.scroll.move(800)
harness.advance(150)
expect(harness.element.scrollTop).toBe(1_600)
})
test("reverses from the current position instead of the queued target", () => {
const harness = keyboardScrollHarness(1_000)
harness.scroll.move(800)
harness.advance(30)
const current = harness.element.scrollTop
harness.scroll.move(-800)
harness.advance(150)
expect(harness.element.scrollTop).toBe(current - 800)
})
})
function keyboardScrollHarness(scrollTop: number) {
const element = { scrollTop, clientHeight: 1_000, scrollHeight: 10_000 }
const callbacks = new Map<number, FrameRequestCallback>()
let time = 0
let handle = 0
const scroll = createKeyboardScroll(element, {
now: () => time,
requestFrame: (callback) => {
callbacks.set(++handle, callback)
return handle
},
cancelFrame: (id) => callbacks.delete(id),
})
const advance = (next: number) => {
time = next
const queued = [...callbacks.values()]
callbacks.clear()
queued.forEach((callback) => callback(time))
}
return { element, scroll, advance }
}
describe("scrollTopFromThumbPointer", () => {
test("keeps downward thumb movement monotonic when content height changes", () => {
const first = scrollTopFromThumbPointer({
+60 -2
View File
@@ -79,6 +79,56 @@ export function isScrollKeyTarget(target: EventTarget | null, key: NonNullable<R
return true
}
export function createKeyboardScroll(
element: Pick<HTMLElement, "scrollTop" | "scrollHeight" | "clientHeight">,
options: {
now?: () => number
requestFrame?: (callback: FrameRequestCallback) => number
cancelFrame?: (handle: number) => void
duration?: number
} = {},
) {
const now = options.now ?? (() => performance.now())
const requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback))
const cancelFrame = options.cancelFrame ?? ((handle) => cancelAnimationFrame(handle))
const duration = options.duration ?? 120
let frame: number | undefined
let start = 0
let from = element.scrollTop
let target = element.scrollTop
let direction = 0
const tick = (time: number) => {
const progress = Math.min(1, (time - start) / duration)
element.scrollTop = from + (target - from) * (1 - Math.pow(1 - progress, 3))
if (progress < 1) {
frame = requestFrame(tick)
return
}
frame = undefined
}
const move = (amount: number) => {
const nextDirection = Math.sign(amount)
const base = frame !== undefined && direction === nextDirection ? target : element.scrollTop
if (frame !== undefined) cancelFrame(frame)
from = element.scrollTop
target = Math.max(0, Math.min(base + amount, element.scrollHeight - element.clientHeight))
direction = nextDirection
start = now()
frame = requestFrame(tick)
}
const cancel = () => {
if (frame !== undefined) cancelFrame(frame)
frame = undefined
target = element.scrollTop
direction = 0
}
return { move, cancel }
}
export function scrollTopFromThumbPointer(input: {
pointer: number
viewportTop: number
@@ -153,6 +203,7 @@ export function ScrollView(props: ScrollViewProps) {
const showThumb = () => state.showThumb
let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined
let keyboardScroll: ReturnType<typeof createKeyboardScroll> | undefined
const markScrolling = () => {
setState("isScrolling", true)
@@ -168,6 +219,7 @@ export function ScrollView(props: ScrollViewProps) {
onCleanup(() => {
if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer)
keyboardScroll?.cancel()
})
const updateThumb = () => {
@@ -202,6 +254,7 @@ export function ScrollView(props: ScrollViewProps) {
}
onMount(() => {
keyboardScroll = createKeyboardScroll(viewportRef)
if (local.viewportRef) {
local.viewportRef(viewportRef)
}
@@ -306,26 +359,30 @@ export function ScrollView(props: ScrollViewProps) {
switch (next) {
case "page-down":
e.preventDefault()
viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" })
keyboardScroll?.move(scrollAmount)
break
case "page-up":
e.preventDefault()
viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" })
keyboardScroll?.move(-scrollAmount)
break
case "home":
e.preventDefault()
keyboardScroll?.cancel()
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
break
case "end":
e.preventDefault()
keyboardScroll?.cancel()
viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
break
case "up":
e.preventDefault()
keyboardScroll?.cancel()
viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" })
break
case "down":
e.preventDefault()
keyboardScroll?.cancel()
viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" })
break
}
@@ -355,6 +412,7 @@ export function ScrollView(props: ScrollViewProps) {
if (typeof events.onScroll === "function") events.onScroll(e as any)
}}
onWheel={(e) => {
keyboardScroll?.cancel()
markScrolling()
const handler = events.onWheel
if (typeof handler === "function") handler(e as any)