fix(awareness): follow a gesture to its last contact, and drop it on blur

Lifting one finger of a pinch removed the document listeners, so the
remaining finger's drag, which Cesium carries on with, stopped counting.
A gesture now ends only when its last pointer lifts or no touches are
left. And a gesture whose end never reaches the page (the window loses
focus mid-drag, or the page is hidden) left the listeners attached, so
a later unrelated drag counted as camera input; a blur or hidden page
now stops following it and drops its pending input.
This commit is contained in:
Sameh Khamis committed 2026-11-03 10:47:23 -08:00
1 parent de634952ec
commit 02a6739466
3 files changed
+105 -1

No files matched your search

+21
View File
@@ -23,6 +23,7 @@ export function createLifecycle({
if (event?.type === 'pointermove' && !event.buttons) return;
if (!cameraInputsEnabled()) return;
layerState.lastCameraInputMs = Date.now();
if (event?.type === 'pointerdown') activePointers.add(event.pointerId);
if (event?.type === 'pointerdown' || event?.type === 'touchstart')
followGesture(true);
}
@@ -39,22 +40,42 @@ export function createLifecycle({
'touchcancel',
];
let followingGesture = false;
const activePointers = new Set();
function followGesture(on) {
if (followingGesture === on) return;
followingGesture = on;
if (!on) activePointers.clear();
const listen = on ? 'addEventListener' : 'removeEventListener';
for (const type of GESTURE_MOVE_EVENTS)
globalThis.document?.[listen]?.(type, noteCameraInput, { passive: true });
for (const type of GESTURE_END_EVENTS)
globalThis.document?.[listen]?.(type, endGesture, { passive: true });
globalThis[listen]?.('blur', dropGesture);
globalThis.document?.[listen]?.('visibilitychange', dropGesture);
}
// A gesture ends with its last contact: lifting one finger of a pinch
// leaves Cesium a one-finger drag.
function endGesture(event) {
noteCameraInput(event);
activePointers.delete(event?.pointerId);
if (activePointers.size || event?.touches?.length) return;
followGesture(false);
}
// Losing the window or the page mid-gesture means its end may never
// arrive here, so stop following it and drop its pending input.
function dropGesture(event) {
if (
event?.type === 'visibilitychange' &&
document.visibilityState !== 'hidden'
)
return;
followGesture(false);
layerState.lastCameraInputMs = Number.NEGATIVE_INFINITY;
}
function cameraInputsEnabled() {
const controller = layerState.viewer?.scene?.screenSpaceCameraController;
return controller?.enableInputs !== false;
+83
View File
@@ -706,6 +706,89 @@ test('a long drag or touch keeps the pulses awake after it leaves the canvas', (
assert.equal(ring.state.lastCameraInputMs, noted);
});
test('lifting one finger of a pinch leaves the other finger counting', (t) => {
const ring = ringRendering(t);
const { inputWakeMs } = AWARENESS_RANGE_PULSE;
const { dispatch, pan, pulsing, loop } = inputLifecycle(t, ring);
const onDocument = (type, init = {}) =>
document.dispatchEvent(Object.assign(new Event(type), init));
// Cesium carries on with a one-finger drag after the other lifts.
const dragOn = (move, init) => {
for (let ms = 0; ms < inputWakeMs + 1000; ms += 250) {
loop.advance(250);
onDocument(move, init);
}
pan();
return pulsing();
};
// Without pointer events: the touch list says who is still down.
dispatch('touchstart');
onDocument('touchend', { touches: [{}] });
assert.equal(dragOn('touchmove', {}), true, 'touch: one finger left');
onDocument('touchend', { touches: [] });
assert.equal(dragOn('touchmove', {}), false, 'touch: all lifted');
// With pointer events: each contact has its own pointer id.
dispatch('pointerdown', { pointerId: 1 });
dispatch('pointerdown', { pointerId: 2 });
onDocument('pointerup', { pointerId: 1 });
const drag = { pointerId: 2, buttons: 1 };
assert.equal(dragOn('pointermove', drag), true, 'pointer: one left');
onDocument('pointercancel', { pointerId: 2 });
assert.equal(dragOn('pointermove', drag), false, 'pointer: all lifted');
});
test('a gesture lost to a window blur or a hidden page stops counting', (t) => {
const ring = ringRendering(t);
const { inputWakeMs } = AWARENESS_RANGE_PULSE;
const { dispatch, pan, pulsing, loop } = inputLifecycle(t, ring);
const visibility = Object.getOwnPropertyDescriptor(
document,
'visibilityState',
);
t.after(() => {
if (visibility)
Object.defineProperty(document, 'visibilityState', visibility);
else delete document.visibilityState;
});
const setVisibility = (value) =>
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => value,
});
for (const [name, lose] of [
['blur', () => window.dispatchEvent(new Event('blur'))],
[
'hidden',
() => {
setVisibility('hidden');
document.dispatchEvent(new Event('visibilitychange'));
setVisibility('visible');
},
],
]) {
// The release happens outside the window, so no end ever arrives.
dispatch('pointerdown', { pointerId: 1 });
lose();
pan();
assert.equal(pulsing(), false, `${name}: pending input dropped`);
loop.advance(inputWakeMs + 100);
const noted = ring.state.lastCameraInputMs;
document.dispatchEvent(
Object.assign(new Event('pointermove'), { pointerId: 1, buttons: 1 }),
);
assert.equal(ring.state.lastCameraInputMs, noted, `${name}: no follow`);
}
// A page coming back into view is no reason to drop a gesture.
dispatch('pointerdown', { pointerId: 1 });
document.dispatchEvent(new Event('visibilitychange'));
loop.advance(inputWakeMs + 100);
document.dispatchEvent(
Object.assign(new Event('pointermove'), { pointerId: 1, buttons: 1 }),
);
pan();
assert.equal(pulsing(), true, 'visible: still following');
});
test('disabling the layer tears the pulses down completely', (t) => {
const ring = ringRendering(t);
const { state, scene, rendering, motion, pulseFrames } = ring;