From 7b53b324a15380513ab1ee035839659673b00fa3 Mon Sep 17 00:00:00 2001 From: Felitendo Date: Mon, 14 Sep 2026 23:29:37 +0200 Subject: [PATCH] fix: autoscroll through nested scrollers and past webkit repaint lag --- ...-Scroll-with-the-middle-mouse-button.patch | 143 +++++++++++++++--- 1 file changed, 119 insertions(+), 24 deletions(-) diff --git a/patches/0009-Scroll-with-the-middle-mouse-button.patch b/patches/0009-Scroll-with-the-middle-mouse-button.patch index c3f044d..dfc2898 100644 --- a/patches/0009-Scroll-with-the-middle-mouse-button.patch +++ b/patches/0009-Scroll-with-the-middle-mouse-button.patch @@ -5,9 +5,9 @@ Subject: [PATCH] Scroll with the middle mouse button --- apps/app-frontend/src/App.vue | 8 + - .../src/assets/stylesheets/global.scss | 44 ++++ - apps/app-frontend/src/helpers/autoscroll.ts | 201 ++++++++++++++++++ - 3 files changed, 253 insertions(+) + .../src/assets/stylesheets/global.scss | 50 +++ + apps/app-frontend/src/helpers/autoscroll.ts | 290 ++++++++++++++++++ + 3 files changed, 348 insertions(+) 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 @@ -42,10 +42,10 @@ index 5a32c65..469ae08 100644 document.querySelector('body').removeEventListener('auxclick', handleAuxClick) 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 -index ba4da08..442f181 100644 +index ba4da08..c5129ac 100644 --- a/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); } @@ -55,6 +55,12 @@ index ba4da08..442f181 100644 + cursor: all-scroll !important; +} + ++.autoscroll-shield { ++ position: fixed; ++ inset: 0; ++ z-index: 2147483646; ++} ++ +.autoscroll-indicator { + position: fixed; + 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 new file mode 100644 -index 0000000..1faef8e +index 0000000..82e1790 --- /dev/null +++ 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: +// on Linux the middle button pastes instead. +// @@ -118,21 +124,74 @@ index 0000000..1faef8e + +type Axes = { x: boolean; y: boolean } + -+function scrollableAxes(el: Element): Axes { -+ const style = getComputedStyle(el) -+ 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 overflowScrolls(overflow: string) { ++ return overflow === 'auto' || overflow === 'scroll' +} + -+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) { -+ const axes = scrollableAxes(el) -+ if (axes.x || axes.y) return { el, axes } ++ const style = getComputedStyle(el) ++ 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 { @@ -149,6 +208,16 @@ index 0000000..1faef8e + 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. +function speed(distance: number): number { + const past = Math.abs(distance) - DEAD_ZONE @@ -158,7 +227,8 @@ index 0000000..1faef8e + +export function installAutoscroll(): () => void { + let active: { -+ el: Element ++ origin: Element ++ scrollers: Element[] + axes: Axes + startX: number + startY: number @@ -170,7 +240,9 @@ index 0000000..1faef8e + carryY: number + lastFrame: number + frame: number ++ stuckFrames: number + indicator: HTMLElement ++ shield: HTMLElement + } | null = null + // The mouseup, click and auxclick that belong to a press autoscroll consumed. + let swallowButtons = 0 @@ -179,7 +251,9 @@ index 0000000..1faef8e + if (!active) return + cancelAnimationFrame(active.frame) + active.indicator.remove() ++ active.shield.remove() + document.documentElement.classList.remove('autoscrolling') ++ repaintScrollPositions(active.scrollers) + active = null + } + @@ -188,12 +262,28 @@ index 0000000..1faef8e + const dt = Math.min(now - active.lastFrame, 100) / 1000 + 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.carryY += active.axes.y ? speed(active.pointerY - active.startY) * dt : 0 + const left = Math.trunc(active.carryX) + const top = Math.trunc(active.carryY) + 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.carryY -= top + } @@ -217,13 +307,16 @@ index 0000000..1faef8e + if (e.button !== 1 || !(e.target instanceof Element)) return + if (e.target.closest(INTERACTIVE)) return + -+ const found = findScrollable(e.target) -+ if (!found) return ++ const scrollers = scrollersFrom(e.target) ++ const axes = scrollableAxes(scrollers) ++ if (!axes.x && !axes.y) return + + swallow(e) + swallowButtons = 1 << e.button + active = { -+ ...found, ++ origin: e.target, ++ scrollers, ++ axes, + startX: e.clientX, + startY: e.clientY, + pointerX: e.clientX, @@ -234,7 +327,9 @@ index 0000000..1faef8e + carryY: 0, + lastFrame: performance.now(), + 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') + active.frame = requestAnimationFrame(tick)