From 6c7035f617a0bf1279608b8ab8e6a1a6ab07db40 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 21:45:24 -0700 Subject: [PATCH] feat(ui): render the CCTV calibration readout and its pose editor from the store presentCctvCalibration() joins the panel view: ADJUST, SAVE CAL, RESET CAL and the seven pose chips render from the gev-cctv store. The inline editor is store state: starting an edit publishes its field, starting value and finish(), and the CctvCalValue chip renders the number input. The draft is set once on mount so a refresh while typing keeps it; each edit gets its own input. Commit and cancel rules, the captured base pose and the camera guards stay with CctvControls. formField.js had no other user and is removed. --- docs/CODE-BOUNDARIES.md | 10 +- docs/CURRENT-STATE.md | 4 +- docs/VUE-MIGRATION.md | 6 +- scripts/package-boundaries.json | 4 +- src/ui/cctv/CctvCalValue.vue | 65 +++++++++++ src/ui/cctvCalibration.js | 124 +++++++++------------ src/ui/cctvControls.js | 4 - src/ui/cctvControls.test.mjs | 96 ---------------- src/ui/cctvPanel.test.mjs | 177 ++++++++++++++++++++++++++++++ src/ui/cctvPresentation.js | 3 +- src/ui/formField.js | 36 ------ src/ui/stores/cctv.js | 23 +++- src/ui/views/panels/CctvPanel.vue | 25 +++-- 13 files changed, 337 insertions(+), 240 deletions(-) create mode 100644 src/ui/cctv/CctvCalValue.vue delete mode 100644 src/ui/formField.js diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index c4f786bf5..93f71d234 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -741,13 +741,13 @@ DOM and platform middleware. ### Native form fields -`src/ui/formField.js` constructs native numeric inputs without listeners or -application state. Static selects and number inputs use the same `gev-field` -surface, with explicit compact and inline densities. Feature owners retain +Native numeric inputs are rendered by the views that own them; the CCTV pose +chip renders its inline input in `src/ui/cctv/CctvCalValue.vue`. Static +selects and number inputs use the same `gev-field` surface, with explicit +compact and inline densities. Feature owners retain option identity/order, bounds, parsing, draft values, input/change timing and teardown. CCTV effective-pose conversion and tuner/device operations remain -feature responsibilities. A Vue replacement can reuse this contract and CSS; -it replaces DOM construction, rather than adding a second state owner. +feature responsibilities. The shared stylesheet owns field surfaces and disabled opacity; feature and theme palettes use `--gev-field-background`, `--gev-field-border-color`, diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ce2c9e635..df360c0c1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6444,8 +6444,8 @@ when their footprints touch or contain one another, never across a seam toleranc - Scene and CCTV selects share the default `gev-field` surface. HUD layout and draw colour retain compact density. Radio filter, tuner gain and FM frequency use the same base while retaining their feature-specific skins and layout. -- The CCTV inline number editor uses `createNumberInput` with its existing - precision and inline density. Enter/blur commit, Escape cancellation, frozen +- The CCTV inline number editor is rendered by its pose chip component with + its existing precision and inline density. Enter/blur commit, Escape cancellation, frozen camera base, camera-switch guards and disposal remain with CCTV controls. - Options, values, native keyboard/typeahead, disabled attributes and browser validity remain native. Frequency bounds, tuner commit timing and calibration diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index ab0051cd2..fd73a285e 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -87,9 +87,9 @@ They contain no application state. Street level (done: `StreetLevelPanel` renders the `gev-street-level` store that `StreetLevelControls` publishes to; the controls keep their listeners, EXPAND, focus and resizing), CCTV (in progress: `CctvPanel` - renders the buttons, CAL chip and meta line from the `gev-cctv` store; - the picker, frame, source badge, calibration readout and summary are - still written by `CctvControls`), Layers, Display, Context and Radio, the command dock, + renders the buttons, CAL chip, meta line and calibration readout, with + its inline pose editor, from the `gev-cctv` store; the picker, frame, + source badge and summary are still written by `CctvControls`), Layers, Display, Context and Radio, the command dock, the phone shell, then Cockpit. Layers that write into the right rail directly gain a slot instead. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 8f3d960ac..e8bfd3bad 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -47,6 +47,7 @@ "src/i18n/locales/zh-Hant.json", "src/ui/views/panels/StreetLevelPanel.vue", "src/ui/views/panels/CctvPanel.vue", + "src/ui/cctv/CctvCalValue.vue", "src/ui/stores/cctv.js", "src/ui/stores/streetLevel.js", "src/ui/rail/RailChips.vue" @@ -638,7 +639,6 @@ "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", - "src/ui/formField.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", "src/ui/cctvVideo.js", @@ -816,7 +816,6 @@ "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", - "src/ui/formField.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", "src/ui/cctvVideo.js", @@ -2511,7 +2510,6 @@ "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", - "src/ui/formField.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", "src/ui/cctvVideo.js", diff --git a/src/ui/cctv/CctvCalValue.vue b/src/ui/cctv/CctvCalValue.vue new file mode 100644 index 000000000..ef9fcd2c6 --- /dev/null +++ b/src/ui/cctv/CctvCalValue.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/ui/cctvCalibration.js b/src/ui/cctvCalibration.js index 9e51540c5..fad47894f 100644 --- a/src/ui/cctvCalibration.js +++ b/src/ui/cctvCalibration.js @@ -1,5 +1,4 @@ import { formatNumber, t, msg } from '../i18n/index.js'; -import { createNumberInput } from './formField.js'; /** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */ const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; @@ -111,100 +110,79 @@ export function _resetCctvCalibration() { export function _beginCctvCalValueEdit(chip) { if (this.destroyed) return; this._calibrationEdit?.(false); - const field = CCTV_CAL_FIELDS[chip.dataset.calField]; + const fieldId = chip.dataset.calField; + const field = CCTV_CAL_FIELDS[fieldId]; const activeCamera = this._cctvState?.activeCamera; if (!field || !activeCamera?.basePose) return; const cameraId = this._activeCctvCameraId(); const basePose = { ...activeCamera.basePose }; - const editListeners = new AbortController(); - const startValue = field.get(activeCamera); - const input = createNumberInput({ - className: 'gev-field--inline cctv-cal-input', - step: field.decimals > 0 ? '0.1' : '1', - value: Number(startValue).toFixed(field.decimals), - ariaLabel: t(field.label), - }); - chip.textContent = `${t(field.label)} `; - chip.appendChild(input); - input.focus(); - input.select(); let finished = false; - const finish = (commit) => { + /** + * End the edit: commit converts the typed value to a calibration offset + * against the pose captured when it began; anything else just closes it. + */ + const finish = (commit, typedText = '') => { if (finished) return; finished = true; - editListeners.abort(); this._calibrationEdit = null; - const typed = parseFloat(input.value); - input.remove(); + this.store.setCalEdit(null); + const typed = parseFloat(typedText); if ( commit && !this.destroyed && Number.isFinite(typed) && this._cctvState?.enabled && this.actions.isEnabled() && - cameraId === this._activeCctvCameraId() + cameraId && + cameraId === this._activeCctvCameraId() && + this.actions.setParams ) { - if (cameraId && this.actions.setParams) { - this.actions.setParams( - { - selectedCameraId: cameraId, - calibration: { cameraId, patch: field.toPatch(typed, basePose) }, - }, - { origin: 'user' }, - ); - return; // re-render restores the chip text from fresh state - } + this.actions.setParams( + { + selectedCameraId: cameraId, + calibration: { cameraId, patch: field.toPatch(typed, basePose) }, + }, + { origin: 'user' }, + ); } - this._syncCctvCalReadout( - !!this._cctvState?.enabled, - this._cctvState?.activeCamera || null, - ); }; this._calibrationEdit = finish; - input.addEventListener( - 'keydown', - (event) => { - if (event.key === 'Enter') finish(true); - else if (event.key === 'Escape') { - event.preventDefault(); - finish(false); - } - event.stopPropagation(); - }, - { signal: editListeners.signal }, - ); - input.addEventListener('blur', () => finish(true), { - signal: editListeners.signal, - }); - input.addEventListener('click', (event) => event.stopPropagation(), { - signal: editListeners.signal, + // The readout renders a number input in this chip; Enter or blur commits + // and Escape cancels through `finish`. + this.store.setCalEdit({ + field: fieldId, + label: `${t(field.label)} `, + ariaLabel: t(field.label), + step: field.decimals > 0 ? '0.1' : '1', + value: Number(field.get(activeCamera)).toFixed(field.decimals), + finish, }); } -export function _syncCctvCalReadout(enabled, activeCamera) { +/** + * The calibration block for a camera state: ADJUST, the pose chips and + * whether calibration is possible. Chips show the effective pose. + * @param {object|null} state Camera layer UI state. + * @param {boolean} enabled Whether the layer is on. + */ +export function presentCctvCalibration(state, enabled) { + const activeCamera = state?.activeCamera || null; const canCalibrate = !!enabled && !!activeCamera; - if (this._cctvAdjustBtn) { - const adjustOn = !!this._cctvState?.calibrationMode; - this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate); - this._cctvAdjustBtn.textContent = adjustOn ? t('ADJUST ON') : t('ADJUST'); - this._cctvAdjustBtn.disabled = !canCalibrate; - } - if (this._cctvCalReadout) { - for (const chip of this._cctvCalReadout.querySelectorAll( - '.cctv-cal-value', - )) { - if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber - const field = CCTV_CAL_FIELDS[chip.dataset.calField]; - if (!field) continue; - const value = canCalibrate ? field.get(activeCamera) : null; - chip.textContent = Number.isFinite(value) - ? calValueText(field, value) - : `${t(field.label)} --`; - chip.disabled = !canCalibrate; - } - } - for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) { - if (el) el.disabled = !canCalibrate; + const adjustOn = !!state?.calibrationMode; + const values = {}; + for (const [id, field] of Object.entries(CCTV_CAL_FIELDS)) { + const value = canCalibrate ? field.get(activeCamera) : null; + values[id] = Number.isFinite(value) + ? calValueText(field, value) + : `${t(field.label)} --`; } + return { + disabled: !canCalibrate, + adjust: { + active: adjustOn && canCalibrate, + text: adjustOn ? t('ADJUST ON') : t('ADJUST'), + }, + values, + }; } diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js index 718287a10..83d225e89 100644 --- a/src/ui/cctvControls.js +++ b/src/ui/cctvControls.js @@ -8,7 +8,6 @@ import { _activeCctvCameraId, _resetCctvCalibration, _beginCctvCalValueEdit, - _syncCctvCalReadout, } from './cctvCalibration.js'; import { _calBadgeLabel, @@ -103,9 +102,6 @@ export class CctvControls { _beginCctvCalValueEdit(...args) { return _beginCctvCalValueEdit.call(this, ...args); } - _syncCctvCalReadout(...args) { - return _syncCctvCalReadout.call(this, ...args); - } _calBadgeLabel(...args) { return _calBadgeLabel.call(this, ...args); } diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 6a47ff91f..7a13aa1c0 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -95,102 +95,6 @@ test('destroy invalidates image callbacks and releases each subscription once', assert.equal(requests.length, 1); }); -function calibrationFixture(t) { - const { controls } = fixture(t); - const prior = globalThis.document; - const inputs = []; - globalThis.document = { - createElement() { - const input = new EventTarget(); - Object.assign(input, { - focus() {}, - select() {}, - setAttribute(name, value) { - this[name] = value; - }, - remove() { - this.parent.input = null; - }, - }); - inputs.push(input); - return input; - }, - }; - t.after(() => { - globalThis.document = prior; - }); - const chip = { - dataset: { calField: 'heading' }, - textContent: '', - input: null, - appendChild(input) { - this.input = input; - input.parent = this; - }, - querySelector() { - return this.input; - }, - }; - const patches = []; - controls.actions.setParams = (params) => patches.push(params); - controls.actions.setPanelCollapsed = () => {}; - controls._cctvCalReadout = { querySelectorAll: () => [chip] }; - controls._cctvState = { - enabled: true, - activeCameraId: 'a', - activeCamera: { id: 'a', headingDeg: 30, basePose: { headingDeg: 20 } }, - }; - const key = (name) => - Object.assign(new Event('keydown', { cancelable: true }), { key: name }); - return { controls, chip, inputs, patches, key }; -} - -test('calibration commits against the captured camera base and releases its editor', (t) => { - const { controls, chip, inputs, patches, key } = calibrationFixture(t); - controls._beginCctvCalValueEdit(chip); - inputs[0].value = '100'; - controls._cctvState.activeCamera.basePose.headingDeg = 60; - inputs[0].dispatchEvent(key('Enter')); - inputs[0].dispatchEvent(new Event('blur')); - assert.deepEqual(patches, [ - { - selectedCameraId: 'a', - calibration: { cameraId: 'a', patch: { headingDeg: 80 } }, - }, - ]); - assert.equal(controls._calibrationEdit, null); -}); - -test('a camera switch cancels calibration before old blur can change the new camera', (t) => { - const { controls, chip, inputs, patches } = calibrationFixture(t); - controls._beginCctvCalValueEdit(chip); - inputs[0].value = '100'; - controls._renderCctvState({ - enabled: true, - activeCameraId: 'b', - activeCamera: { id: 'b', headingDeg: 200, basePose: { headingDeg: 190 } }, - }); - inputs[0].dispatchEvent(new Event('blur')); - assert.deepEqual(patches, []); - assert.equal(chip.input, null); - assert.equal(chip.textContent, 'HDG 200.0°'); -}); - -test('Escape claims calibration cancellation and disposal prevents late commits', (t) => { - const { controls, chip, inputs, patches, key } = calibrationFixture(t); - controls._beginCctvCalValueEdit(chip); - inputs[0].value = '100'; - const escape = key('Escape'); - inputs[0].dispatchEvent(escape); - inputs[0].dispatchEvent(new Event('blur')); - assert.equal(escape.defaultPrevented, true); - controls._beginCctvCalValueEdit(chip); - inputs[1].value = '120'; - controls.destroy(); - inputs[1].dispatchEvent(new Event('blur')); - assert.deepEqual(patches, []); -}); - test('disposing during camera enable prevents the delayed focus and future clicks', async () => { const button = new EventTarget(); let resolveEnable; diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs index 0d9dcd670..5902b4291 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -1,5 +1,6 @@ import '../testSupport/registerDom.mjs'; import assert from 'node:assert/strict'; +import { assertSameNode } from '../testSupport/domAssert.mjs'; import test from 'node:test'; import { createPinia } from 'pinia'; import { createApp, nextTick } from 'vue'; @@ -36,6 +37,10 @@ function panel(t) { ...elements, _cctvPanel: document.getElementById('cctv-panel'), _cctvSourceBadge: document.getElementById('cctv-source-badge'), + _cctvCalReadout: document.getElementById('cctv-cal-readout'), + _cctvAdjustBtn: document.getElementById('cctv-adjust-btn'), + _cctvCalibSaveBtn: document.getElementById('cctv-calib-save-btn'), + _cctvCalibResetBtn: document.getElementById('cctv-calib-reset-btn'), }, cctv: {}, actions: { @@ -213,3 +218,175 @@ test('losing the active camera resets the CAL chip', async (t) => { 'Enable CCTV to load camera intersections', ); }); + +/** A panel with an active camera, calibration patches recorded. */ +async function calibrating(t) { + const page = panel(t); + const patches = []; + page.controls.actions.setParams = (params) => patches.push(params); + page.controls._renderCctvState({ + enabled: true, + count: 1, + cameraIndex: [camera], + activeCameraId: 'a', + activeCamera: { ...camera, headingDeg: 30, basePose: { headingDeg: 20 } }, + }); + await nextTick(); + const chip = document.querySelector('[data-cal-field="heading"]'); + const begin = async () => { + chip.click(); + await nextTick(); + return chip.querySelector('input'); + }; + const key = (name) => + new KeyboardEvent('keydown', { key: name, cancelable: true }); + return { ...page, patches, chip, begin, key }; +} + +test('a pose chip types against the camera base captured when it opened', async (t) => { + const { controls, patches, chip, begin, key } = await calibrating(t); + assert.equal(chip.textContent, 'HDG 30.0°'); + const input = await begin(); + assert.ok(input, 'the chip holds an input'); + assert.ok(document.activeElement === input, 'the input has focus'); + assert.equal(chip.textContent, 'HDG '); + assert.equal(input.value, '30.0'); + assert.equal(input.step, '0.1'); + assert.equal(input.getAttribute('aria-label'), 'HDG'); + input.value = '100'; + // A refresh while typing keeps the draft and the base the edit opened with. + controls._renderCctvState({ + ...controls._cctvState, + activeCamera: { + ...controls._cctvState.activeCamera, + basePose: { headingDeg: 60 }, + }, + }); + await nextTick(); + assertSameNode(chip.querySelector('input'), input, 'the input survives'); + assert.equal(input.value, '100'); + input.dispatchEvent(key('Enter')); + input.dispatchEvent(new Event('blur')); + assert.deepEqual(patches, [ + { + selectedCameraId: 'a', + calibration: { cameraId: 'a', patch: { headingDeg: 80 } }, + }, + ]); + assert.equal(controls._calibrationEdit, null); + await nextTick(); + assert.ok(!chip.querySelector('input'), 'the input is gone'); + assert.equal(chip.textContent, 'HDG 30.0°'); +}); + +test('a camera switch cancels calibration before old blur can change the new camera', async (t) => { + const { controls, patches, chip, begin } = await calibrating(t); + const input = await begin(); + input.value = '100'; + controls._renderCctvState({ + enabled: true, + count: 1, + cameraIndex: [camera], + activeCameraId: 'b', + activeCamera: { + ...camera, + id: 'b', + headingDeg: 200, + basePose: { headingDeg: 190 }, + }, + }); + input.dispatchEvent(new Event('blur')); + await nextTick(); + assert.deepEqual(patches, []); + assert.ok(!chip.querySelector('input'), 'the input is gone'); + assert.equal(chip.textContent, 'HDG 200.0°'); +}); + +test('Escape claims calibration cancellation and disposal prevents late commits', async (t) => { + const { controls, patches, chip, begin, key } = await calibrating(t); + let input = await begin(); + input.value = '100'; + const escape = key('Escape'); + input.dispatchEvent(escape); + input.dispatchEvent(new Event('blur')); + assert.equal(escape.defaultPrevented, true); + await nextTick(); + assert.equal(chip.textContent, 'HDG 30.0°'); + input = await begin(); + input.value = '120'; + controls.destroy(); + input.dispatchEvent(new Event('blur')); + assert.deepEqual(patches, []); + await nextTick(); + assert.ok(!chip.querySelector('input'), 'disposal closes the editor'); +}); + +test('ADJUST, SAVE and RESET follow whether a camera can be calibrated', async (t) => { + const { controls, byId } = panel(t); + controls._renderCctvState({ enabled: true, count: 1, cameraIndex: [] }); + await nextTick(); + for (const id of [ + 'cctv-adjust-btn', + 'cctv-calib-save-btn', + 'cctv-calib-reset-btn', + ]) + assert.equal(byId(id).disabled, true, id); + const chip = document.querySelector('[data-cal-field="north"]'); + assert.equal(chip.textContent, 'ΔN --'); + assert.equal(chip.disabled, true); + controls._renderCctvState({ + enabled: true, + count: 1, + cameraIndex: [camera], + activeCameraId: 'a', + activeCamera: camera, + calibrationMode: true, + }); + await nextTick(); + assert.equal(byId('cctv-adjust-btn').textContent, 'ADJUST ON'); + assert.equal(byId('cctv-adjust-btn').classList.contains('active'), true); + assert.equal(byId('cctv-calib-save-btn').disabled, false); + assert.equal(chip.textContent, 'ΔN 0.0m'); + assert.equal(chip.disabled, false); +}); + +test('blur alone commits the draft; an empty draft changes nothing', async (t) => { + const { patches, chip, begin } = await calibrating(t); + let input = await begin(); + input.value = ''; + input.dispatchEvent(new Event('blur')); + assert.deepEqual(patches, []); + await nextTick(); + assert.equal(chip.textContent, 'HDG 30.0°'); + input = await begin(); + input.value = '25'; + input.dispatchEvent(new Event('blur')); + assert.deepEqual(patches, [ + { + selectedCameraId: 'a', + calibration: { cameraId: 'a', patch: { headingDeg: 5 } }, + }, + ]); +}); + +test('keys typed into a pose chip stay in it', async (t) => { + const { begin } = await calibrating(t); + const input = await begin(); + let reached = 0; + document.body.addEventListener('keydown', () => reached++); + for (const key of ['5', 'Escape']) + input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + assert.equal(reached, 0); +}); + +test('a second edit of the same chip gets a fresh input', async (t) => { + const { controls, chip, begin } = await calibrating(t); + const first = await begin(); + first.value = '99'; + controls._beginCctvCalValueEdit(chip); + controls._beginCctvCalValueEdit(chip); + await nextTick(); + const second = chip.querySelector('input'); + assert.ok(second && second !== first, 'a new input'); + assert.equal(second.value, '30.0'); +}); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 55273d2d4..378a79c8f 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -2,6 +2,7 @@ import { t, tn } from '../i18n/index.js'; import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js'; import { createCctvVideoSurface } from './cctvVideo.js'; +import { presentCctvCalibration } from './cctvCalibration.js'; export function _calBadgeLabel(badge) { switch (badge) { case 'calibrated': @@ -78,6 +79,7 @@ export function presentCctvPanel(state, enabled) { calBadge: dirty ? 'edited' : badge || '', }, meta: metaText(state, enabled, activeCamera, catalogCount), + calibration: presentCctvCalibration(state, enabled), }; } @@ -245,7 +247,6 @@ export function _renderCctvState(state) { } this.store.publish(presentCctvPanel(state, enabled)); - this._syncCctvCalReadout(enabled, activeCamera); const liveIntent = enabled && !!activeCamera?.isVideo; if (this._cctvVideo) { diff --git a/src/ui/formField.js b/src/ui/formField.js deleted file mode 100644 index 8b2127b4d..000000000 --- a/src/ui/formField.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Build a native numeric field without listeners, parsing or commit policy. - * Owners retain drafts, validation, input/change timing and disposal. Static - * selects and number inputs share the gev-field surface in their templates; - * options remain owned by each feature (no replacement popup or keyboard code). - * @param {object} options - * @param {Document} [options.documentRef] - * @param {string} [options.className] Additional density/layout hooks. - * @param {number|string} [options.min] - * @param {number|string} [options.max] - * @param {number|string} [options.step] - * @param {number|string} [options.value] - * @param {boolean} [options.disabled] - * @param {string} [options.ariaLabel] Already translated accessible name. - * @returns {HTMLInputElement} - */ -export function createNumberInput({ - documentRef = globalThis.document, - className = '', - min, - max, - step, - value, - disabled = false, - ariaLabel, -} = {}) { - const input = documentRef.createElement('input'); - input.type = 'number'; - input.className = `gev-field${className ? ` ${className}` : ''}`; - for (const [key, setting] of Object.entries({ min, max, step, value })) { - if (setting !== undefined) input[key] = setting; - } - input.disabled = disabled; - if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel); - return input; -} diff --git a/src/ui/stores/cctv.js b/src/ui/stores/cctv.js index d4f4e1f41..74c3ac6a4 100644 --- a/src/ui/stores/cctv.js +++ b/src/ui/stores/cctv.js @@ -2,19 +2,32 @@ import { defineStore } from 'pinia'; import { markRaw, shallowRef } from 'vue'; /** - * The CCTV panel's buttons, CAL chip and meta line as its view renders them. - * CctvControls presents them (`presentCctvPanel()`) and publishes here; it - * keeps its listeners on the buttons and paints the picker, frame, source - * badge, calibration readout and summary itself. + * The CCTV panel's buttons, CAL chip, meta line and calibration readout as + * its view renders them. CctvControls presents them (`presentCctvPanel()`) + * and publishes here; it keeps its listeners on the buttons and paints the + * picker, frame, source badge and summary itself. */ export const useCctvStore = defineStore('gev-cctv', () => { /** The presented panel; null until the controls first render. */ const view = shallowRef(null); + /** + * The pose value being typed: `{field, label, ariaLabel, step, value, + * finish(commit, typedText)}`, or null. + */ + const calEdit = shallowRef(null); + /** @param {object} next A fresh presentation; every publish re-renders. */ function publish(next) { view.value = markRaw(next); } - return { view, publish }; + let editCount = 0; + + /** @param {object|null} edit Each edit gets its own `id`, and input. */ + function setCalEdit(edit) { + calEdit.value = edit ? markRaw({ ...edit, id: ++editCount }) : null; + } + + return { view, calEdit, publish, setCalEdit }; }); diff --git a/src/ui/views/panels/CctvPanel.vue b/src/ui/views/panels/CctvPanel.vue index 102c55ac1..e46a24611 100644 --- a/src/ui/views/panels/CctvPanel.vue +++ b/src/ui/views/panels/CctvPanel.vue @@ -1,12 +1,13 @@ @@ -48,20 +49,20 @@ const { view } = storeToRefs(useCctvStore());
CALIBRATION
- +
- - - - - - - + + + + + + +
- - + +
SCENE SUMMARY