fix: autoscroll through nested scrollers and past webkit repaint lag

This commit is contained in:
Felitendo committed 2026-09-14 23:29:37 +02:00
1 parent af66b2ffe5
commit 7b53b324a1
1 file changed
+119 -24
@@ -5,9 +5,9 @@ Subject: [PATCH] Scroll with the middle mouse button
--- ---
apps/app-frontend/src/App.vue | 8 + apps/app-frontend/src/App.vue | 8 +
.../src/assets/stylesheets/global.scss | 44 ++++ .../src/assets/stylesheets/global.scss | 50 +++
apps/app-frontend/src/helpers/autoscroll.ts | 201 ++++++++++++++++++ apps/app-frontend/src/helpers/autoscroll.ts | 290 ++++++++++++++++++
3 files changed, 253 insertions(+) 3 files changed, 348 insertions(+)
create mode 100644 apps/app-frontend/src/helpers/autoscroll.ts create mode 100644 apps/app-frontend/src/helpers/autoscroll.ts
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
@@ -42,10 +42,10 @@ index 5a32c65..469ae08 100644
document.querySelector('body').removeEventListener('auxclick', handleAuxClick) document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
document.querySelector('body').removeEventListener('contextmenu', handleContextMenu) document.querySelector('body').removeEventListener('contextmenu', handleContextMenu)
diff --git a/apps/app-frontend/src/assets/stylesheets/global.scss b/apps/app-frontend/src/assets/stylesheets/global.scss diff --git a/apps/app-frontend/src/assets/stylesheets/global.scss b/apps/app-frontend/src/assets/stylesheets/global.scss
index ba4da08..442f181 100644 index ba4da08..c5129ac 100644
--- a/apps/app-frontend/src/assets/stylesheets/global.scss --- a/apps/app-frontend/src/assets/stylesheets/global.scss
+++ b/apps/app-frontend/src/assets/stylesheets/global.scss +++ b/apps/app-frontend/src/assets/stylesheets/global.scss
@@ -83,6 +83,50 @@ html.rounded-window #app { @@ -83,6 +83,56 @@ html.rounded-window #app {
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
} }
@@ -55,6 +55,12 @@ index ba4da08..442f181 100644
+ cursor: all-scroll !important; + cursor: all-scroll !important;
+} +}
+ +
+.autoscroll-shield {
+ position: fixed;
+ inset: 0;
+ z-index: 2147483646;
+}
+
+.autoscroll-indicator { +.autoscroll-indicator {
+ position: fixed; + position: fixed;
+ z-index: 2147483647; + z-index: 2147483647;
@@ -98,10 +104,10 @@ index ba4da08..442f181 100644
} }
diff --git a/apps/app-frontend/src/helpers/autoscroll.ts b/apps/app-frontend/src/helpers/autoscroll.ts diff --git a/apps/app-frontend/src/helpers/autoscroll.ts b/apps/app-frontend/src/helpers/autoscroll.ts
new file mode 100644 new file mode 100644
index 0000000..1faef8e index 0000000..82e1790
--- /dev/null --- /dev/null
+++ b/apps/app-frontend/src/helpers/autoscroll.ts +++ b/apps/app-frontend/src/helpers/autoscroll.ts
@@ -0,0 +1,201 @@ @@ -0,0 +1,290 @@
+// Middle-click autoscroll, as browsers on Windows do it. WebKitGTK has none: +// Middle-click autoscroll, as browsers on Windows do it. WebKitGTK has none:
+// on Linux the middle button pastes instead. +// on Linux the middle button pastes instead.
+// +//
@@ -118,21 +124,74 @@ index 0000000..1faef8e
+ +
+type Axes = { x: boolean; y: boolean } +type Axes = { x: boolean; y: boolean }
+ +
+function scrollableAxes(el: Element): Axes { +function overflowScrolls(overflow: string) {
+ const style = getComputedStyle(el) + return overflow === 'auto' || overflow === 'scroll'
+ const scrolls = (overflow: string) => overflow === 'auto' || overflow === 'scroll'
+ return {
+ x: scrolls(style.overflowX) && el.scrollWidth > el.clientWidth,
+ y: scrolls(style.overflowY) && el.scrollHeight > el.clientHeight,
+ }
+} +}
+ +
+function findScrollable(start: Element | null): { el: Element; axes: Axes } | null { +// Every ancestor that can scroll, innermost first.
+function scrollersFrom(start: Element | null): Element[] {
+ const scrollers: Element[] = []
+ for (let el = start; el && el !== document.documentElement; el = el.parentElement) { + for (let el = start; el && el !== document.documentElement; el = el.parentElement) {
+ const axes = scrollableAxes(el) + const style = getComputedStyle(el)
+ if (axes.x || axes.y) return { el, axes } + if (overflowScrolls(style.overflowX) || overflowScrolls(style.overflowY)) scrollers.push(el)
+ }
+ return scrollers
+}
+
+function scrollableAxes(scrollers: Element[]): Axes {
+ const axes = { x: false, y: false }
+ for (const el of scrollers) {
+ const style = getComputedStyle(el)
+ axes.x ||= overflowScrolls(style.overflowX) && el.scrollWidth > el.clientWidth
+ axes.y ||= overflowScrolls(style.overflowY) && el.scrollHeight > el.clientHeight
+ }
+ return axes
+}
+
+// Scroll the innermost scroller that can still move this way, and hand the
+// movement outwards once it reaches its end, as wheel scrolling does. Returns
+// whether anything moved.
+function scrollChained(scrollers: Element[], left: number, top: number): boolean {
+ let moved = false
+ for (const [delta, vertical] of [
+ [left, false],
+ [top, true],
+ ] as const) {
+ if (!delta) continue
+ for (const el of scrollers) {
+ const before = vertical ? el.scrollTop : el.scrollLeft
+ el.scrollBy(
+ vertical ? { top: delta, behavior: 'instant' } : { left: delta, behavior: 'instant' },
+ )
+ if ((vertical ? el.scrollTop : el.scrollLeft) !== before) {
+ moved = true
+ break
+ }
+ }
+ }
+ return moved
+}
+
+// WebKitGTK can leave the painted scroll position a few frames behind fast
+// programmatic scrolling. Once the real position stops changing, as it does at
+// either end, it never paints the last ones: the page looks stuck short of the
+// end while hit testing already uses the real position. A one pixel round trip
+// over two frames makes it paint again.
+function repaintScrollPositions(scrollers: Element[]) {
+ for (const el of scrollers) {
+ for (const vertical of [true, false]) {
+ const range = vertical ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth
+ if (range <= 0) continue
+ const position = vertical ? el.scrollTop : el.scrollLeft
+ const away = position > 0 ? position - 1 : position + 1
+ if (vertical) el.scrollTop = away
+ else el.scrollLeft = away
+ requestAnimationFrame(() => {
+ if (vertical) el.scrollTop = position
+ else el.scrollLeft = position
+ })
+ }
+ } + }
+ return null
+} +}
+ +
+function createIndicator(x: number, y: number, axes: Axes): HTMLElement { +function createIndicator(x: number, y: number, axes: Axes): HTMLElement {
@@ -149,6 +208,16 @@ index 0000000..1faef8e
+ return indicator + return indicator
+} +}
+ +
+// Covers the page while autoscroll runs. The pointer does not move while the
+// page does, so without it whatever scrolls under the pointer would light up
+// as hovered.
+function createShield(): HTMLElement {
+ const shield = document.createElement('div')
+ shield.className = 'autoscroll-shield'
+ document.body.appendChild(shield)
+ return shield
+}
+
+// Pixels per second for a pointer this far from where autoscroll started. +// Pixels per second for a pointer this far from where autoscroll started.
+function speed(distance: number): number { +function speed(distance: number): number {
+ const past = Math.abs(distance) - DEAD_ZONE + const past = Math.abs(distance) - DEAD_ZONE
@@ -158,7 +227,8 @@ index 0000000..1faef8e
+ +
+export function installAutoscroll(): () => void { +export function installAutoscroll(): () => void {
+ let active: { + let active: {
+ el: Element + origin: Element
+ scrollers: Element[]
+ axes: Axes + axes: Axes
+ startX: number + startX: number
+ startY: number + startY: number
@@ -170,7 +240,9 @@ index 0000000..1faef8e
+ carryY: number + carryY: number
+ lastFrame: number + lastFrame: number
+ frame: number + frame: number
+ stuckFrames: number
+ indicator: HTMLElement + indicator: HTMLElement
+ shield: HTMLElement
+ } | null = null + } | null = null
+ // The mouseup, click and auxclick that belong to a press autoscroll consumed. + // The mouseup, click and auxclick that belong to a press autoscroll consumed.
+ let swallowButtons = 0 + let swallowButtons = 0
@@ -179,7 +251,9 @@ index 0000000..1faef8e
+ if (!active) return + if (!active) return
+ cancelAnimationFrame(active.frame) + cancelAnimationFrame(active.frame)
+ active.indicator.remove() + active.indicator.remove()
+ active.shield.remove()
+ document.documentElement.classList.remove('autoscrolling') + document.documentElement.classList.remove('autoscrolling')
+ repaintScrollPositions(active.scrollers)
+ active = null + active = null
+ } + }
+ +
@@ -188,12 +262,28 @@ index 0000000..1faef8e
+ const dt = Math.min(now - active.lastFrame, 100) / 1000 + const dt = Math.min(now - active.lastFrame, 100) / 1000
+ active.lastFrame = now + active.lastFrame = now
+ +
+ // The page may have re-rendered the element autoscroll started on.
+ if (!active.origin.isConnected) {
+ active.shield.style.pointerEvents = 'none'
+ const origin = document.elementFromPoint(active.startX, active.startY)
+ active.shield.style.pointerEvents = ''
+ if (origin) {
+ active.origin = origin
+ active.scrollers = scrollersFrom(origin)
+ }
+ }
+
+ active.carryX += active.axes.x ? speed(active.pointerX - active.startX) * dt : 0 + active.carryX += active.axes.x ? speed(active.pointerX - active.startX) * dt : 0
+ active.carryY += active.axes.y ? speed(active.pointerY - active.startY) * dt : 0 + active.carryY += active.axes.y ? speed(active.pointerY - active.startY) * dt : 0
+ const left = Math.trunc(active.carryX) + const left = Math.trunc(active.carryX)
+ const top = Math.trunc(active.carryY) + const top = Math.trunc(active.carryY)
+ if (left || top) { + if (left || top) {
+ active.el.scrollBy({ left, top, behavior: 'instant' }) + if (scrollChained(active.scrollers, left, top)) {
+ active.stuckFrames = 0
+ } else if (++active.stuckFrames === 2) {
+ // Just ran into an end: make sure the last frames get painted.
+ repaintScrollPositions(active.scrollers)
+ }
+ active.carryX -= left + active.carryX -= left
+ active.carryY -= top + active.carryY -= top
+ } + }
@@ -217,13 +307,16 @@ index 0000000..1faef8e
+ if (e.button !== 1 || !(e.target instanceof Element)) return + if (e.button !== 1 || !(e.target instanceof Element)) return
+ if (e.target.closest(INTERACTIVE)) return + if (e.target.closest(INTERACTIVE)) return
+ +
+ const found = findScrollable(e.target) + const scrollers = scrollersFrom(e.target)
+ if (!found) return + const axes = scrollableAxes(scrollers)
+ if (!axes.x && !axes.y) return
+ +
+ swallow(e) + swallow(e)
+ swallowButtons = 1 << e.button + swallowButtons = 1 << e.button
+ active = { + active = {
+ ...found, + origin: e.target,
+ scrollers,
+ axes,
+ startX: e.clientX, + startX: e.clientX,
+ startY: e.clientY, + startY: e.clientY,
+ pointerX: e.clientX, + pointerX: e.clientX,
@@ -234,7 +327,9 @@ index 0000000..1faef8e
+ carryY: 0, + carryY: 0,
+ lastFrame: performance.now(), + lastFrame: performance.now(),
+ frame: 0, + frame: 0,
+ indicator: createIndicator(e.clientX, e.clientY, found.axes), + stuckFrames: 0,
+ shield: createShield(),
+ indicator: createIndicator(e.clientX, e.clientY, axes),
+ } + }
+ document.documentElement.classList.add('autoscrolling') + document.documentElement.classList.add('autoscrolling')
+ active.frame = requestAnimationFrame(tick) + active.frame = requestAnimationFrame(tick)