refactor(ui): extract application shortcuts and parameter controls

This commit is contained in:
Sameh Khamis committed 2026-09-12 20:38:16 -07:00
1 parent 5fa8db1686
commit 7a5f615d55
14 files changed
+469 -92

No files matched your search

+3 -3
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const parameters = readFileSync(new URL('./ui/styleParameters.js', import.meta.url), 'utf8');
// Focused markup guards; actual computed names are checked in Chromium.
// Native labels and hidden inputs must not be treated as missing aria-labels.
@@ -25,6 +25,6 @@ test('the first-run checkbox keeps its native visible label', () => {
});
test('generated style sliders use the visible parameter label as their name', () => {
assert.match(ui, /label\.textContent\s*=\s*uMeta\.label;/);
assert.match(ui, /slider\.setAttribute\(['"]aria-label['"],\s*uMeta\.label\)/);
assert.match(parameters, /label\.textContent\s*=\s*metadata\.label;/);
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*metadata\.label\)/);
});
+12 -2
View File
@@ -417,8 +417,8 @@ test('newer visual, map, and individual panel actions suppress only their owned
test('every explicit visual UI gesture claims restore authority before it mutates state', () => {
const initUi = sourceBlock(' _initUI() {', ' _initMapStackControl() {');
const gestureRoutes = [
["if (e.key.toLowerCase() === 'h')", "if (e.key.toLowerCase() === 'o')", 'this.hud.toggle()', 'HUD hotkey'],
["if (e.key.toLowerCase() === 'd')", "if (e.key.toLowerCase() === 'c')", 'cycleDetectionMode()', 'detection hotkey'],
['toggleHud: () => {', 'toggleOrbit: () =>', 'this.hud.toggle()', 'HUD hotkey'],
['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'],
['// Bloom toggle', '// Bloom intensity slider', 'this._setBloomEnabled(', 'bloom button'],
['// Bloom intensity slider', '// Sharpen toggle', 'this._setBloomIntensity(', 'bloom slider'],
['// Sharpen toggle', '// Scope mask', 'this._setSharpenEnabled(', 'sharpen button'],
@@ -695,3 +695,13 @@ test('destroy cancels only a still-owned share flight and ignores delayed comple
newerManager.destroy();
assert.equal(cancellations, 1, 'newer navigation must not be cancelled');
});
test('visual input listeners are revoked before asynchronous UI teardown', () => {
const disposal = uiSource.slice(uiSource.indexOf(' async dispose() {'));
const firstAwait = disposal.indexOf('await ');
assert.ok(firstAwait > 0);
const synchronous = disposal.slice(0, firstAwait);
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
});
+45 -84
View File
@@ -1,3 +1,4 @@
import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js';
import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js';
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
import * as Cesium from 'cesium';
@@ -3399,51 +3400,37 @@ export class StyleManager {
btn.addEventListener('click', () => this.setStyle(btn.dataset.style));
});
// Keyboard shortcuts: 1-7, H, Escape
this._globalKeydownHandler = (e) => {
// Ignore when interacting with a form control (except Escape). Global
// hotkeys ('1'-'7', 'h', 'o', 'v', 'd', 'c', 'f') otherwise fire while a
// <select> dropdown (e.g. HUD layout) is focused and its native
// type-ahead is in use, or while typing in a text field (M9).
const isFormControl = e.target?.matches?.('select, input, textarea')
|| e.target === this._locationSearch;
if (isFormControl && e.key !== 'Escape') return;
const keyMap = {
'1': 'normal', '2': 'retro', '3': 'surveillance',
'4': 'thermal', '5': 'anime', '6': 'noir',
'7': 'snow',
};
if (keyMap[e.key]) this.setStyle(keyMap[e.key]);
if (e.key === 'Escape') {
if (this._locationSearch.classList.contains('expanded')) {
this._locationSearch.classList.remove('expanded');
this._locationSearch.value = '';
this._locationSearch.blur();
}
}
if (e.key.toLowerCase() === 'h') {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.hud.toggle();
this._updateHudButtonState();
this._syncShareState();
}
if (e.key.toLowerCase() === 'o') this._toggleOrbit();
if (e.key.toLowerCase() === 'v') this.toggleCleanView();
if (e.key.toLowerCase() === 'f') {
document.getElementById('data-panel').classList.toggle('active');
}
if (e.key.toLowerCase() === 'd') {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._detectionUserOverridden = true;
cycleDetectionMode();
this._syncShareState();
}
if (e.key.toLowerCase() === 'c') {
this._toggleCctvEnabled();
}
};
document.addEventListener('keydown', this._globalKeydownHandler);
this._applicationShortcuts?.destroy();
this._applicationShortcuts = bindApplicationShortcuts({
documentRef: document,
searchInput: this._locationSearch,
actions: {
setStyle: (style) => this.setStyle(style),
dismissSearch: () => {
if (this._locationSearch.classList.contains('expanded')) {
this._locationSearch.classList.remove('expanded');
this._locationSearch.value = '';
this._locationSearch.blur();
}
},
toggleHud: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.hud.toggle();
this._updateHudButtonState();
this._syncShareState();
},
toggleOrbit: () => this._toggleOrbit(),
toggleCleanView: () => this.toggleCleanView(),
toggleLayers: () => document.getElementById('data-panel').classList.toggle('active'),
cycleDetection: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._detectionUserOverridden = true;
cycleDetectionMode();
this._syncShareState();
},
toggleCctv: () => this._toggleCctvEnabled(),
},
});
// Bloom toggle
this._bloomBtn.addEventListener('click', () => {
@@ -8507,7 +8494,8 @@ export class StyleManager {
* @returns {void}
*/
_updateSliderPanel(styleName, { reveal = false } = {}) {
this._sliderContainer.innerHTML = '';
this._styleParameters ||= createStyleParameters({ container: this._sliderContainer });
this._styleParameters.clear();
const shader = STYLES[styleName];
if (!shader || !shader.uniforms || styleName === 'normal') {
@@ -8516,44 +8504,19 @@ export class StyleManager {
return;
}
for (const [uName, uMeta] of Object.entries(shader.uniforms)) {
const row = document.createElement('div');
row.className = 'param-slider-row';
const label = document.createElement('span');
label.className = 'param-label';
label.textContent = uMeta.label;
const slider = document.createElement('input');
slider.type = 'range';
slider.className = 'param-slider';
slider.setAttribute('aria-label', uMeta.label);
slider.min = uMeta.min;
slider.max = uMeta.max;
slider.step = uMeta.max <= 1 ? '0.01' : '0.1';
slider.value = this.stages[styleName].uniforms[uName];
const valueDisplay = document.createElement('span');
valueDisplay.className = 'param-value';
valueDisplay.textContent = parseFloat(slider.value).toFixed(uMeta.max <= 1 ? 2 : 1);
slider.addEventListener('input', () => {
this._styleParameters.render({
uniforms: shader.uniforms,
readValue: (uName) => this.stages[styleName].uniforms[uName],
writeValue: (uName, val) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
const val = parseFloat(slider.value);
this.stages[styleName].uniforms[uName] = val;
valueDisplay.textContent = val.toFixed(uMeta.max <= 1 ? 2 : 1);
// Uniform writes don't auto-render under the idle governor —
// without this the slider visibly does nothing until the next
// camera move (browser finding). (perf wave 2)
},
onChange: () => {
// Uniform writes need an explicit render under the idle governor.
governorRequestRender('style-param-slider');
this._syncShareState();
});
row.appendChild(label);
row.appendChild(slider);
row.appendChild(valueDisplay);
this._sliderContainer.appendChild(row);
}
},
});
this._sliderPanel.classList.add('active');
this._scheduleRightPanelLayout();
@@ -9726,6 +9689,8 @@ export class StyleManager {
this._globalStatusNotice = null;
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
this._disposed = true;
this._applicationShortcuts?.destroy();
this._styleParameters?.destroy();
for (const control of this._panelDisclosureControls || []) control.destroy();
this._panelDisclosureControls = [];
this._hoverPanelControls?.forEach((control) => control.destroy());
@@ -9835,10 +9800,6 @@ export class StyleManager {
this._loadingVisibilityHandler = null;
}
this._stopLoadingFeedbackTicker();
if (this._globalKeydownHandler) {
document.removeEventListener('keydown', this._globalKeydownHandler);
this._globalKeydownHandler = null;
}
if (this._poiKeydownHandler) {
document.removeEventListener('keydown', this._poiKeydownHandler);
this._poiKeydownHandler = null;
+34
View File
@@ -0,0 +1,34 @@
const STYLE_KEYS = Object.freeze({
'1': 'normal', '2': 'retro', '3': 'surveillance',
'4': 'thermal', '5': 'anime', '6': 'noir', '7': 'snow',
});
/**
* Bind the application's existing bubbling keyboard shortcuts.
* Capture-phase surfaces keep first refusal. The caller owns each action and
* persistence; form controls keep native typing except for Escape.
* @param {object} options
* @param {Document} options.documentRef Keyboard event target.
* @param {HTMLElement} options.searchInput Additional editing target.
* @param {object} options.actions Existing application operations.
* @returns {{destroy: Function}} Synchronous, idempotent listener cleanup.
*/
export function bindApplicationShortcuts({ documentRef, searchInput, actions }) {
const onKeyDown = (event) => {
const isFormControl = event.target?.matches?.('select, input, textarea')
|| event.target === searchInput;
if (isFormControl && event.key !== 'Escape') return;
if (STYLE_KEYS[event.key]) actions.setStyle(STYLE_KEYS[event.key]);
if (event.key === 'Escape') actions.dismissSearch();
const key = event.key.toLowerCase();
if (key === 'h') actions.toggleHud();
if (key === 'o') actions.toggleOrbit();
if (key === 'v') actions.toggleCleanView();
if (key === 'f') actions.toggleLayers();
if (key === 'd') actions.cycleDetection();
if (key === 'c') actions.toggleCctv();
};
documentRef.addEventListener('keydown', onKeyDown);
return { destroy() { documentRef.removeEventListener('keydown', onKeyDown); } };
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Own generated parameter rows and their input listeners.
* The caller supplies uniform metadata/value access and rendering/persistence
* callbacks. Replacing or destroying rows revokes all old input handlers.
* @param {object} options
* @param {HTMLElement} options.container Container owned by this component.
* @param {Document} [options.documentRef] Document used to construct rows.
* @returns {{render: Function, clear: Function, destroy: Function}}
*/
export function createStyleParameters({ container, documentRef = container.ownerDocument }) {
let listeners = [];
let destroyed = false;
const clear = () => {
if (destroyed) return;
for (const [slider, listener] of listeners) slider.removeEventListener('input', listener);
listeners = [];
container.replaceChildren();
};
return {
/** Render uniform metadata with readValue, writeValue and onChange callbacks. */
render({ uniforms, readValue, writeValue, onChange }) {
if (destroyed) return;
clear();
for (const [name, metadata] of Object.entries(uniforms)) {
const row = documentRef.createElement('div');
row.className = 'param-slider-row';
const label = documentRef.createElement('span');
label.className = 'param-label';
label.textContent = metadata.label;
const slider = documentRef.createElement('input');
slider.type = 'range';
slider.className = 'param-slider';
slider.setAttribute('aria-label', metadata.label);
slider.min = metadata.min;
slider.max = metadata.max;
slider.step = metadata.max <= 1 ? '0.01' : '0.1';
slider.value = readValue(name);
const digits = metadata.max <= 1 ? 2 : 1;
const valueDisplay = documentRef.createElement('span');
valueDisplay.className = 'param-value';
valueDisplay.textContent = parseFloat(slider.value).toFixed(digits);
const onInput = () => {
const value = parseFloat(slider.value);
writeValue(name, value);
valueDisplay.textContent = value.toFixed(digits);
onChange();
};
slider.addEventListener('input', onInput);
listeners.push([slider, onInput]);
row.append(label, slider, valueDisplay);
container.appendChild(row);
}
},
clear,
destroy() {
if (destroyed) return;
clear();
destroyed = true;
},
};
}
+2
View File
@@ -0,0 +1,2 @@
export { bindApplicationShortcuts } from './applicationShortcuts.js';
export { createStyleParameters } from './styleParameters.js';
+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { bindApplicationShortcuts, createStyleParameters } from './visualInput.js';
class Element {
constructor(tagName = 'DIV', ownerDocument) {
this.tagName = tagName;
this.ownerDocument = ownerDocument;
this.children = [];
this.listeners = new Map();
this.attributes = new Map();
}
addEventListener(type, listener, capture) {
assert.notEqual(capture, true, 'these controls must not preempt capture-phase surfaces');
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type).add(listener);
}
removeEventListener(type, listener) { this.listeners.get(type)?.delete(listener); }
emit(type, event = {}) { for (const listener of [...(this.listeners.get(type) || [])]) listener(event); }
setAttribute(name, value) { this.attributes.set(name, value); }
append(...children) { this.children.push(...children); }
appendChild(child) { this.children.push(child); }
replaceChildren(...children) { this.children = children; }
matches() { return ['INPUT', 'SELECT', 'TEXTAREA'].includes(this.tagName); }
}
function shortcuts() {
const documentRef = new Element();
const searchInput = new Element();
const calls = [];
const names = ['setStyle', 'dismissSearch', 'toggleHud', 'toggleOrbit', 'toggleCleanView', 'toggleLayers', 'cycleDetection', 'toggleCctv'];
const actions = Object.fromEntries(names.map((name) => [name, (...args) => calls.push([name, ...args])]));
const controller = bindApplicationShortcuts({ documentRef, searchInput, actions });
const press = (key, target = new Element(), rest = {}) => documentRef.emit('keydown', { key, target, ...rest });
return { documentRef, searchInput, calls, controller, press };
}
test('number keys retain the seven style mappings', () => {
const f = shortcuts();
for (const key of ['1', '2', '3', '4', '5', '6', '7', '8', 'Space']) f.press(key);
assert.deepEqual(f.calls, ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'].map((style) => ['setStyle', style]));
});
test('letter shortcuts retain uppercase handling and existing actions', () => {
const f = shortcuts();
for (const key of ['H', 'o', 'V', 'f', 'D', 'c']) f.press(key);
assert.deepEqual(f.calls, ['toggleHud', 'toggleOrbit', 'toggleCleanView', 'toggleLayers', 'cycleDetection', 'toggleCctv'].map((name) => [name]));
});
for (const tag of ['INPUT', 'SELECT', 'TEXTAREA']) {
test(`${tag} retains native editing, while Escape reaches search dismissal`, () => {
const f = shortcuts();
for (const key of ['1', 'h', 'o', 'v', 'f', 'd', 'c']) f.press(key, new Element(tag));
assert.deepEqual(f.calls, []);
f.press('Escape', new Element(tag));
assert.deepEqual(f.calls, [['dismissSearch']]);
});
}
test('the supplied search target retains editing even without form markup', () => {
const f = shortcuts();
f.press('1', f.searchInput);
f.press('Escape', f.searchInput);
assert.deepEqual(f.calls, [['dismissSearch']]);
});
test('shortcut extraction does not change repeat or modifier policy', () => {
const f = shortcuts();
f.press('h', new Element(), { repeat: true, ctrlKey: true });
assert.deepEqual(f.calls, [['toggleHud']]);
});
test('destroy synchronously removes shortcuts and a replacement binds once', () => {
const f = shortcuts();
f.controller.destroy();
f.controller.destroy();
f.press('h');
assert.deepEqual(f.calls, []);
assert.equal(f.documentRef.listeners.get('keydown').size, 0);
const replacement = bindApplicationShortcuts({ documentRef: f.documentRef, searchInput: f.searchInput, actions: { toggleHud: () => f.calls.push('new') } });
f.press('h');
assert.deepEqual(f.calls, ['new']);
replacement.destroy();
});
function parameters() {
const documentRef = { createElement: (tag) => new Element(tag.toUpperCase(), documentRef) };
const container = new Element('DIV', documentRef);
const controller = createStyleParameters({ container });
const values = { amount: 0.25, scale: 2 };
const uniforms = { amount: { label: '<Amount>', min: 0, max: 1 }, scale: { label: 'Scale', min: 0, max: 10 } };
const order = [];
const options = { uniforms, readValue: (name) => values[name], writeValue: (name, value) => { order.push(['write', name, value]); values[name] = value; }, onChange: () => { order.push(['change', container.children[0]?.children[2].textContent]); } };
controller.render(options);
return { container, controller, values, options, order };
}
test('parameter rows preserve labels, bounds, precision and initial values', () => {
const f = parameters();
assert.equal(f.container.children.length, 2);
const [label, slider, value] = f.container.children[0].children;
assert.equal(label.textContent, '<Amount>');
assert.equal(slider.attributes.get('aria-label'), '<Amount>');
assert.equal(slider.type, 'range');
assert.equal(slider.className, 'param-slider');
assert.equal(slider.min, 0);
assert.equal(slider.max, 1);
assert.equal(slider.step, '0.01');
assert.equal(value.textContent, '0.25');
assert.equal(f.container.children[1].children[1].step, '0.1');
assert.equal(f.container.children[1].children[2].textContent, '2.0');
assert.deepEqual(f.order, [], 'rendering must not write shader values or publish changes');
});
test('input writes numeric values, updates the readout, then requests a change', () => {
const f = parameters();
const slider = f.container.children[0].children[1];
slider.value = '0.75';
slider.emit('input');
assert.equal(f.values.amount, 0.75);
assert.deepEqual(f.order, [['write', 'amount', 0.75], ['change', '0.75']]);
});
test('rebuilding the parameter panel revokes detached slider listeners', () => {
const f = parameters();
const oldSlider = f.container.children[0].children[1];
f.controller.render({ ...f.options, uniforms: { scale: f.options.uniforms.scale } });
oldSlider.value = '0.8';
oldSlider.emit('input');
assert.equal(f.values.amount, 0.25);
assert.equal(oldSlider.listeners.get('input').size, 0);
assert.equal(f.container.children.length, 1);
f.container.children[0].children[1].value = '3.5';
f.container.children[0].children[1].emit('input');
assert.equal(f.values.scale, 3.5);
});
test('clearing removes rows and listeners but allows another style', () => {
const f = parameters();
const oldSlider = f.container.children[0].children[1];
f.controller.clear();
oldSlider.emit('input');
assert.equal(f.container.children.length, 0);
assert.deepEqual(f.order, []);
f.controller.render(f.options);
assert.equal(f.container.children.length, 2);
});
test('destroy is final and cannot clear a subsequent owner’s DOM', () => {
const f = parameters();
const oldSlider = f.container.children[0].children[1];
f.controller.destroy();
const nextOwner = new Element();
f.container.appendChild(nextOwner);
f.controller.render(f.options);
f.controller.clear();
f.controller.destroy();
oldSlider.emit('input');
assert.deepEqual(f.container.children, [nextOwner]);
assert.deepEqual(f.order, []);
});