fix(camera): hold the globe recovery until every pointer is released

The held-pointer guard was one flag, so with two fingers down, lifting one cleared it and the recovery turned the view down under the other (pitch -42° to -90°). It now tracks each pressed pointer by id and defers until the last is released.

A release outside the window that the page never sees could leave the recovery deferred for good. A lost window focus, a hidden page or a lost pointer capture now releases every held pointer, and a pointer that moves with no button down is released.
This commit is contained in:
Sameh Khamis committed 2026-11-05 16:16:05 -08:00
1 parent 44106b2bfb
commit f332150689
3 files changed
+132 -15

No files matched your search

+6 -2
View File
@@ -104,8 +104,12 @@ other ways a move can end looking away, such as a saved or shared view that did.
Views with more surface than that, such as the oblique orbital Director shots, Views with more surface than that, such as the oblique orbital Director shots,
are left as they are, and so are tracked or locked cameras, a camera flight in are left as they are, and so are tracked or locked cameras, a camera flight in
progress, a Director scene while it plays (holds included), a pointer held progress, a Director scene while it plays (holds included), a pointer held
down on the globe (a drag held still) and the 2D and Columbus modes; a check a down on the globe (a drag held still; with several fingers down, until the
flight, scene or held pointer defers runs once it ends. A Director last is released) and the 2D and Columbus modes; a check a flight, scene or
held pointer defers runs once it ends. A release outside the window that the
page never sees counts as given once the window loses focus, the page is
hidden, the canvas loses the pointer's capture, or the pointer moves with no
button down. A Director
shot loaded or replayed outside a run is not a playing scene. A view that loses shot loaded or replayed outside a run is not a playing scene. A view that loses
its surface without a camera move (a resize, or turning the scope on) is left as its surface without a camera move (a resize, or turning the scope on) is left as
it is until the next move. A voice turn requested while the camera is tracked is it is until the next move. A voice turn requested while the camera is tracked is
+96
View File
@@ -811,6 +811,102 @@ test('the recovery waits while a pointer is held on the globe', () => {
assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners'); assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners');
}); });
/** A held-pointer recovery over a canvas in a document in a window. */
function heldPointerRecovery() {
const timers = fakeTimers();
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
const window = new EventTarget();
const document = Object.assign(new EventTarget(), {
defaultView: window,
visibilityState: 'visible',
});
scene.canvas = Object.assign(new EventTarget(), {
clientWidth: 1225,
clientHeight: 923,
ownerDocument: document,
});
const dispose = installGlobeInViewRecovery(viewer, timers);
const pointer = (target, type, pointerId, init = {}) =>
target.dispatchEvent(
Object.assign(new Event(type), { pointerId, ...init }),
);
/** Press, move and hold still: the check defers while a pointer is held. */
const holdStill = () => {
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
camera.moveEnd.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
};
const settle = () => {
for (let i = 0; i < 3; i += 1) timers.tick();
};
const recovered = () =>
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6);
return {
camera,
document,
window,
dispose,
pointer,
holdStill,
settle,
recovered,
canvas: scene.canvas,
};
}
// Releasing one of two fingers cleared the held flag, and the recovery
// turned the view down under the finger still on the glass.
test('the recovery waits until every held pointer is released', () => {
const h = heldPointerRecovery();
h.pointer(h.canvas, 'pointerdown', 1);
h.pointer(h.canvas, 'pointerdown', 2);
h.holdStill();
h.pointer(h.document, 'pointerup', 1);
h.settle();
assert.equal(h.recovered(), false, 'left alone under the second finger');
assert.equal(globeOutOfFrame(h.camera), true);
h.pointer(h.document, 'pointercancel', 2);
h.settle();
assert.equal(h.recovered(), true, 'recovered once both are released');
h.dispose();
});
// A release the page never sees (the pointer let go outside the window)
// must not hold the recovery off for good.
for (const [label, lose] of [
['the window loses focus', (h) => h.window.dispatchEvent(new Event('blur'))],
[
'the page is hidden',
(h) => {
h.document.visibilityState = 'hidden';
h.document.dispatchEvent(new Event('visibilitychange'));
},
],
[
'the canvas loses the pointer capture',
(h) => h.pointer(h.canvas, 'lostpointercapture', 1),
],
[
'the pointer moves with no button down',
(h) => h.pointer(h.document, 'pointermove', 1, { buttons: 0 }),
],
]) {
test(`a held pointer whose release is lost stops deferring when ${label}`, () => {
const h = heldPointerRecovery();
h.pointer(h.canvas, 'pointerdown', 1);
h.holdStill();
h.pointer(h.document, 'pointermove', 1, { buttons: 1 });
h.settle();
assert.equal(h.recovered(), false, 'left alone while held');
lose(h);
h.settle();
assert.equal(h.recovered(), true, 'recovered once the hold is let go');
h.dispose();
});
}
function fakeTimers() { function fakeTimers() {
let next = 1; let next = 1;
const pending = new Map(); const pending = new Map();
+30 -13
View File
@@ -207,26 +207,43 @@ export function installGlobeInViewRecovery(
throw new TypeError('A complete Cesium viewer is required'); throw new TypeError('A complete Cesium viewer is required');
// A pointer held on the globe is a gesture in progress, even when it holds // A pointer held on the globe is a gesture in progress, even when it holds
// still for half a second: the check waits for its release. // still for half a second: the check waits until every pointer pressed on
// it (each finger of a pinch) is released. A release the page never sees,
// outside the window, is taken as given when the window loses focus, the
// page is hidden, the canvas loses the pointer's capture, or the pointer
// moves with no button down.
const canvas = scene.canvas; const canvas = scene.canvas;
const releaseTarget = canvas?.ownerDocument ?? canvas; const releaseTarget = canvas?.ownerDocument ?? canvas;
let pointerHeld = false; const releaseWindow = canvas?.ownerDocument?.defaultView;
const onPointerDown = () => { const heldPointers = new Set();
pointerHeld = true; const onPointerDown = (event) => heldPointers.add(event?.pointerId);
const onPointerUp = (event) => heldPointers.delete(event?.pointerId);
const onPointerMove = (event) => {
if (event?.buttons === 0) heldPointers.delete(event.pointerId);
}; };
const onPointerUp = () => { const releaseAll = () => heldPointers.clear();
pointerHeld = false; const onVisibilityChange = () => {
if (canvas.ownerDocument.visibilityState === 'hidden') releaseAll();
}; };
canvas?.addEventListener?.('pointerdown', onPointerDown); const listeners = [
releaseTarget?.addEventListener?.('pointerup', onPointerUp); [canvas, 'pointerdown', onPointerDown],
releaseTarget?.addEventListener?.('pointercancel', onPointerUp); [canvas, 'lostpointercapture', onPointerUp],
[releaseTarget, 'pointerup', onPointerUp],
[releaseTarget, 'pointercancel', onPointerUp],
[releaseTarget, 'pointermove', onPointerMove],
[canvas?.ownerDocument, 'visibilitychange', onVisibilityChange],
[releaseWindow, 'blur', releaseAll],
];
for (const [target, type, listener] of listeners)
target?.addEventListener?.(type, listener);
// Returns false while a flight, a paused scene or a held pointer defers the // Returns false while a flight, a paused scene or a held pointer defers the
// check, so the caller can try again once it ends. // check, so the caller can try again once it ends.
const recover = () => { const recover = () => {
if (!isFreeGlobeCamera(viewer)) return true; if (!isFreeGlobeCamera(viewer)) return true;
// Cesium has no public running-flight flag. // Cesium has no public running-flight flag.
if (camera._currentFlight || paused() || pointerHeld) return false; if (camera._currentFlight || paused() || heldPointers.size > 0)
return false;
const height = camera.positionCartographic?.height; const height = camera.positionCartographic?.height;
if ( if (
!(height > minHeight) || !(height > minHeight) ||
@@ -310,9 +327,9 @@ export function installGlobeInViewRecovery(
stopSettling(); stopSettling();
removeMoveEnd(); removeMoveEnd();
removeMoveStart(); removeMoveStart();
canvas?.removeEventListener?.('pointerdown', onPointerDown); for (const [target, type, listener] of listeners)
releaseTarget?.removeEventListener?.('pointerup', onPointerUp); target?.removeEventListener?.(type, listener);
releaseTarget?.removeEventListener?.('pointercancel', onPointerUp); heldPointers.clear();
}; };
} }