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 +
|
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)
|
||||||
|
|||||||
Reference in new issue
Block a user