From 9374e0228b308d40913eb772b236a1316ac27429 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 13:36:12 -0800 Subject: [PATCH] Leave the wheel alone where edge scrolling should not take it Hidden rails (clean view, recording), sideways or empty moves, dialogs and the world timeline keep their wheel; coasting after a step is swallowed for at most 800 ms; line-based wheels step a panel per notch; the strip follows where each rail is in a right-to-left language; a held step is dropped when the pointer leaves the strip; nothing runs after removal. Co-authored-by: Manjunath Paparaju --- src/ui/railEdgeScroll.js | 127 +++++++++++++++++++---- src/ui/railEdgeScroll.test.mjs | 177 +++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 22 deletions(-) create mode 100644 src/ui/railEdgeScroll.test.mjs diff --git a/src/ui/railEdgeScroll.js b/src/ui/railEdgeScroll.js index de3c2e39a..4e3d9b0a4 100644 --- a/src/ui/railEdgeScroll.js +++ b/src/ui/railEdgeScroll.js @@ -26,12 +26,20 @@ const STEP_GAP_MS = 500; const NOTCH_DELTA = 100; /** The widest the strip gets, measured from the screen edge, whatever shape the rail takes. */ const STRIP_MAX = 84; +/** + * After a step, how long (ms) the rest of that gesture is still swallowed once + * the pointer has left the strip, so trackpad coasting does not go on to zoom + * the globe. Past it, the wheel belongs to whatever is under the pointer. + */ +const COAST_MAX_MS = 800; +/** Pixels per wheel line, for mice that report lines rather than pixels. */ +const LINE_PX = 16; /** * Things in the strip that keep their own pointer and wheel behaviour: any * control, and every panel in either side's stack (a panel scrolls itself). */ const OWN_CONTROLS = - 'a, button, input, select, textarea, [role="dialog"], .ui-panel, #left-panel-stack > :not(.gev-panel-rail), #right-context-rail > :not(.gev-panel-rail), #first-run-launcher, #cesium-credits'; + 'a, button, input, select, textarea, dialog, [role="dialog"], .ui-panel, #left-panel-stack > :not(.gev-panel-rail), #right-context-rail > :not(.gev-panel-rail), #first-run-launcher, #cesium-credits, #world-timeline'; /** * @param {Document} [doc] @@ -54,16 +62,40 @@ export function installRailEdgeScroll(doc = globalThis.document) { let peakSpeed = 0; let slowed = false; let frame = 0; + /** Set once removed, so frames already queued do nothing. */ + let removed = false; + // Clean view and recording hide the rails with visibility and opacity, + // which keep their boxes, so a box alone does not mean the rail shows. + const showing = (node) => + node.getBoundingClientRect().width > 0 && + (typeof node.checkVisibility !== 'function' || + node.checkVisibility({ + visibilityProperty: true, + opacityProperty: true, + })); + + // Which screen edge a rail is on comes from where it is, not its name: in a + // right-to-left language the left stack sits on the right. const rails = () => ['left', 'right'] - .map((side) => ({ side, rail: doc.querySelector(`.ui-rail--${side}`) })) - .filter(({ rail }) => rail && rail.getBoundingClientRect().width > 0); + .map((name) => doc.querySelector(`.ui-rail--${name}`)) + .filter((rail) => rail && showing(rail)) + .map((rail) => { + const box = rail.getBoundingClientRect(); + const middle = doc.documentElement.clientWidth / 2; + return { + side: box.left + box.width / 2 < middle ? 'left' : 'right', + rail, + }; + }); const items = (rail) => // Only items that show: a theme or mode may hide some, and the wheel must // not open those. - [...rail.querySelectorAll('.ui-rail__item')].filter((item) => !item.disabled && item.offsetWidth > 0); + [...rail.querySelectorAll('.ui-rail__item')].filter( + (item) => !item.disabled && showing(item), + ); // ── The sliding highlight ── // Each rail draws it as a ::before under its items, placed by custom @@ -73,6 +105,7 @@ export function installRailEdgeScroll(doc = globalThis.document) { const gliding = new WeakSet(); function glide(rail) { + if (removed) return; rail.classList.toggle('ui-rail--edge-hot', rail === hot); const on = rail.querySelector('.ui-rail__item[aria-pressed="true"]'); if (!on?.offsetWidth) { @@ -87,16 +120,30 @@ export function installRailEdgeScroll(doc = globalThis.document) { // buttons shrinking over a few frames; the highlight waits until the open // button is an icon again (square) rather than fading in at a pill's // width and then shrinking. - if (!shown && (rail.classList.contains('ui-rail--expanded') || box.width > box.height + 2)) return; + if ( + !shown && + (rail.classList.contains('ui-rail--expanded') || + box.width > box.height + 2) + ) + return; // Appearing from nothing: jump into place, then fade in. rail.classList.toggle('ui-rail--glide-jump', !shown); - rail.style.setProperty('--rail-glide-x', `${box.left - railBox.left - rail.clientLeft}px`); - rail.style.setProperty('--rail-glide-y', `${box.top - railBox.top - rail.clientTop}px`); + rail.style.setProperty( + '--rail-glide-x', + `${box.left - railBox.left - rail.clientLeft}px`, + ); + rail.style.setProperty( + '--rail-glide-y', + `${box.top - railBox.top - rail.clientTop}px`, + ); rail.style.setProperty('--rail-glide-w', `${box.width}px`); rail.style.setProperty('--rail-glide-h', `${box.height}px`); rail.classList.add('ui-rail--glide'); gliding.add(rail); - if (!shown) win.requestAnimationFrame(() => rail.classList.remove('ui-rail--glide-jump')); + if (!shown) + win.requestAnimationFrame(() => + rail.classList.remove('ui-rail--glide-jump'), + ); } // Follow the rails for a few frames after any change, as their items @@ -105,6 +152,7 @@ export function installRailEdgeScroll(doc = globalThis.document) { win.cancelAnimationFrame(frame); let left = frames; const tick = () => { + if (removed) return; watchRails(); doc.querySelectorAll('.ui-rail').forEach(glide); if ((left -= 1) > 0) frame = win.requestAnimationFrame(tick); @@ -117,7 +165,11 @@ export function installRailEdgeScroll(doc = globalThis.document) { ? new win.MutationObserver((records) => { // A rail item opened or closed. Nothing here writes aria-pressed, // and the work waits for the next frame, so this never feeds itself. - if (records.some((record) => record.target.classList?.contains('ui-rail__item'))) + if ( + records.some((record) => + record.target.classList?.contains('ui-rail__item'), + ) + ) win.requestAnimationFrame(() => follow()); }) : null; @@ -132,7 +184,9 @@ export function installRailEdgeScroll(doc = globalThis.document) { // row (panels moved to the screen edge). Its highlight is placed again // whenever its size changes, so it is never left where an icon was. const sized = - typeof win.ResizeObserver === 'function' ? new win.ResizeObserver(() => follow(2)) : null; + typeof win.ResizeObserver === 'function' + ? new win.ResizeObserver(() => follow(2)) + : null; const watched = new WeakSet(); const watchRails = () => { if (!sized) return; @@ -147,7 +201,8 @@ export function installRailEdgeScroll(doc = globalThis.document) { // ── The edge strip ── /** The rail whose strip holds the point, unless a control there owns it. */ function railAt(x, target) { - if (doc.body.classList.contains('mobile-shell') || coarse?.matches) return null; + if (doc.body.classList.contains('mobile-shell') || coarse?.matches) + return null; for (const { side, rail } of rails()) { const box = rail.getBoundingClientRect(); // From the screen edge to just past the rail, but never wider than a @@ -168,6 +223,12 @@ export function installRailEdgeScroll(doc = globalThis.document) { function setHot(rail) { if (rail === hot) return; + // A held step belongs to the strip it was made in; leaving drops it, so + // coming back later does not set it off with no wheel turned. + if (heldStep) { + win.clearTimeout(heldStep); + heldStep = 0; + } hot?.classList.remove('ui-rail--edge-hot'); hot = rail; travel = 0; @@ -186,7 +247,9 @@ export function installRailEdgeScroll(doc = globalThis.document) { function step(direction) { const list = items(hot); if (!list.length) return; - const current = list.findIndex((item) => item.getAttribute('aria-pressed') === 'true'); + const current = list.findIndex( + (item) => item.getAttribute('aria-pressed') === 'true', + ); // Past either end, carry on from the other. const nextIndex = current < 0 @@ -201,7 +264,9 @@ export function installRailEdgeScroll(doc = globalThis.document) { if (panel) { panel.classList.remove('rail-switch-down', 'rail-switch-up'); void panel.offsetWidth; - panel.classList.add(direction > 0 ? 'rail-switch-down' : 'rail-switch-up'); + panel.classList.add( + direction > 0 ? 'rail-switch-down' : 'rail-switch-up', + ); // The classes come off when the slide ends, or after a moment where // there is no slide to end (reduced motion). const settle = () => { @@ -216,17 +281,20 @@ export function installRailEdgeScroll(doc = globalThis.document) { } function onWheel(event) { - if (event.ctrlKey) return; + // Pinch zoom, and sideways or empty moves (trackpads send them mid-swipe), + // are left alone: they neither step nor turn a gesture round. + if (event.ctrlKey || !event.deltaY) return; const now = win.performance?.now?.() ?? Date.now(); const inGesture = now - lastWheel <= GESTURE_GAP_MS; // Recheck: the pointer may not have moved since the panels changed. - const stillHot = Boolean(hot) && railAt(event.clientX, event.target) === hot; + const stillHot = + Boolean(hot) && railAt(event.clientX, event.target) === hot; if (!stillHot) { setHot(null); // The rest of a gesture that has already stepped is used up here, so // its coasting does not go on to zoom the globe or scroll what opened // under the pointer. - if (stepped && inGesture) { + if (stepped && inGesture && now - steppedAt <= COAST_MAX_MS) { event.preventDefault(); event.stopPropagation(); lastWheel = now; @@ -235,15 +303,25 @@ export function installRailEdgeScroll(doc = globalThis.document) { } event.preventDefault(); event.stopPropagation(); - const speed = Math.abs(event.deltaY); + // Line- and page-based wheels report far smaller numbers than pixels. + const deltaY = + event.deltaMode === 1 + ? event.deltaY * LINE_PX + : event.deltaMode === 2 + ? event.deltaY * win.innerHeight + : event.deltaY; + const speed = Math.abs(deltaY); // A new gesture: after a pause, after turning round, a mouse wheel notch, // or a fresh flick while the last one is still coasting. A flick is a // rise in speed after the gesture has clearly slowed, and not in the // moment after a step: a swipe is still speeding up when it first steps. - const turned = travel && Math.sign(event.deltaY) !== Math.sign(travel); + const turned = travel && Math.sign(deltaY) !== Math.sign(travel); const cooled = now - steppedAt > STEP_COOLDOWN_MS; - const notch = stepped && cooled && speed >= NOTCH_DELTA; - const flick = stepped && cooled && slowed && speed > Math.max(8, lastSpeed * 1.8); + // Every event from a line- or page-based wheel is a notch. + const notch = + stepped && cooled && (speed >= NOTCH_DELTA || event.deltaMode > 0); + const flick = + stepped && cooled && slowed && speed > Math.max(8, lastSpeed * 1.8); if (!inGesture || turned || notch || flick) { // A held step belongs to the motion it came from; a reversal or a new // flick replaces it rather than queueing behind it. @@ -263,7 +341,7 @@ export function installRailEdgeScroll(doc = globalThis.document) { if (speed < peakSpeed * 0.5) slowed = true; return; } - travel += event.deltaY; + travel += deltaY; if (Math.abs(travel) < STEP_DELTA) return; stepped = true; const direction = Math.sign(travel); @@ -294,6 +372,7 @@ export function installRailEdgeScroll(doc = globalThis.document) { follow(); return () => { + removed = true; win.clearTimeout(heldStep); setHot(null); observer?.disconnect(); @@ -304,7 +383,11 @@ export function installRailEdgeScroll(doc = globalThis.document) { win.removeEventListener('wheel', onWheel, { capture: true }); win.removeEventListener('resize', onResize); doc.querySelectorAll('.ui-rail').forEach((rail) => { - rail.classList.remove('ui-rail--glide', 'ui-rail--glide-jump', 'ui-rail--edge-hot'); + rail.classList.remove( + 'ui-rail--glide', + 'ui-rail--glide-jump', + 'ui-rail--edge-hot', + ); }); }; } diff --git a/src/ui/railEdgeScroll.test.mjs b/src/ui/railEdgeScroll.test.mjs new file mode 100644 index 000000000..a16964ebe --- /dev/null +++ b/src/ui/railEdgeScroll.test.mjs @@ -0,0 +1,177 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Window } from 'happy-dom'; +import { installRailEdgeScroll } from './railEdgeScroll.js'; + +// A page with a left and a right rail of three items each, laid out by hand: +// happy-dom does no layout, so boxes and the clock are stubbed. +function page({ rtl = false } = {}) { + const window = new Window({ width: 1600, height: 900 }); + const doc = window.document; + let now = 1000; + const timers = new Map(); + let nextTimer = 1; + window.performance.now = () => now; + window.setTimeout = (fn, ms) => { + const id = nextTimer++; + timers.set(id, { fn, at: now + ms }); + return id; + }; + window.clearTimeout = (id) => timers.delete(id); + window.requestAnimationFrame = () => 0; + window.cancelAnimationFrame = () => {}; + Object.defineProperty(doc.documentElement, 'clientWidth', { value: 1600 }); + const box = (el, left, top, width, height) => { + el.getBoundingClientRect = () => ({ + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }); + Object.defineProperty(el, 'offsetWidth', { get: () => width }); + }; + const clicks = []; + const rail = (name, left) => { + const el = doc.createElement('div'); + el.className = `ui-rail ui-rail--${name}`; + box(el, left, 200, 40, 120); + for (let i = 0; i < 3; i++) { + const item = doc.createElement('button'); + item.className = 'ui-rail__item'; + item.dataset.railItem = `${name}-${i}`; + item.setAttribute('aria-pressed', i === 0 ? 'true' : 'false'); + box(item, left + 4, 204 + i * 40, 32, 32); + item.addEventListener('click', () => { + clicks.push(item.dataset.railItem); + for (const other of el.children) + other.setAttribute('aria-pressed', String(other === item)); + }); + el.append(item); + } + doc.body.append(el); + return el; + }; + // In a right-to-left language the left stack's rail is on the right edge. + const left = rail('left', rtl ? 1550 : 10); + const right = rail('right', rtl ? 10 : 1550); + const remove = installRailEdgeScroll(doc); + const wheel = (x, deltaY, { deltaMode = 0, target = doc.body } = {}) => { + // happy-dom leaves clientX off these events, so it is set by hand. + const at = (event) => Object.defineProperty(event, 'clientX', { value: x }); + target.dispatchEvent( + at(new window.PointerEvent('pointermove', { bubbles: true })), + ); + const event = at( + new window.WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY, + deltaMode, + }), + ); + target.dispatchEvent(event); + return event; + }; + const advance = (ms) => { + now += ms; + for (const [id, timer] of [...timers]) { + if (timer.at > now) continue; + timers.delete(id); + timer.fn(); + } + }; + return { window, doc, left, right, clicks, wheel, advance, remove }; +} + +test('a wheel in the left strip steps that rail and keeps the globe from zooming', () => { + const p = page(); + const event = p.wheel(20, 120); + assert.equal(event.defaultPrevented, true); + assert.deepEqual(p.clicks, ['left-1']); + p.remove(); +}); + +test('a line-based mouse wheel steps one panel per notch', () => { + const p = page(); + p.wheel(20, 3, { deltaMode: 1 }); + p.advance(600); + p.wheel(20, 3, { deltaMode: 1 }); + assert.deepEqual(p.clicks, ['left-1', 'left-2']); + p.remove(); +}); + +test('a rail hidden by clean view or recording leaves the wheel to the globe', () => { + const p = page(); + p.left.checkVisibility = () => false; + const event = p.wheel(20, 120); + assert.equal(event.defaultPrevented, false); + assert.deepEqual(p.clicks, []); + p.remove(); +}); + +test('right to left: the wheel at each screen edge steps the rail on that edge', () => { + const p = page({ rtl: true }); + p.wheel(1580, 120); + p.advance(600); + p.wheel(20, 120); + assert.deepEqual(p.clicks, ['left-1', 'right-1']); + p.remove(); +}); + +test('after a step, the wheel goes back to the page once the coasting is over', () => { + const p = page(); + p.wheel(20, 120); + // The pointer moves off the strip while the gesture still coasts. + let event; + for (let i = 0; i < 20; i++) { + p.advance(100); + event = p.wheel(800, 30); + } + assert.equal(event.defaultPrevented, false); + assert.deepEqual(p.clicks, ['left-1']); + p.remove(); +}); + +test('a held step is dropped when the pointer leaves the strip, even if it comes back', () => { + const p = page(); + p.wheel(20, 120); + p.advance(250); + p.wheel(20, -120); // turns round: a second step, held for the gap + p.wheel(800, 0); // leaves the strip + p.wheel(20, 0); // and returns before the gap ends + p.advance(600); + assert.deepEqual(p.clicks, ['left-1']); + p.remove(); +}); + +test('removing it stops edge scrolling', () => { + const p = page(); + p.remove(); + const event = p.wheel(20, 120); + assert.equal(event.defaultPrevented, false); + assert.deepEqual(p.clicks, []); +}); + +test('a sideways move mid-swipe neither turns the gesture round nor steps again', () => { + const p = page(); + p.wheel(20, 60); + p.advance(50); + const sideways = p.wheel(20, 0); + p.advance(50); + p.wheel(20, 40); // the same swipe coasting on + assert.equal(sideways.defaultPrevented, false); + assert.deepEqual(p.clicks, ['left-1']); + p.remove(); +}); + +test('an open dialog near the edge keeps its own wheel', () => { + const p = page(); + const dialog = p.doc.createElement('dialog'); + p.doc.body.append(dialog); + const event = p.wheel(20, 120, { target: dialog }); + assert.equal(event.defaultPrevented, false); + assert.deepEqual(p.clicks, []); + p.remove(); +});