fix: autoscroll through nested scrollers and past webkit repaint lag
This commit is contained in:
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 +
|
||||
.../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)
|
||||
|
||||
Reference in new issue
Block a user