From 24bc8fce7ecd3913b1898985d0f8158aa3ee24dc Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 19:22:43 -0800 Subject: [PATCH] Deliver the application's pointer input through the renderer's input port The Cesium renderer now creates the input port and exposes it as renderer.input. The input dispatcher becomes an adapter over the port: one port listener per event type, fanned out to its subscribers with the ownership check read once per event, keeping the legacy event shapes. - Ctrl+wheel zoom is switched on through renderer.input.ctrlWheelZoom. - A dispatcher made for a viewer with no port creates its own port with takeStock: false, which borrows the stock select and double-click follow only while they are suppressed. - The stock actions see the press as it began: a click whose press started while select or follow was held off never runs them. - A scene accepts one input port; cesiumInputOf(scene) returns it. - Releasing one mouse button while another is held no longer gives the held button a click. Cesium still ends that press. --- scripts/package-boundaries.json | 100 ++++++++-- scripts/qa-draw-tool.mjs | 87 +++----- src/annotations/drawTool.js | 19 +- src/annotations/drawToolBehaviour.test.mjs | 37 ++-- src/app/scene.js | 4 +- src/data/alprCameras.test.mjs | 7 +- src/data/infrastructure.test.mjs | 3 + src/data/militaryInstallations.test.mjs | 3 +- src/input/dispatcher.js | 218 +++++++++------------ src/input/testDoubles.mjs | 40 ++-- src/layers/recentImagery/testDoubles.mjs | 3 +- src/render/cesium/appRenderer.js | 12 +- src/render/cesium/appRenderer.test.mjs | 11 +- src/render/cesium/input.js | 97 +++++++-- src/render/cesium/input.test.mjs | 53 ++++- src/ui/imageryBoxTool.test.mjs | 8 +- 16 files changed, 427 insertions(+), 275 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9eb75f2a9..4ec5742db 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -14,6 +14,9 @@ "src/data/scenePick.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js", "src/input/sensorPick.js", "src/data/infrastructureOverlayEntry.js", "src/sources/infrastructureData.js", @@ -884,7 +887,10 @@ "src/data/pickRegistry.js", "src/input/sensorPick.js", "src/input/dispatcher.js", - "src/render/cesium/cameraInput.js" + "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js" ], "external": ["cesium"] }, @@ -1143,7 +1149,10 @@ "src/data/pickRegistry.js", "src/input/sensorPick.js", "src/input/dispatcher.js", - "src/render/cesium/cameraInput.js" + "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -1282,7 +1291,10 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", - "src/render/cesium/cameraInput.js" + "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -1376,6 +1388,9 @@ "src/data/pickRegistry.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js", "src/render/cesium/internals.js" ], "external": ["cesium"] @@ -1448,7 +1463,10 @@ "src/input/sensorPick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", - "src/render/cesium/cameraInput.js" + "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js" ], "external": ["cesium"] }, @@ -1533,7 +1551,10 @@ "src/data/pickRegistry.js", "src/input/sensorPick.js", "src/input/dispatcher.js", - "src/render/cesium/cameraInput.js" + "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js" ], "external": ["cesium"] }, @@ -1620,7 +1641,10 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", - "src/render/cesium/cameraInput.js" + "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js" ], "external": ["cesium"] }, @@ -1665,7 +1689,10 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", - "src/render/cesium/cameraInput.js" + "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js" ], "external": ["cesium", "satellite.js"] }, @@ -1710,7 +1737,10 @@ "src/data/pickRegistry.js", "src/input/sensorPick.js", "src/input/dispatcher.js", - "src/render/cesium/cameraInput.js" + "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js" ], "external": ["cesium"] }, @@ -1768,7 +1798,10 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", - "src/render/cesium/cameraInput.js" + "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1916,6 +1949,9 @@ "src/data/pickRegistry.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js", "src/queries/heights.js" ], "external": ["cesium"] @@ -1986,7 +2022,10 @@ "src/data/pickRegistry.js", "src/input/sensorPick.js", "src/input/dispatcher.js", - "src/render/cesium/cameraInput.js" + "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js" ], "external": ["hls.js", "cesium"] }, @@ -2065,6 +2104,9 @@ "src/data/pickRegistry.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js", "src/queries/heights.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] @@ -2110,6 +2152,9 @@ "src/data/pickRegistry.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js", "src/queries/heights.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] @@ -2164,6 +2209,9 @@ "src/data/pickRegistry.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js", "src/render/cesium/internals.js" ], "external": ["cesium"] @@ -2201,7 +2249,10 @@ "src/input/sensorPick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", - "src/render/cesium/cameraInput.js" + "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js" ], "external": ["cesium"] }, @@ -3363,6 +3414,8 @@ "src/input/sensorPick.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/input/router.js", "src/queries/heights.js", "src/render/cesium/internals.js", "src/render/cesium/tileRequests.js" @@ -3992,6 +4045,9 @@ "src/input/sensorPick.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js", "src/queries/heights.js", "src/render/cesium/tileRequests.js", "src/render/cesium/internals.js" @@ -4104,6 +4160,9 @@ "modules": [ "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js", "src/input/sensorPick.js", "src/data/inputOwnership.js", "src/data/pickRegistry.js", @@ -4362,7 +4421,10 @@ "src/input/sensorPick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", - "src/render/cesium/cameraInput.js" + "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js" ], "external": ["cesium"] }, @@ -4695,6 +4757,9 @@ "src/input/sensorPick.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js", "src/queries/heights.js", "src/render/cesium/internals.js" ], @@ -4754,6 +4819,9 @@ "src/data/pickRegistry.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js", "src/queries/heights.js", "src/render/cesium/internals.js" ], @@ -4822,7 +4890,10 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", - "src/render/cesium/cameraInput.js" + "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js" ], "external": ["cesium"] }, @@ -4924,6 +4995,9 @@ "src/data/pickRegistry.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/input.js", + "src/render/cesium/ctrlWheelZoom.js", + "src/render/input/router.js", "src/queries/heights.js" ], "external": ["cesium"] diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs index d579234e9..d0c5f1f19 100644 --- a/scripts/qa-draw-tool.mjs +++ b/scripts/qa-draw-tool.mjs @@ -263,39 +263,16 @@ try { }), ); - // The viewer's OWN click actions, captured before the tool ever runs. Both - // must be absent while drawing and back — as the SAME functions — after. - await page.evaluate(() => { - const Cesium = window.__CESIUM__; - const stock = window.__godsEyeView.viewer.screenSpaceEventHandler; - window.__qaStock = { - click: - stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null, - double: - stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) || - null, + // The viewer's own select and follow, which the input port runs: on before + // the tool runs, off while drawing, and on again after. + const stockActions = async () => { + const state = await drawState(); + return { + clickOn: state.stockSingleClick, + doubleOn: state.stockDoubleClick, }; - }); - const stockActions = () => - page.evaluate(() => { - const Cesium = window.__CESIUM__; - const stock = window.__godsEyeView.viewer.screenSpaceEventHandler; - const live = { - click: - stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null, - double: - stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) || - null, - }; - return { - clickPresent: Boolean(live.click), - doublePresent: Boolean(live.double), - clickIsOriginal: live.click === window.__qaStock.click, - doubleIsOriginal: live.double === window.__qaStock.double, - hadClick: Boolean(window.__qaStock.click), - hadDouble: Boolean(window.__qaStock.double), - }; - }); + }; + const beforeDraw = await stockActions(); // ── enable / disable cycles leave nothing behind ──────────────────────── const before = await drawState(); @@ -318,24 +295,21 @@ try { `handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`, ); - // ── the viewer's own click actions are borrowed, then given back ──────── + // ── the viewer's own click actions are held off, then given back ─────── await page.click('#draw-toggle'); const duringDraw = await stockActions(); check( - "Cesium's own selecting click and tracking double-click are both borrowed while drawing", - duringDraw.clickPresent === false && duringDraw.doublePresent === false, - `click present ${duringDraw.clickPresent}, double present ${duringDraw.doublePresent} ` + - `(viewer had click ${duringDraw.hadClick}, double ${duringDraw.hadDouble})`, + "Cesium's own selecting click and tracking double-click are both off while drawing", + duringDraw.clickOn === false && duringDraw.doubleOn === false, + JSON.stringify({ beforeDraw, duringDraw }), ); await page.click('#draw-toggle'); const afterDraw = await stockActions(); check( - 'and both are restored as the SAME functions, not replacements', - afterDraw.clickPresent === duringDraw.hadClick && - afterDraw.doublePresent === duringDraw.hadDouble && - afterDraw.clickIsOriginal && - afterDraw.doubleIsOriginal, - JSON.stringify(afterDraw), + 'and both are back after', + afterDraw.clickOn === beforeDraw.clickOn && + afterDraw.doubleOn === beforeDraw.doubleOn, + JSON.stringify({ beforeDraw, afterDraw }), ); // ── a pin, finished with Enter ────────────────────────────────────────── @@ -664,19 +638,6 @@ try { document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0, { timeout: 10_000 }, ); - // The reload built a new page context: re-capture the viewer's own click - // actions so the identity comparisons below still mean something. - await page.evaluate(() => { - const Cesium = window.__CESIUM__; - const stock = window.__godsEyeView.viewer.screenSpaceEventHandler; - window.__qaStock = { - click: - stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null, - double: - stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) || - null, - }; - }); await page.evaluate((view) => { const viewer = window.__godsEyeView.viewer; viewer.camera.cancelFlight?.(); @@ -827,24 +788,20 @@ try { const wasActive = tool.active; await tool.destroy(); await new Promise((resolve) => setTimeout(resolve, 50)); - const Cesium = window.__CESIUM__; const viewer = window.__godsEyeView.viewer; - const stock = viewer.screenSpaceEventHandler; let previews = 0; for (let i = 0; i < viewer.dataSources.length; i += 1) if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1; + const diagnostics = tool.diagnostics(); return { wasActive, - diagnostics: tool.diagnostics(), + diagnostics, previews, dataSources: viewer.dataSources.length, handleGone: window.__gevDrawTool === undefined, - clickRestored: - stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) === - window.__qaStock.click, - doubleRestored: - stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) === - window.__qaStock.double, + // The viewer's own select and follow run again. + clickRestored: diagnostics.stockSingleClick, + doubleRestored: diagnostics.stockDoubleClick, drawingClass: document.body.classList.contains('gev-drawing'), }; }); diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 905901f47..827e99e62 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -509,21 +509,10 @@ export function initDrawTool({ previewDataSources: countPreviewDataSources(viewer), previewEntities: destroyed ? 0 : dataSource.entities.values.length, pointerOwner: pointerOwner(), - // The viewer's OWN click actions. While a session is open both are - // borrowed (absent here); after it closes both are back. A harness can - // compare the restored functions by identity with what it captured - // before, which is the only way to prove they were given back rather - // than replaced. - stockSingleClick: Boolean( - viewer.screenSpaceEventHandler?.getInputAction?.( - Cesium.ScreenSpaceEventType.LEFT_CLICK, - ), - ), - stockDoubleClick: Boolean( - viewer.screenSpaceEventHandler?.getInputAction?.( - Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, - ), - ), + // The viewer's own select and follow, which the input port runs: off + // while a session is open, back on after it closes. + stockSingleClick: inputFor(viewer).stockEnabled('select'), + stockDoubleClick: inputFor(viewer).stockEnabled('dblclickFollow'), }; }, /** Resolves once every deferred teardown step has run. */ diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index b76181db2..a99d85569 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -224,11 +224,13 @@ function fakeAnnotations() { } /** Stand up a tool against fresh doubles, with globals restored afterwards. */ -function harness({ navigation = null } = {}) { +function harness({ navigation = null, beforeInput = () => {} } = {}) { resetPointerOwnership(); const { byId, documentDouble } = fakeDom(); const viewer = fakeViewer(); - const input = fakeInput(viewer, { eventTypes: Cesium.ScreenSpaceEventType }); + // The viewer's own actions are bound before its input port takes them. + beforeInput(viewer); + const input = fakeInput(viewer); const annotations = fakeAnnotations(); const previousDocument = globalThis.document; const previousWindow = globalThis.window; @@ -339,30 +341,33 @@ test('a destroyed instance cannot free the pointer its replacement holds', async } }); -test('both stock viewer click actions are borrowed for the session and given back', async () => { - const h = harness(); +test('both stock viewer click actions are held off for the session and given back', async () => { + const stockClick = () => 'viewer picks and selects'; + const stockDouble = () => 'viewer tracks'; + const h = harness({ + beforeInput(viewer) { + viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK); + viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK); + }, + }); try { - const stockClick = () => 'viewer picks and selects'; - const stockDouble = () => 'viewer tracks'; - h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK); - h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK); - + assert.equal(h.input.stockEnabled('select'), true); h.act('toggleDraw'); assert.equal( - h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK), - undefined, + h.input.stockEnabled('select'), + false, "the viewer's own selecting click must not run while drawing", ); - assert.equal( - h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK), - undefined, - ); + assert.equal(h.input.stockEnabled('dblclickFollow'), false); h.act('toggleDraw'); + assert.equal(h.input.stockEnabled('select'), true); + assert.equal(h.input.stockEnabled('dblclickFollow'), true); + h.input.destroy(); assert.equal( h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK), stockClick, - 'the exact function is restored, not a replacement', + 'the exact function goes back to the viewer, not a replacement', ); assert.equal( h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK), diff --git a/src/app/scene.js b/src/app/scene.js index b1405108b..f50c543ae 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -1,6 +1,5 @@ import { createApplicationOperations } from './operations.js'; import * as Cesium from 'cesium'; -import { installTrackpadPinchZoom } from '../render/cesium/ctrlWheelZoom.js'; import { cesiumRenderer, cesiumViewerOf, @@ -87,7 +86,8 @@ export async function createApplicationScene({ // Deferred after the viewer, so it is released before the viewer goes. const input = inputFor(viewer); defer(() => input.destroy()); - defer(installTrackpadPinchZoom(viewer)); + // Ctrl+wheel and trackpad pinches zoom the globe, not the page. + renderer.input.ctrlWheelZoom = true; registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index a09dd6887..9f381db96 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -348,7 +348,12 @@ function cameraHarness(layer = alprCamerasLayer) { type, modifier, ) { - if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) click = fn; + // The unmodified click: the input port binds one per modifier. + if ( + type === Cesium.ScreenSpaceEventType.LEFT_CLICK && + modifier === undefined + ) + click = fn; return setInput.call(this, fn, type, modifier); }; try { diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index 21f93b9a4..05ea476da 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -187,15 +187,18 @@ test('consumer build includes only infrastructure code and resolves assets under const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js')); assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [ 'cameraInput.js', + 'ctrlWheelZoom.js', 'dispatcher.js', 'heights.js', 'infrastructure.js', 'infrastructureData.js', 'infrastructureOverlayEntry.js', + 'input.js', 'inputOwnership.js', 'localGeojsonCore.js', 'localGeojsonLod.js', 'pickRegistry.js', + 'router.js', 'scenePick.js', 'sensorPick.js', ]); diff --git a/src/data/militaryInstallations.test.mjs b/src/data/militaryInstallations.test.mjs index 1b4065a0b..89ae37cc8 100644 --- a/src/data/militaryInstallations.test.mjs +++ b/src/data/militaryInstallations.test.mjs @@ -345,7 +345,8 @@ async function runInstallationLoad({ const originalSetInputAction = Cesium.ScreenSpaceEventHandler.prototype.setInputAction; Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (action, type, modifier) { - if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) clickAction = action; + // The unmodified click: the input port binds one per modifier. + if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK && modifier === undefined) clickAction = action; return originalSetInputAction.call(this, action, type, modifier); }; try { militaryInstallationsLayer.init(viewer); } diff --git a/src/input/dispatcher.js b/src/input/dispatcher.js index 0c3c2df82..3db77e559 100644 --- a/src/input/dispatcher.js +++ b/src/input/dispatcher.js @@ -1,8 +1,11 @@ /** * @module input/dispatcher - * @description The one place pointer input over the globe comes in: a single - * `ScreenSpaceEventHandler` per scene, a click picked once and handed to - * every subscriber, and the camera-input and stock-click holds tools take. + * @description The one place the application's layers and tools take + * pointer input over the globe: the scene's input port + * (src/render/cesium/input.js), a click picked once and handed to every + * subscriber, and the camera-input and stock-click holds tools take. Each + * layer family moves onto the renderer's own `input` port as it is ported; + * until then it subscribes here, with the events and shapes it always had. * * Layers used to bind a handler each and pick the same click again, so one * click with the usual layers on cost about 25 `scene.pick` passes (each one @@ -33,6 +36,7 @@ import * as Cesium from 'cesium'; import { isLeaseCurrent, isPointerFree } from '../data/inputOwnership.js'; import { acquireCameraInput } from '../render/cesium/cameraInput.js'; +import { cesiumInputOf, createCesiumInput } from '../render/cesium/input.js'; import { findPickOwner, pickContact, @@ -50,12 +54,23 @@ export { releasePointer, } from '../data/inputOwnership.js'; +/** The handler event types the scene's input port binds, for test doubles. */ +export const HANDLER_EVENT_TYPES = Cesium.ScreenSpaceEventType; + /** A drill is run at least this deep and sliced, so callers asking for * different depths on one click share one pass. */ const DRILL_DEPTH = 16; const POINTER_TYPES = ['down', 'move', 'up', 'click', 'dblclick']; +/** + * The events Cesium's unmodified left-button actions raised, which is all + * the dispatcher's subscribers have ever seen: the port also has the other + * buttons and the modified presses. + */ +const unmodified = ({ modifiers: m }) => !m.shift && !m.ctrl && !m.alt; +const unmodifiedLeft = (ev) => ev.button === 0 && unmodified(ev); + /** A tool that re-arms takes a new lease each time: it may pass a getter. */ const leaseOf = (entry) => typeof entry.lease === 'function' ? entry.lease() : entry.lease; @@ -106,6 +121,9 @@ const dispatchers = new WeakMap(); * off; returns this hold's release. * @property {() => () => void} suppressStockClicks Take the viewer's stock * select and double-click-track actions off; returns this hold's release. + * @property {(name: 'select'|'dblclickFollow') => boolean} stockEnabled + * Whether the viewer's stock select or follow runs now: it does unless a + * hold is out (or the viewer has none). * @property {() => number} subscriberCount Live click and pointer * subscriptions, for leak checks. * @property {() => void} destroy @@ -114,8 +132,9 @@ const dispatchers = new WeakMap(); /** * The scene's dispatcher, made on first use. * @param {object} viewer A Cesium viewer (or a test double with `scene`). - * @param {{createHandler?: (canvas: object) => object, eventTypes?: object, now?: () => number}} [options] - * Used only by the call that makes it. + * @param {{createHandler?: (canvas: object) => object, now?: () => number, document?: object|null}} [options] + * For the input port it makes when the scene has none (tests pass a fake + * handler); used only by the call that makes it. * @returns {InputDispatcher} */ export function inputFor(viewer, options = {}) { @@ -128,16 +147,23 @@ export function inputFor(viewer, options = {}) { return dispatcher; } -function createDispatcher( - viewer, - { - createHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), - eventTypes = Cesium.ScreenSpaceEventType, - now = () => performance.now(), - }, -) { +function createDispatcher(viewer, { createHandler, now, document } = {}) { const scene = viewer.scene; - const handler = createHandler(scene.canvas); + // The renderer's port when the application made one; a viewer of the + // caller's own gets one for as long as this dispatcher lives. + const shared = cesiumInputOf(scene); + const cesiumInput = + shared ?? + createCesiumInput(viewer, { + report: (error) => console.error('Pointer input failed', error), + // The viewer is its owner's: its stock actions stay on its handler. + takeStock: false, + ...(createHandler ? { createHandler } : {}), + ...(now ? { now } : {}), + ...(document !== undefined ? { document } : {}), + }); + const { port } = cesiumInput; + const cameraInput = acquireCameraInput(scene); /** @type {Array<{fn: Function}>} */ const clickSubscribers = []; /** @type {Record>} */ @@ -145,42 +171,8 @@ function createDispatcher( POINTER_TYPES.map((type) => [type, []]), ); const stockHolds = new Set(); - let stockSaved = null; let destroyed = false; - let pressActive = false; - let pressStartedAt = 0; - let previousPosition = null; - let travelPx = 0; - let completedGesture = null; - - const appendTravel = (position) => { - if ( - !pressActive || - !Number.isFinite(position?.x) || - !Number.isFinite(position?.y) - ) - return; - if (previousPosition) { - travelPx += Math.hypot( - position.x - previousPosition.x, - position.y - previousPosition.y, - ); - } - previousPosition = { x: position.x, y: position.y }; - }; - - const finishPress = (position) => { - if (!pressActive) return; - appendTravel(position); - completedGesture = { - travelPx, - durationMs: Math.max(0, now() - pressStartedAt), - }; - pressActive = false; - previousPosition = null; - }; - const run = (list, free, arg) => { for (const entry of [...list]) { if (entry.lease ? !isLeaseCurrent(leaseOf(entry)) : !free) continue; @@ -192,43 +184,45 @@ function createDispatcher( } }; - handler.setInputAction((event) => { - pressActive = true; - pressStartedAt = now(); - previousPosition = null; - travelPx = 0; - completedGesture = null; - appendTravel(event?.position); - run(pointerSubscribers.down, isPointerFree(), event); - }, eventTypes.LEFT_DOWN); - - handler.setInputAction((event) => { - appendTravel(event?.endPosition ?? event?.position); - run(pointerSubscribers.move, true, event); - }, eventTypes.MOUSE_MOVE); - - handler.setInputAction((event) => { - finishPress(event?.position); - run(pointerSubscribers.up, true, event); - }, eventTypes.LEFT_UP); - - handler.setInputAction((click) => { - if (pressActive) finishPress(click?.position); - const gesture = completedGesture || { travelPx: 0, durationMs: 0 }; - completedGesture = null; - const free = isPointerFree(); - const event = createClickEvent(viewer, click, gesture); - run(pointerSubscribers.click, free, event); - if (free) run(clickSubscribers, true, event); - }, eventTypes.LEFT_CLICK); - - handler.setInputAction((event) => { - run(pointerSubscribers.dblclick, isPointerFree(), event); - }, eventTypes.LEFT_DOUBLE_CLICK); - - // Once the handler is bound, so a setup that throws holds no share of the - // scene's camera input. - const cameraInput = acquireCameraInput(scene); + const cartesian = (p) => new Cesium.Cartesian2(p.x, p.y); + // Each type is one port listener; the pointer's owner is read once per + // event, before any subscriber runs. + const offPort = [ + port.on('down', (ev) => { + if (!unmodifiedLeft(ev)) return; + run(pointerSubscribers.down, isPointerFree(), { + position: cartesian(ev.position), + }); + }), + port.on('move', (ev) => { + if (!unmodified(ev)) return; + run(pointerSubscribers.move, true, { + startPosition: cartesian(ev.position), + endPosition: cartesian(ev.endPosition ?? ev.position), + }); + }), + port.on('up', (ev) => { + if (!unmodifiedLeft(ev)) return; + run(pointerSubscribers.up, true, { position: cartesian(ev.position) }); + }), + port.on('click', (ev) => { + if (!unmodifiedLeft(ev)) return; + const free = isPointerFree(); + const event = createClickEvent( + viewer, + { position: cartesian(ev.position) }, + ev.gesture ?? { travelPx: 0, durationMs: 0 }, + ); + run(pointerSubscribers.click, free, event); + if (free) run(clickSubscribers, true, event); + }), + port.on('dblclick', (ev) => { + if (!unmodifiedLeft(ev)) return; + run(pointerSubscribers.dblclick, isPointerFree(), { + position: cartesian(ev.position), + }); + }), + ]; const subscribe = (list, entry) => { if (destroyed) return () => {}; @@ -239,22 +233,6 @@ function createDispatcher( }; }; - const stockHandler = () => viewer.screenSpaceEventHandler; - const stockTypes = () => [ - eventTypes.LEFT_CLICK, - eventTypes.LEFT_DOUBLE_CLICK, - ]; - - const restoreStock = () => { - const stock = stockHandler(); - if (stockSaved && stock && !stock.isDestroyed?.()) { - for (const [type, action] of stockSaved) { - if (action) stock.setInputAction(action, type); - } - } - stockSaved = null; - }; - return { onClick(fn) { return subscribe(clickSubscribers, { fn, lease: null }); @@ -273,26 +251,22 @@ function createDispatcher( suppressStockClicks() { if (destroyed) return () => {}; - const hold = {}; - if (stockHolds.size === 0) { - const stock = stockHandler(); - if (stock) { - // Cesium's own select and double-click-track run on the viewer's - // handler and never see the pointer lease. - stockSaved = stockTypes().map((type) => [ - type, - stock.getInputAction(type), - ]); - for (const type of stockTypes()) stock.removeInputAction(type); - } - } - stockHolds.add(hold); - return () => { - if (!stockHolds.delete(hold) || stockHolds.size > 0) return; - restoreStock(); + // Cesium's own select and double-click-track never see the pointer + // lease; the port runs them, and holds them off here. + const releases = [ + port.behaviours.select.suppress(), + port.behaviours.dblclickFollow.suppress(), + ]; + const hold = () => { + if (!stockHolds.delete(hold)) return; + for (const release of releases) release(); }; + stockHolds.add(hold); + return hold; }, + stockEnabled: (name) => port.behaviours[name]?.enabled ?? false, + subscriberCount() { return POINTER_TYPES.reduce( (sum, type) => sum + pointerSubscribers[type].length, @@ -306,12 +280,10 @@ function createDispatcher( dispatchers.delete(scene); clickSubscribers.length = 0; for (const type of POINTER_TYPES) pointerSubscribers[type].length = 0; + for (const off of offPort) off(); + for (const hold of [...stockHolds]) hold(); cameraInput.release(); - if (stockHolds.size) { - stockHolds.clear(); - restoreStock(); - } - if (!handler.isDestroyed?.()) handler.destroy?.(); + if (!shared) cesiumInput.dispose(); }, }; } diff --git a/src/input/testDoubles.mjs b/src/input/testDoubles.mjs index 106ecaf50..78baca3fd 100644 --- a/src/input/testDoubles.mjs +++ b/src/input/testDoubles.mjs @@ -3,30 +3,30 @@ * `ScreenSpaceEventHandler`, and a dispatcher made on it ahead of the code * under test, so that code's `inputFor(viewer)` gets the fake. */ -import { inputFor } from './dispatcher.js'; +import { HANDLER_EVENT_TYPES, inputFor } from './dispatcher.js'; -export const FAKE_EVENT_TYPES = Object.freeze({ - LEFT_DOWN: 'down', - MOUSE_MOVE: 'move', - LEFT_UP: 'up', - LEFT_CLICK: 'click', - LEFT_DOUBLE_CLICK: 'dblclick', -}); +/** Cesium's own event types, the ones the input port binds. */ +export const FAKE_EVENT_TYPES = HANDLER_EVENT_TYPES; export class FakeScreenSpaceEventHandler { constructor() { this.actions = new Map(); this.destroyed = false; } - setInputAction(fn, type) { - this.actions.set(type, fn); + /** One action per type and modifier, as Cesium keeps them. */ + static key(type, modifier) { + return modifier === undefined ? type : `${type}+${modifier}`; } - getInputAction(type) { - return this.actions.get(type); + setInputAction(fn, type, modifier) { + this.actions.set(FakeScreenSpaceEventHandler.key(type, modifier), fn); } - removeInputAction(type) { - this.actions.delete(type); + getInputAction(type, modifier) { + return this.actions.get(FakeScreenSpaceEventHandler.key(type, modifier)); } + removeInputAction(type, modifier) { + this.actions.delete(FakeScreenSpaceEventHandler.key(type, modifier)); + } + /** Raise an action as Cesium does for a press with no modifier held. */ fire(type, event) { this.actions.get(type)?.(event); } @@ -41,18 +41,16 @@ export class FakeScreenSpaceEventHandler { /** * Make `viewer`'s dispatcher on a fake handler and return ways to drive it. * @param {object} viewer - * @param {{now?: () => number, eventTypes?: object}} [options] `eventTypes` - * defaults to {@link FAKE_EVENT_TYPES}; pass Cesium's own when the code under - * test reads the viewer's stock actions by Cesium's type values. + * @param {{now?: () => number}} [options] */ -export function fakeInput(viewer, { now, eventTypes = FAKE_EVENT_TYPES } = {}) { +export function fakeInput(viewer, { now } = {}) { const handler = new FakeScreenSpaceEventHandler(); const input = inputFor(viewer, { createHandler: () => handler, - eventTypes, + document: null, ...(now ? { now } : {}), }); - const T = eventTypes; + const T = FAKE_EVENT_TYPES; return { input, handler, @@ -71,6 +69,8 @@ export function fakeInput(viewer, { now, eventTypes = FAKE_EVENT_TYPES } = {}) { }, dblclick: (position = { x: 10, y: 10 }) => handler.fire(T.LEFT_DOUBLE_CLICK, { position }), + /** Whether the viewer's stock `select` or `dblclickFollow` runs now. */ + stockEnabled: (name) => input.stockEnabled(name), destroy: () => input.destroy(), }; } diff --git a/src/layers/recentImagery/testDoubles.mjs b/src/layers/recentImagery/testDoubles.mjs index 7d4b338be..6186b51a9 100644 --- a/src/layers/recentImagery/testDoubles.mjs +++ b/src/layers/recentImagery/testDoubles.mjs @@ -133,7 +133,7 @@ export function boxToolFakes() { }, }; - const input = fakeInput(viewer, { eventTypes: EVENT_TYPES }); + const input = fakeInput(viewer); const pickWorld = (_viewer, nx, ny) => ny * 500 < 40 ? null : { lon: -97.9 + nx * 0.2, lat: 30.4 - ny * 0.2 }; @@ -162,6 +162,7 @@ export function boxToolFakes() { documentRef, pickWorld, input: input.input, + stockEnabled: input.stockEnabled, bound, fire, key, diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index 17d164ec9..c99019ca6 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -18,13 +18,15 @@ */ import { createApplicationViewer } from '../../app/viewer.js'; import { CONTRACT_VERSION } from '../contract/index.js'; +import { createCesiumInput } from './input.js'; import { cesiumCapabilities, createCesiumPorts } from './ports.js'; /** * The members a transitional renderer has today. * @typedef {Pick} + * | 'camera' | 'query' | 'input' | 'capture' | 'on' | 'resize' + * | 'destroy'>} * TransitionalRenderer */ @@ -83,12 +85,18 @@ export function createCesiumRendererPlugin({ document.body.appendChild(creditContainer); let viewer; let ports; + let input; let capabilities; try { viewer = createViewer({ container, creditContainer }); capabilities = new ReadonlySet(cesiumCapabilities(viewer.scene)); ports = createCesiumPorts(viewer, { capabilities }); + input = createCesiumInput(viewer, { + query: ports.query, + report: ports.report, + }); } catch (error) { + ports?.dispose(); if (viewer && !viewer.isDestroyed()) viewer.destroy(); creditContainer.remove(); throw error; @@ -102,6 +110,7 @@ export function createCesiumRendererPlugin({ draw: ports.draw, camera: ports.camera, query: ports.query, + input: input.port, capture: ports.capture, on: ports.on, resize() { @@ -109,6 +118,7 @@ export function createCesiumRendererPlugin({ }, destroy() { try { + input.dispose(); ports.dispose(); if (!viewer.isDestroyed()) viewer.destroy(); } finally { diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index f6da249cb..b502ff947 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -27,6 +27,9 @@ function fakeDocument() { createElement: make, body: { appendChild: (el) => children.push(el) }, getElementById: () => null, + // The input port watches the document's pointer events. + addEventListener() {}, + removeEventListener() {}, }; } @@ -53,7 +56,12 @@ function fakeViewer(log, { failDestroy = false } = {}) { preRender: fakeEvent(listeners), postRender: fakeEvent(listeners), }, - canvas: { tag: 'canvas' }, + canvas: { + tag: 'canvas', + // Cesium's input handler binds the canvas's events. + addEventListener() {}, + removeEventListener() {}, + }, resize: () => log.push('resize'), isDestroyed: () => destroyed, destroy() { @@ -346,7 +354,6 @@ const EXPECTED_MISSING = [ 'renderer.credits', 'renderer.effects', 'renderer.globe', - 'renderer.input', 'renderer.quality', 'renderer.readPixels', 'renderer.releaseUnused', diff --git a/src/render/cesium/input.js b/src/render/cesium/input.js index dcaf265c1..5daf19737 100644 --- a/src/render/cesium/input.js +++ b/src/render/cesium/input.js @@ -91,17 +91,33 @@ function pointerTypeOf(dom, otherwise = 'mouse') { return otherwise; } +/** @type {WeakMap} scene -> its input port */ +const inputs = new WeakMap(); + /** + * The input port made for a scene, while it lives: the application's + * dispatcher (src/input/dispatcher.js) delivers through it. + * @param {object} scene + * @returns {ReturnType | null} + */ +export const cesiumInputOf = (scene) => inputs.get(scene) ?? null; + +/** + * One per scene: a second would find the stock actions already taken. * @param {object} widget A Cesium `CesiumWidget` or `Viewer`. * @param {object} options - * @param {object} options.query The renderer's query port: clicks pick - * through it. + * @param {object} [options.query] The renderer's query port: clicks pick + * through it. Without one, clicks carry no picks, for a caller that makes + * its own (the application's dispatcher, over a viewer of its own). * @param {(error: unknown) => void} options.report Where a listener's or a * `grab`'s error goes. * @param {(canvas: object) => object} [options.createHandler] * @param {() => number} [options.now] Event time, ms. * @param {object} [options.document] Where the DOM events are watched. * @param {typeof installTrackpadPinchZoom} [options.ctrlWheelZoom] + * @param {boolean} [options.takeStock] False leaves the viewer's stock + * actions on its own handler, taken off only while suppressed, for a + * viewer that is not this renderer's: its owner may still rebind them. * @returns {{port: object, feed: Function, listenerCount: () => number, dispose: () => void}} * `feed(type, x, y, extra)` delivers one event as the handler would, for * the conformance harness. @@ -115,31 +131,52 @@ export function createCesiumInput( now = () => performance.now(), document: dom = globalThis.document, ctrlWheelZoom = installTrackpadPinchZoom, + takeStock = true, }, ) { const scene = widget.scene; + if (inputs.has(scene)) + throw new TypeError('This scene already has an input port'); const canvas = scene.canvas; let disposed = false; - // The viewer's stock actions, now this port's to run. + // The viewer's stock actions, now this port's to run; or, not taking + // them, borrowed from its handler while suppressed. const stockHandler = widget.screenSpaceEventHandler; const stock = {}; + const has = {}; for (const [name, type] of Object.entries(STOCK)) { const action = stockHandler?.getInputAction?.(type); - if (!action) continue; + has[name] = Boolean(action); + if (!action || !takeStock) continue; stock[name] = action; stockHandler.removeInputAction(type); } + /** Actions borrowed while suppressed, to give back by identity. */ + const borrowed = {}; + const giveBack = (name) => { + const action = borrowed[name]; + delete borrowed[name]; + if (!action || !stockHandler || stockHandler.isDestroyed?.()) return; + if (!stockHandler.getInputAction(STOCK[name])) + stockHandler.setInputAction(action, STOCK[name]); + }; + function borrowStock(name, enabled) { + if (takeStock || !stockHandler) return; + if (enabled) return giveBack(name); + const action = stockHandler.getInputAction?.(STOCK[name]); + if (!action) return; + borrowed[name] = action; + stockHandler.removeInputAction(STOCK[name]); + } const cameraInput = acquireCameraInput(scene); let uninstallCtrlWheel = null; const router = createInputRouter({ report, suspendCamera: (reason) => cameraInput.suspend(reason), - supported: { - select: Boolean(stock.select), - dblclickFollow: Boolean(stock.dblclickFollow), - }, + supported: { select: has.select, dblclickFollow: has.dblclickFollow }, + onBehaviour: borrowStock, onCtrlWheelZoom(on) { if (disposed) return; if (on && !uninstallCtrlWheel) uninstallCtrlWheel = ctrlWheelZoom(widget); @@ -179,6 +216,13 @@ export function createCesiumInput( const finished = new Map(); /** The button of the last event, when it was an up. */ let lastUp = null; + /** The type of the last event delivered. */ + let lastType = null; + /** + * The stock behaviours as they stood at the last left down: a press that + * began suppressed stays so, even when its tool lets go on the up. + */ + let stockAtDown = null; const heldButton = () => { for (const button of [0, 1, 2]) if (pressing.has(button)) return button; return 0; @@ -277,6 +321,18 @@ export function createCesiumInput( track(type, at, end ?? at, button); const justUp = lastUp; lastUp = type === 'up' ? button : null; + const previous = lastType; + lastType = type; + if (type === 'down' && button === 0) + stockAtDown = { + select: behaviours.select.enabled, + dblclickFollow: behaviours.dblclickFollow.enabled, + }; + // A click straight after its up, and a double-click straight after + // that click, belong to the last press. + const ofPress = + (type === 'click' && justUp === 0) || + (type === 'dblclick' && previous === 'click'); const ev = { type, position: at, @@ -300,15 +356,22 @@ export function createCesiumInput( : undefined; finished.delete(button); ev.gesture = extra.gesture ?? press ?? { travelPx: 0, durationMs: 0 }; - withPicks(ev); + if (query) withPicks(ev); } // The viewer's own select and follow run first, as they did on its // handler. - router.dispatch(ev, (to) => runStock(ev, to)); + router.dispatch(ev, (to) => runStock(ev, to, ofPress ? stockAtDown : null)); + // A hold a down subscriber takes covers its press too. + if (type === 'down' && button === 0 && stockAtDown) + stockAtDown = { + select: stockAtDown.select && behaviours.select.enabled, + dblclickFollow: + stockAtDown.dblclickFollow && behaviours.dblclickFollow.enabled, + }; } /** The viewer's own select or follow, as it would have run. */ - function runStock(ev, to) { + function runStock(ev, to, atDown) { const name = ev.type === 'click' ? 'select' @@ -319,6 +382,7 @@ export function createCesiumInput( // Cesium bound them with no modifier: a modified click never ran them. const { shift, ctrl, alt } = ev.modifiers; if (shift || ctrl || alt || !behaviours[name].enabled) return; + if (atDown && !atDown[name]) return; try { stock[name]({ position: new Cesium.Cartesian2(ev.position.x, ev.position.y), @@ -352,7 +416,10 @@ export function createCesiumInput( deliver('dblclick', e.position, { button: 0, modifier }), ); bind(T.MOUSE_MOVE, (e, modifier) => - deliver('move', e.startPosition, { endPosition: e.endPosition, modifier }), + deliver('move', e.startPosition ?? e.endPosition, { + endPosition: e.endPosition, + modifier, + }), ); // Pinch is at the two fingers' midpoint where it started; its delta is // the scale since the last event. @@ -390,7 +457,7 @@ export function createCesiumInput( }; canvas?.addEventListener?.('wheel', onWheel, { passive: true }); - return { + const self = { port: router.port, feed(type, x, y, extra = {}) { deliver(type, { x, y }, extra, null); @@ -400,6 +467,7 @@ export function createCesiumInput( if (disposed) return; disposed = true; router.dispose(); + if (inputs.get(scene) === self) inputs.delete(scene); canvas?.removeEventListener?.('wheel', onWheel, { passive: true }); for (const type of DOM_EVENTS) dom?.removeEventListener?.(type, record, { capture: true }); @@ -407,10 +475,13 @@ export function createCesiumInput( uninstallCtrlWheel?.(); uninstallCtrlWheel = null; cameraInput.release(); + for (const name of Object.keys(borrowed)) giveBack(name); if (stockHandler && !stockHandler.isDestroyed?.()) for (const [name, type] of Object.entries(STOCK)) if (stock[name] && !stockHandler.getInputAction(type)) stockHandler.setInputAction(stock[name], type); }, }; + inputs.set(scene, self); + return self; } diff --git a/src/render/cesium/input.test.mjs b/src/render/cesium/input.test.mjs index 499d5917d..dffb468dc 100644 --- a/src/render/cesium/input.test.mjs +++ b/src/render/cesium/input.test.mjs @@ -48,7 +48,7 @@ function fakeDocument() { }; } -function setup({ stock = true, document = null } = {}) { +function setup({ stock = true, document = null, takeStock = true } = {}) { const stockHandler = fakeHandler(); const selected = []; const followed = []; @@ -86,6 +86,7 @@ function setup({ stock = true, document = null } = {}) { createHandler: () => handler, now: () => clock, document, + takeStock, ctrlWheelZoom: () => { wheelZoom.push('on'); return () => wheelZoom.push('off'); @@ -374,3 +375,53 @@ test('a double-tap is a touch; a click on Cesium’s timer ends no press', () => s.handler.fire(T.RIGHT_CLICK, { position: s.at(5, 6) }); assert.deepEqual(heard, ['touch', { travelPx: 0, durationMs: 0 }]); }); + +test('a press that began suppressed never selects, even if its tool lets go on the up', () => { + const s = setup(); + let release = s.port.behaviours.select.suppress(); + s.port.on('up', () => { + release?.(); + release = null; + }); + s.press(5, 6); + assert.equal(s.selected.length, 0, 'the click of the suppressed press'); + s.press(5, 6); + assert.equal(s.selected.length, 1, 'the next press selects'); +}); + +test('a hold a down subscriber takes keeps its press from selecting', () => { + const s = setup(); + let release = null; + s.port.on('down', () => { + release = s.port.behaviours.select.suppress(); + }); + s.port.on('up', () => release?.()); + s.press(5, 6); + assert.equal(s.selected.length, 0); +}); + +test('not taking the stock, they stay on the viewer’s handler but while held', () => { + const s = setup({ takeStock: false }); + const original = s.stockHandler.getInputAction(T.LEFT_CLICK); + assert.equal(typeof original, 'function', 'left where the viewer put them'); + s.press(5, 6); + assert.equal(s.selected.length, 0, 'the port never runs them itself'); + const release = s.port.behaviours.select.suppress(); + assert.equal(s.stockHandler.getInputAction(T.LEFT_CLICK), undefined); + assert.equal( + typeof s.stockHandler.getInputAction(T.LEFT_DOUBLE_CLICK), + 'function', + 'follow is its own', + ); + release(); + assert.equal(s.stockHandler.getInputAction(T.LEFT_CLICK), original); + s.port.behaviours.dblclickFollow.suppress(); + const follow = s.stockHandler.getInputAction(T.LEFT_DOUBLE_CLICK); + assert.equal(follow, undefined); + s.input.dispose(); + assert.equal( + typeof s.stockHandler.getInputAction(T.LEFT_DOUBLE_CLICK), + 'function', + 'dispose gives a held one back', + ); +}); diff --git a/src/ui/imageryBoxTool.test.mjs b/src/ui/imageryBoxTool.test.mjs index f6a2b6cb9..a5cb5378a 100644 --- a/src/ui/imageryBoxTool.test.mjs +++ b/src/ui/imageryBoxTool.test.mjs @@ -29,8 +29,14 @@ function fixture(options = {}) { ...options, }); const camera = fakes.viewer.scene.screenSpaceCameraController; + // The stock action as it runs now: the input port holds them, so it is + // the original while not suppressed, and none while it is. const stockAction = (type) => - fakes.viewer.screenSpaceEventHandler.getInputAction(fakes.types[type]); + fakes.stockEnabled(type === 'LEFT_CLICK' ? 'select' : 'dblclickFollow') + ? type === 'LEFT_CLICK' + ? fakes.originalClick + : fakes.originalDouble + : undefined; const preview = () => fakes.viewer.dataSources.sources[0]?.entities.values.every((e) => e.show); return {