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 <[email protected]>
This commit is contained in:
Sameh KhamisandManjunath Paparaju committed 2026-11-14 13:41:07 -08:00
1 parent 235fe94a89
commit 9374e0228b
2 files changed
+282 -22

No files matched your search

+105 -22
View File
@@ -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',
);
});
};
}
+177
View File
@@ -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();
});