diff --git a/CHANGELOG.md b/CHANGELOG.md index 0125b1f7c..825e0e74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,9 @@ - `./ui/display`'s `bindDisplayControls` is gone: the Display panel's controls and the dock's style buttons call their owner through the display store's `connect`/`act`, and its allocation and 3D model choices are the - kit's `UiSegmented`. `syncScopeControls` (`./ui/display`) and + kit's `UiSegmented`. `initDrawTool` takes `connect(actions)`, which returns + their disconnect, and no longer listens on the Draw controls; its view + publishes `color` and `label` too, and the shape choice is a `UiSegmented`. `syncScopeControls` (`./ui/display`) and `shouldHideCollapsedRightPanels` (`./ui/layout`) are deprecated: the Display view and the rail now own what they did. `./ui/input` imports Vue (samehkhamis). diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index fde88d562..3a444ed11 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -132,7 +132,9 @@ a feature supplying only its accent. the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the `gev-display` store, which VisualSettings publishes to, and the 3D aircraft group, the Draw controls and the imported file list, which - AircraftDisplay, the draw tool and the file import publish beside it; the + AircraftDisplay, the draw tool and the file import publish beside it; + the controls call DisplayBindings and the draw tool through the store's + `connect`/`act`, the newest owner of an action answering it; the file import restores keyboard focus once the list has rendered; the style parameter rows render through Vue behind `createStyleParameters()`, as a render function so the portable `./ui/input` entry needs no diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 7cd1fa895..4ba731522 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -30,7 +30,6 @@ * at finish, deliberately and in one place — see `finishSpec` in drawMode.js. */ import * as Cesium from 'cesium'; -import { createSegmentedControl } from '../ui/segmentedControl.js'; import { pickWorldFromScreen } from './annotationResolver.js'; import { claimPointer, @@ -65,26 +64,31 @@ const PREVIEW = { }; /** - * Wire the Draw control. Returns a handle with a `destroy()` the application + * Run the Draw control. Returns a handle with a `destroy()` the application * lifetime owns, plus the console/test seam (`window.__gevDrawTool`). - * The tool keeps its listeners on the controls and hands what they show - * (`{ active, shape, hint }`) to `publish`, which the view renders. - * @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, publish?: Function}} deps + * The tool hands what the controls show (`{ active, shape, color, label, + * hint }`) to `publish`, which the view renders, and answers the controls + * through the actions it gives `connect`, which returns their disconnect. + * @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, publish?: Function, connect?: Function}} deps * @returns {{destroy: Function}|null} */ -export function initDrawTool({ viewer, annotations, publish = () => {} }) { - const toggle = document.getElementById('draw-toggle'); - const modeRow = document.getElementById('draw-mode-row'); - const labelInput = document.getElementById('draw-label-input'); - const colorSelect = document.getElementById('draw-color-select'); - const clearButton = document.getElementById('draw-clear'); - if (!viewer || !annotations || !toggle) return null; +export function initDrawTool({ + viewer, + annotations, + publish = () => {}, + connect = () => () => {}, +}) { + // A page without the Draw controls (the Display controls view omitted) has + // no draw tool. + if (!viewer || !annotations || !document.getElementById('draw-toggle')) + return null; let active = false; let destroyed = false; let session = null; let shape = 'area'; let color = 'primary'; + let label = ''; let hint = ''; let handler = null; let lease = null; @@ -96,11 +100,10 @@ export function initDrawTool({ viewer, annotations, publish = () => {} }) { // one of those must not write its outcome over the newer state. let generation = 0; const previewEntities = []; - // Every DOM listener this tool adds, so teardown is one loop rather than a - // list that can fall out of step with the bindings below. + // Every DOM listener this tool adds (the session's keydown), so teardown is + // one loop rather than a list that can fall out of step with the bindings. const domListeners = []; const listen = (target, type, listener, options) => { - if (!target) return; target.addEventListener(type, listener, options); domListeners.push([target, type, listener, options]); }; @@ -125,7 +128,7 @@ export function initDrawTool({ viewer, annotations, publish = () => {} }) { () => null, ); - const present = () => publish({ active, shape, hint }); + const present = () => publish({ active, shape, color, label, hint }); const setHint = (text) => { hint = text; present(); @@ -241,14 +244,15 @@ export function initDrawTool({ viewer, annotations, publish = () => {} }) { if (reason !== 'too-few') setHint(drawHint(session)); return null; } - const spec = finishSpec(session, { label: labelInput?.value || '', color }); + const spec = finishSpec(session, { label, color }); if (!spec) return null; // Start a fresh session BEFORE awaiting, so a second Enter or the second // half of a double-click cannot submit the same shape twice. session = createDrawSession(shape); cursor = null; syncPreview(); - if (labelInput) labelInput.value = ''; + label = ''; + present(); const attempt = generation; try { const result = await annotations.annotate([spec], { @@ -286,9 +290,11 @@ export function initDrawTool({ viewer, annotations, publish = () => {} }) { }; // ---- keys: only while drawing, never while typing in another field ---- + // The label field is this tool's own: typing there still drives drawing. + const isLabelField = (target) => target?.id === 'draw-label-input'; const typingElsewhere = (event) => { const t = event.target; - if (!t || t === labelInput) return false; + if (!t || isLabelField(t)) return false; return ( t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable ); @@ -301,7 +307,7 @@ export function initDrawTool({ viewer, annotations, publish = () => {} }) { */ const enterBelongsToDrawing = (event) => { const t = event.target; - if (!t || t === labelInput) return true; + if (!t || isLabelField(t)) return true; if ( typeof t.closest === 'function' && t.closest('button, a, select, [role="button"], [role="radio"]') @@ -331,7 +337,7 @@ export function initDrawTool({ viewer, annotations, publish = () => {} }) { else setActive(false); return; } - if (event.key === 'Backspace' && event.target !== labelInput) { + if (event.key === 'Backspace' && !isLabelField(event.target)) { if (removeLastVertex(session)) { event.preventDefault(); syncPreview(); @@ -419,8 +425,7 @@ export function initDrawTool({ viewer, annotations, publish = () => {} }) { ); savedDoubleClick = null; } - // Drop just the keydown listener bound alongside this handler; the control - // listeners below outlive a session and come off in destroy(). + // Drop the keydown listener bound alongside this handler. for (let i = domListeners.length - 1; i >= 0; i -= 1) { const [target, type, listener, options] = domListeners[i]; if (listener !== onKey) continue; @@ -429,35 +434,30 @@ export function initDrawTool({ viewer, annotations, publish = () => {} }) { } } - listen(toggle, 'click', () => setActive(!active)); - const shapeButtons = [ - ...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []), - ]; - createSegmentedControl({ - buttons: shapeButtons, - valueOf: (button) => button.dataset.shape, - listen, - onChange: (value) => { - shape = normalizeShape(value); - present(); - if (active) { - generation += 1; - session = createDrawSession(shape); - cursor = null; - syncPreview(); - } - }, - }); - listen(colorSelect, 'change', () => { - color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary'; - syncPreview(); - }); - listen(clearButton, 'click', clearAll); - listen(labelInput, 'keydown', (event) => { - if (event.key === 'Enter' && session?.vertices?.length) { - event.preventDefault(); - void finish(); + /** Draw `next` from now on; an open shape starts over in it. */ + function chooseShape(next) { + shape = normalizeShape(next); + present(); + if (active) { + generation += 1; + session = createDrawSession(shape); + cursor = null; + syncPreview(); } + } + const disconnect = connect({ + toggleDraw: () => setActive(!active), + setDrawShape: chooseShape, + setDrawColor: (value) => { + color = COLORS.includes(value) ? value : 'primary'; + present(); + syncPreview(); + }, + setDrawLabel: (text) => { + label = String(text ?? ''); + present(); + }, + clearDraw: clearAll, }); setHint(drawHint(null)); @@ -472,12 +472,7 @@ export function initDrawTool({ viewer, annotations, publish = () => {} }) { return session; }, setActive, - setShape(next) { - const btn = modeRow?.querySelector( - `.pp-mode-btn[data-shape="${normalizeShape(next)}"]`, - ); - btn?.click(); - }, + setShape: chooseShape, /** Test seam: add a vertex from lon/lat as if it had been clicked. */ addVertex(lon, lat, height = 0) { if (!session) return false; @@ -527,6 +522,7 @@ export function initDrawTool({ viewer, annotations, publish = () => {} }) { if (active) setActive(false); destroyed = true; unregisterUnsavedWork(); + disconnect(); releaseSceneHandler(); releasePointer(lease); lease = null; diff --git a/src/annotations/drawTool.test.mjs b/src/annotations/drawTool.test.mjs index f554f55f7..a866565b6 100644 --- a/src/annotations/drawTool.test.mjs +++ b/src/annotations/drawTool.test.mjs @@ -84,19 +84,25 @@ test('the Draw control markup carries the ids the tool binds to', () => { for (const shape of ['area', 'line', 'pin']) { assert.match( html, - new RegExp(`data-shape="${shape}"`), + new RegExp(`'data-shape': '${shape}'`), `shape button ${shape} missing`, ); } - // The rows and the hint render what the tool publishes; it binds the rest. - const bound = ids.filter( - (id) => id !== 'draw-label-row' && id !== 'draw-hint', - ); - for (const id of bound) { + // The rows and the hint render what the tool publishes; the controls + // call the actions the tool connects to the display store. + for (const action of [ + "act('toggleDraw')", + "act('setDrawShape', $event)", + "act('setDrawLabel', $event.target.value)", + "act('setDrawColor', $event.target.value)", + "act('clearDraw')", + ]) { + assert.ok(html.includes(action), `DisplayPanel must call ${action}`); + const name = /act\('([a-zA-Z]+)'/.exec(action)[1]; assert.match( tool(), - new RegExp(`getElementById\\('${id}'\\)`), - `drawTool must bind #${id}`, + new RegExp(`\\b${name}: `), + `drawTool must answer ${name}`, ); } }); diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index c63c9e9ee..db02febbc 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -47,7 +47,6 @@ class FakeElement { this.attributes = new Map(); this.textContent = ''; this.value = ''; - this.children = []; this.listeners = []; } setAttribute(name, value) { @@ -65,16 +64,6 @@ class FakeElement { ); if (at >= 0) this.listeners.splice(at, 1); } - querySelectorAll(selector) { - if (selector === '.pp-mode-btn[data-shape]') - return this.children.filter((child) => child.dataset.shape); - const shape = /\.pp-mode-btn\[data-shape="([a-z]+)"\]/.exec(selector)?.[1]; - if (shape) return this.children.filter((child) => child.dataset.shape === shape); - return []; - } - querySelector(selector) { - return this.querySelectorAll(selector)[0] ?? null; - } click() { return this.emit('click'); } @@ -108,18 +97,11 @@ function fakeDom() { byId.set(id, element); return element; }; + // The control whose presence means the page has Draw, a focused button + // whose Enter is its own, and the tool's label field. make('draw-toggle', 'BUTTON'); - const modeRow = make('draw-mode-row'); - for (const shape of ['area', 'line', 'pin']) { - const button = new FakeElement('BUTTON'); - button.dataset.shape = shape; - modeRow.children.push(button); - } - make('draw-label-row'); - make('draw-label-input', 'INPUT'); - make('draw-color-select', 'SELECT'); make('draw-clear', 'BUTTON'); - make('draw-hint', 'SPAN'); + make('draw-label-input', 'INPUT'); const body = new FakeElement('BODY'); const documentDouble = { @@ -219,10 +201,16 @@ function harness() { globalThis.document = documentDouble; globalThis.window = globalThis.window || {}; const shown = { view: null }; + // The controls' actions, as the display store holds them. + const controls = { actions: null }; const tool = initDrawTool({ viewer, annotations, publish: (view) => (shown.view = view), + connect: (actions) => { + controls.actions = actions; + return () => (controls.actions = null); + }, }); return { tool, @@ -232,6 +220,9 @@ function harness() { byId, documentDouble, element: (id) => byId.get(id), + controls, + /** A control's change, as the view sends it. */ + act: (name, ...args) => controls.actions?.[name]?.(...args), restore() { resetPointerOwnership(); if (previousDocument === undefined) delete globalThis.document; @@ -259,7 +250,7 @@ test('a lasting mark or a half-drawn shape makes the language switch ask first', marks = [{ id: 'b', ttlMs: null, expiring: false }]; assert.deepEqual(describeUnsavedWork(), ['drawings on the map']); marks = []; - h.element('draw-toggle').emit('click'); + h.act('toggleDraw'); h.tool.addVertex(-97.75, 30.26); assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress'); marks = [{ id: 'c', ttlMs: null, expiring: false }]; @@ -274,11 +265,11 @@ test('turning Draw on takes a lease and turning it off gives it back', async () const h = harness(); try { assert.equal(isPointerFree(), true); - h.element('draw-toggle').emit('click'); + h.act('toggleDraw'); assert.equal(pointerOwner(), 'draw', 'the session owns the pointer'); assert.equal(h.tool.active, true); - h.element('draw-toggle').emit('click'); + h.act('toggleDraw'); assert.equal(isPointerFree(), true, 'leaving draw mode frees the pointer'); assert.equal(h.tool.active, false); } finally { @@ -290,7 +281,7 @@ test('Draw refuses to start when something else holds the pointer', async () => const h = harness(); try { const other = claimPointer('directions'); - h.element('draw-toggle').emit('click'); + h.act('toggleDraw'); assert.equal(h.tool.active, false, 'it must not half-start'); assert.equal(pointerOwner(), 'directions', 'and must not steal the claim'); // Named by its tool, not by the internal owner id. @@ -301,7 +292,7 @@ test('Draw refuses to start when something else holds the pointer', async () => assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound'); releasePointer(other); - h.element('draw-toggle').emit('click'); + h.act('toggleDraw'); assert.equal(h.tool.active, true, 'and it starts once the pointer is free'); } finally { h.restore(); @@ -311,13 +302,13 @@ test('Draw refuses to start when something else holds the pointer', async () => test('a destroyed instance cannot free the pointer its replacement holds', async () => { const first = harness(); try { - first.element('draw-toggle').emit('click'); + first.act('toggleDraw'); assert.equal(pointerOwner(), 'draw'); // The replacement takes over before the old one finishes tearing down. first.tool.setActive(false); const second = harness(); try { - second.element('draw-toggle').emit('click'); + second.act('toggleDraw'); assert.equal(pointerOwner(), 'draw'); await first.tool.destroy(); assert.equal( @@ -342,7 +333,7 @@ test('both stock viewer click actions are borrowed for the session and given bac h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK); h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK); - h.element('draw-toggle').emit('click'); + h.act('toggleDraw'); assert.equal( h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK), undefined, @@ -353,7 +344,7 @@ test('both stock viewer click actions are borrowed for the session and given bac undefined, ); - h.element('draw-toggle').emit('click'); + h.act('toggleDraw'); assert.equal( h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK), stockClick, @@ -371,7 +362,7 @@ test('both stock viewer click actions are borrowed for the session and given bac test('Enter finishes from the canvas and the label field, never from a focused button', async () => { const h = harness(); try { - h.element('draw-toggle').emit('click'); + h.act('toggleDraw'); h.tool.setShape('area'); h.tool.addVertex(-97.75, 30.26); h.tool.addVertex(-97.74, 30.26); @@ -401,7 +392,7 @@ test('Enter finishes from the canvas and the label field, never from a focused b test('the live preview hangs at the height the click landed on', async () => { const h = harness(); try { - h.element('draw-toggle').emit('click'); + h.act('toggleDraw'); h.tool.setShape('line'); h.tool.addVertex(-97.75, 30.26, 187.5); h.tool.addVertex(-97.74, 30.26, 212.25); @@ -455,7 +446,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act try { const stockClick = () => 'viewer picks and selects'; h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK); - h.element('draw-toggle').emit('click'); + h.act('toggleDraw'); h.tool.addVertex(-97.75, 30.26); assert.equal(pointerOwner(), 'draw'); assert.ok(h.tool.diagnostics().domListeners > 0); @@ -469,7 +460,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act assert.equal(after.sceneHandler, false, 'the Cesium handler is gone'); assert.equal(after.domListeners, 0, 'every DOM listener came off'); assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown'); - assert.equal(h.element('draw-toggle').listeners.length, 0); + assert.equal(h.controls.actions, null, 'and the controls are disconnected'); assert.equal( h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK), stockClick, @@ -487,15 +478,99 @@ test('destroy releases the pointer, the handler, the listeners and the stock act test('Clear wipes the board and the shape in progress', async () => { const h = harness(); try { - h.element('draw-toggle').emit('click'); + h.act('toggleDraw'); h.tool.addVertex(-97.75, 30.26); h.tool.addVertex(-97.74, 30.26); assert.equal(h.tool.session.vertices.length, 2); - h.element('draw-clear').emit('click'); + h.act('clearDraw'); assert.equal(h.annotations.calls.clear, 1, 'placed marks are removed'); assert.equal(h.tool.session.vertices.length, 0, 'and so is the shape in progress'); } finally { h.restore(); } }); + +test('the controls choose the shape, colour and label the next shape is drawn with', async () => { + const h = harness(); + try { + h.act('toggleDraw'); + h.act('setDrawShape', 'line'); + h.act('setDrawColor', 'amber'); + h.act('setDrawLabel', 'Route A'); + assert.deepEqual( + { ...h.shown.view, hint: undefined }, + { active: true, shape: 'line', color: 'amber', label: 'Route A', hint: undefined }, + 'the view shows what the controls chose', + ); + h.act('setDrawColor', 'purple'); + assert.equal(h.shown.view.color, 'primary', 'an unknown colour falls back'); + h.act('setDrawColor', 'cyan'); + h.tool.addVertex(-97.75, 30.26); + // Choosing a shape again starts the open one over. + h.act('setDrawShape', 'line'); + assert.equal(h.tool.session.vertices.length, 0); + h.tool.addVertex(-97.75, 30.26); + h.tool.addVertex(-97.74, 30.26); + h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body }); + await Promise.resolve(); + await Promise.resolve(); + const spec = h.annotations.calls.annotate[0].specs[0]; + assert.equal(spec.type, 'route', 'a drawn line is a manual route'); + assert.equal(spec.color, 'cyan'); + assert.equal(spec.label, 'Route A'); + assert.equal(h.shown.view.label, '', 'a finished shape empties the label'); + } finally { + h.restore(); + } +}); + +test('keys typed in the label field: Enter finishes, Backspace edits, Escape cancels', async () => { + const h = harness(); + try { + const label = h.element('draw-label-input'); + const elsewhere = new FakeElement('INPUT', 'location-search'); + h.act('toggleDraw'); + h.tool.addVertex(-97.75, 30.26); + h.tool.addVertex(-97.74, 30.26); + h.documentDouble.emit('keydown', { key: 'Backspace', target: label }); + assert.equal(h.tool.session.vertices.length, 2, 'Backspace edits the label'); + h.documentDouble.emit('keydown', { key: 'Enter', target: elsewhere }); + await Promise.resolve(); + assert.equal(h.annotations.calls.annotate.length, 0, 'another field keeps its Enter'); + h.documentDouble.emit('keydown', { key: 'Escape', target: label }); + assert.equal(h.tool.session.vertices.length, 0, 'Escape drops the open shape'); + assert.equal(h.tool.active, true, 'and leaves Draw on'); + h.act('setDrawShape', 'line'); + h.tool.addVertex(-97.75, 30.26); + h.tool.addVertex(-97.74, 30.26); + h.documentDouble.emit('keydown', { key: 'Enter', target: label }); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(h.annotations.calls.annotate.length, 1, 'Enter in the label finishes'); + } finally { + h.restore(); + } +}); + +test('a page without the Draw controls gets no draw tool', () => { + resetPointerOwnership(); + const previousDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + try { + let connected = false; + const tool = initDrawTool({ + viewer: fakeViewer(), + annotations: fakeAnnotations(), + connect: () => { + connected = true; + return () => {}; + }, + }); + assert.equal(tool, null); + assert.equal(connected, false); + } finally { + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + } +}); diff --git a/src/app/tools.js b/src/app/tools.js index 943815a68..f1e05f74d 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -64,6 +64,10 @@ export function createApplicationTools({ viewer, annotations, publish: (view) => displayStore.publishDraw(view), + connect: (actions) => { + const token = displayStore.connect(actions); + return () => displayStore.detach(token); + }, }); defer(() => drawTool?.destroy()); // DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its diff --git a/src/ui/displayPanelActions.test.mjs b/src/ui/displayPanelActions.test.mjs index 7ea5e4ea3..214a840b3 100644 --- a/src/ui/displayPanelActions.test.mjs +++ b/src/ui/displayPanelActions.test.mjs @@ -97,6 +97,38 @@ test('buttons, sliders, the layout select and the Scope checkbox call their acti ]); }); +test('the Draw controls call the draw tool and show what it publishes', async (t) => { + const store = mount(t); + const { calls } = recorder(store); + $('draw-toggle').click(); + document.querySelector('[data-shape="pin"]').click(); + input($('draw-label-input'), 'Camp'); + input($('draw-color-select'), 'red', 'change'); + $('draw-clear').click(); + assert.deepEqual(calls, [ + ['toggleDraw'], + ['setDrawShape', 'pin'], + ['setDrawLabel', 'Camp'], + ['setDrawColor', 'red'], + ['clearDraw'], + ]); + store.publishDraw({ + active: true, + shape: 'line', + color: 'green', + label: '', + hint: 'Click to add points.', + }); + await nextTick(); + const line = document.querySelector('[data-shape="line"]'); + assert.equal(line.getAttribute('aria-checked'), 'true'); + assert.ok(line.classList.contains('active')); + assert.equal($('draw-color-select').value, 'green'); + assert.equal($('draw-label-input').value, '', 'a finished shape empties it'); + assert.equal($('draw-hint').textContent, 'Click to add points.'); + assert.equal($('draw-toggle').getAttribute('aria-pressed'), 'true'); +}); + test('style, allocation and model choices keep their ids and data attributes', async (t) => { const store = mount(t); const { calls } = recorder(store); @@ -171,3 +203,30 @@ test('DisplayBindings answers the controls from init until destroy', (t) => { $('bloom-toggle').click(); assert.deepEqual(calls, [['bloom', true]]); }); + +test('several owners answer their own actions, the newest first', (t) => { + const store = mount(t); + const calls = []; + const owner = (tag, names) => + store.connect( + Object.fromEntries( + names.map((name) => [name, () => calls.push([tag, name])]), + ), + ); + const display = owner('display', ['toggleBloom', 'setStyle']); + const draw = owner('draw', ['toggleDraw']); + const newer = owner('newer', ['toggleBloom']); + $('bloom-toggle').click(); + $('draw-toggle').click(); + store.detach(newer); + $('bloom-toggle').click(); + store.detach(draw); + $('draw-toggle').click(); + store.detach(display); + $('bloom-toggle').click(); + assert.deepEqual(calls, [ + ['newer', 'toggleBloom'], + ['draw', 'toggleDraw'], + ['display', 'toggleBloom'], + ]); +}); diff --git a/src/ui/rail/rangeValue.js b/src/ui/rail/rangeValue.js index 0ac3af74d..a04364379 100644 --- a/src/ui/rail/rangeValue.js +++ b/src/ui/rail/rangeValue.js @@ -1,6 +1,6 @@ /** - * `v-range-value`: a range input's (or select's) live value, written only - * when it differs. + * `v-range-value`: a range input's (or a select's or text field's) live + * value, written only when it differs. * A `:value` binding would also rewrite the value attribute on every patch; * the directive runs after the element's min, max and step are patched, so * the value is never clamped to a stale range. diff --git a/src/ui/stores/display.js b/src/ui/stores/display.js index 7f06bf4cb..bbe48eb97 100644 --- a/src/ui/stores/display.js +++ b/src/ui/stores/display.js @@ -5,9 +5,9 @@ import { markRaw, shallowRef } from 'vue'; * The Display panel as its view renders it. VisualSettings presents its * groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group, the * draw tool its controls and the file import its list, and each publishes - * here. The Display controls' changes reach their owner (DisplayBindings) - * through act(); the draw tool and the file import keep their listeners on - * their controls. VisualSettings also + * here. The controls' changes reach their owners (DisplayBindings and the + * draw tool) through act(); the file import keeps its listeners on its + * controls. VisualSettings also * publishes the active style, which the dock and the scene chrome show, and * the map source controls publish their chip row beside it. */ @@ -68,32 +68,42 @@ export const useDisplayStore = defineStore('gev-display', () => { mapSourceControls.value = controls && markRaw(controls); } - /** @type {{actions: object}|null} The Display controls' owner. */ - let owner = null; + /** + * The owners answering the panel's controls, oldest first: DisplayBindings + * the Display controls and the dock's style buttons, the draw tool its own. + * @type {Array<{actions: object}>} + */ + const owners = []; /** - * Answer the Display controls (and the dock's style buttons) with - * `actions`, replacing any earlier owner. + * Answer the actions named in `actions`; for those names this owner + * replaces any earlier one. * @param {Record} actions * @returns {object} Token for detach(). */ function connect(actions) { - owner = { actions }; - return owner; + const token = { actions }; + owners.push(token); + return token; } - /** Stop answering for the owner holding `token`; a later owner stays. */ + /** Stop answering for the owner holding `token`; the others stay. */ function detach(token) { - if (token === owner) owner = null; + const index = owners.indexOf(token); + if (index >= 0) owners.splice(index, 1); } /** - * A control's change: call the owner's action, if one is connected. + * A control's change: call the newest connected owner's action of that + * name, if any. * @param {string} name * @param {...unknown} args */ function act(name, ...args) { - owner?.actions[name]?.(...args); + for (let index = owners.length - 1; index >= 0; index -= 1) { + const action = owners[index].actions[name]; + if (action) return void action(...args); + } } /** The phone shell's tab bar; null until the shell first presents it. */ diff --git a/src/ui/views/panels/DisplayPanel.vue b/src/ui/views/panels/DisplayPanel.vue index f6e76e870..b5e82c85d 100644 --- a/src/ui/views/panels/DisplayPanel.vue +++ b/src/ui/views/panels/DisplayPanel.vue @@ -14,7 +14,7 @@ import { useDisplayStore } from '../../stores/display.js'; // them; until they do, the panel shows its initial markup. const display = useDisplayStore(); const { view, models3d, draw, geoImport } = storeToRefs(display); -// A control's change goes to the Display controls' owner (DisplayBindings). +// A control's change goes to its owner (DisplayBindings or the draw tool). const act = (name, ...args) => display.act(name, ...args); const integer = (event) => parseInt(event.target.value, 10); const pct = (value) => `${value}%`; @@ -34,17 +34,16 @@ const celestialTitle = computed(() => ); const models3dOn = computed(() => models3d.value?.pressed ?? true); const modelsMode = computed(() => models3d.value?.mode ?? 'proximity'); -// A segmented control's segment, selected when `current` holds its value. -const segment = (current) => (value) => ({ - class: { active: current.value === value }, - 'aria-checked': String(current.value === value), -}); const modelsOptions = [ { value: 'proximity', label: t('Proximity'), attrs: { id: 'models3d-mode-proximity', 'data-mode': 'proximity' } }, { value: 'all', label: t('All'), attrs: { id: 'models3d-mode-all', 'data-mode': 'all' } }, ]; const drawShape = computed(() => draw.value?.shape ?? 'area'); -const drawSegment = segment(drawShape); +const drawShapeOptions = [ + { value: 'area', label: t('Area'), attrs: { 'data-shape': 'area' } }, + { value: 'line', label: t('Line'), attrs: { 'data-shape': 'line' } }, + { value: 'pin', label: t('Pin'), attrs: { 'data-shape': 'pin' } }, +]; const allocationOptions = [ { value: 'ELASTIC', label: t('Elastic'), attrs: { id: 'detection-allocation-elastic', 'data-allocation': 'ELASTIC' } }, { value: 'WEIGHTED', label: t('Weighted'), attrs: { id: 'detection-allocation-weighted', 'data-allocation': 'WEIGHTED' } }, @@ -167,28 +166,24 @@ const geoListed = computed(() => geoRows.value.length > 0);
-
Shape -
- - - -
+
- - + - + {{ draw?.hint ?? '' }}