style: format Scene controls and browser acceptance

This commit is contained in:
Sameh Khamis committed 2026-09-13 03:30:05 -07:00
1 parent cd72feb4fe
commit d42036d74f
5 files changed
+498 -103

No files matched your search

+45 -13
View File
@@ -1,5 +1,12 @@
/** Own Scene panel listeners and presentation through project reads and actions. */
import { sceneElements, renderSceneOptions, renderSceneShots, presentSceneButtons, presentSceneProgress, presentSceneRuntime } from './scenePresentation.js';
import {
sceneElements,
renderSceneOptions,
renderSceneShots,
presentSceneButtons,
presentSceneProgress,
presentSceneRuntime,
} from './scenePresentation.js';
export class SceneControls {
constructor({ read, actions, elements = sceneElements() }) {
@@ -14,11 +21,23 @@ export class SceneControls {
if (!elements.select) return;
this.renderSceneSelect();
this.renderShotList();
this.listen(elements.select, 'change', () => this.run('selectScene', elements.select.value));
for (const [element, action] of Object.entries({ new: 'create', delete: 'deleteScene', capture: 'capture', update: 'update', next: 'next', export: 'export', download: 'download' })) {
this.listen(elements.select, 'change', () =>
this.run('selectScene', elements.select.value),
);
for (const [element, action] of Object.entries({
new: 'create',
delete: 'deleteScene',
capture: 'capture',
update: 'update',
next: 'next',
export: 'export',
download: 'download',
})) {
this.listen(elements[element], 'click', () => this.run(action));
}
this.listen(elements.start, 'click', () => this.run('start', this.read().selectedSceneId));
this.listen(elements.start, 'click', () =>
this.run('start', this.read().selectedSceneId),
);
this.listen(elements.stop, 'click', () => this.run('stop', 'Stopped'));
this.listen(elements.import, 'click', () => elements.file?.click());
this.listen(elements.file, 'change', async () => {
@@ -34,7 +53,9 @@ export class SceneControls {
listen(target, type, callback, removers = this.removers) {
if (!target) return;
const listener = (event) => { if (!this.destroyed) return callback(event); };
const listener = (event) => {
if (!this.destroyed) return callback(event);
};
target.addEventListener(type, listener);
removers.push(() => target.removeEventListener(type, listener));
}
@@ -43,7 +64,8 @@ export class SceneControls {
if (this.destroyed) return;
const generation = ++this.actionGeneration;
const failed = () => {
if (!this.destroyed && generation === this.actionGeneration) this.updateStatus('Scene action failed');
if (!this.destroyed && generation === this.actionGeneration)
this.updateStatus('Scene action failed');
};
try {
const result = this.actions[action](...args);
@@ -61,16 +83,19 @@ export class SceneControls {
if (this.destroyed) return;
for (const remove of this.rowRemovers.splice(0)) remove();
renderSceneShots(this.elements.shots, this.read(), {
listen: (element, type, callback) => this.listen(element, type, callback, this.rowRemovers),
listen: (element, type, callback) =>
this.listen(element, type, callback, this.rowRemovers),
select: (id) => this.run('selectShot', id),
rename: (sceneId, shotId, title) => this.run('renameShot', sceneId, shotId, title),
rename: (sceneId, shotId, title) =>
this.run('renameShot', sceneId, shotId, title),
load: (sceneId, shotId) => this.run('load', sceneId, shotId),
remove: (sceneId, shotId) => this.run('deleteShot', sceneId, shotId),
});
}
setButtons(running) {
if (!this.destroyed) presentSceneButtons(this.elements, running, this.read().hasRun);
if (!this.destroyed)
presentSceneButtons(this.elements, running, this.read().hasRun);
}
setProgress(progress) {
@@ -78,7 +103,8 @@ export class SceneControls {
}
updateStatus(text) {
if (!this.destroyed && this.elements.status) this.elements.status.textContent = text;
if (!this.destroyed && this.elements.status)
this.elements.status.textContent = text;
}
updateRuntime(text) {
@@ -96,10 +122,12 @@ export class SceneControls {
this.playbackKeyRemover = null;
} else if (!this.destroyed && !this.playbackKeyRemover) {
const onKeyDown = (event) => {
if (!this.destroyed && event.key === 'Escape' && this.read().running) this.run('stop', 'Stopped (Esc)');
if (!this.destroyed && event.key === 'Escape' && this.read().running)
this.run('stop', 'Stopped (Esc)');
};
document.addEventListener('keydown', onKeyDown);
this.playbackKeyRemover = () => document.removeEventListener('keydown', onKeyDown);
this.playbackKeyRemover = () =>
document.removeEventListener('keydown', onKeyDown);
}
}
@@ -108,7 +136,11 @@ export class SceneControls {
this.destroyed = true;
this.actionGeneration++;
this.setPlaybackKeyboardEnabled(false);
for (const remove of [...this.removers.splice(0), ...this.rowRemovers.splice(0)]) remove();
for (const remove of [
...this.removers.splice(0),
...this.rowRemovers.splice(0),
])
remove();
if (this.elements.shots) this.elements.shots.textContent = '';
presentSceneRuntime(this.elements.runtime, '');
this.setPlaybackActive(false);
+141 -22
View File
@@ -12,28 +12,116 @@ function fixture() {
this._text = '';
const classes = new Set();
this.classList = {
toggle(name, active) { if (active) classes.add(name); else classes.delete(name); },
toggle(name, active) {
if (active) classes.add(name);
else classes.delete(name);
},
contains: (name) => classes.has(name),
};
}
appendChild(element) { this.children.push(element); }
set textContent(value) { this._text = value; this.children = []; }
get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); }
click() { this.dispatchEvent(new Event('click')); }
appendChild(element) {
this.children.push(element);
}
set textContent(value) {
this._text = value;
this.children = [];
}
get textContent() {
return (
this._text + this.children.map((node) => node.textContent).join('')
);
}
click() {
this.dispatchEvent(new Event('click'));
}
}
const saved = { document: globalThis.document, window: globalThis.window };
const document = Object.assign(new EventTarget(), { createElement: () => new Element(), body: new Element() });
const document = Object.assign(new EventTarget(), {
createElement: () => new Element(),
body: new Element(),
});
globalThis.document = document;
globalThis.window = { prompt: () => 'Renamed' };
const elements = Object.fromEntries(['panel','select','new','delete','capture','update','shots','start','stop','next','export','import','file','download','status','progress','runtime'].map((name) => [name, new Element()]));
const elements = Object.fromEntries(
[
'panel',
'select',
'new',
'delete',
'capture',
'update',
'shots',
'start',
'stop',
'next',
'export',
'import',
'file',
'download',
'status',
'progress',
'runtime',
].map((name) => [name, new Element()]),
);
const state = {
scenes: [{ id: 'scene-a', title: '<Scene A>', shots: [{ id: 'shot-a', title: '<Shot A>', durationSec: 4, holdSec: 0.9, visual: { style: 'normal' } }] }],
selectedSceneId: 'scene-a', selectedShotId: 'shot-a', running: false, hasRun: false,
scenes: [
{
id: 'scene-a',
title: '<Scene A>',
shots: [
{
id: 'shot-a',
title: '<Shot A>',
durationSec: 4,
holdSec: 0.9,
visual: { style: 'normal' },
},
],
},
],
selectedSceneId: 'scene-a',
selectedShotId: 'shot-a',
running: false,
hasRun: false,
};
const calls = [];
const actions = Object.fromEntries(['selectScene','selectShot','renameShot','create','deleteScene','capture','update','start','stop','next','export','import','download','load','deleteShot'].map((name) => [name, (...args) => { calls.push([name, ...args]); }]));
const actions = Object.fromEntries(
[
'selectScene',
'selectShot',
'renameShot',
'create',
'deleteScene',
'capture',
'update',
'start',
'stop',
'next',
'export',
'import',
'download',
'load',
'deleteShot',
].map((name) => [
name,
(...args) => {
calls.push([name, ...args]);
},
]),
);
const owner = new SceneControls({ read: () => state, actions, elements });
return { owner, state, actions, elements, calls, document, restore() { owner.destroy(); Object.assign(globalThis, saved); } };
return {
owner,
state,
actions,
elements,
calls,
document,
restore() {
owner.destroy();
Object.assign(globalThis, saved);
},
};
}
test('Scene controls render the supplied selection and running/download states', () => {
@@ -41,7 +129,10 @@ test('Scene controls render the supplied selection and running/download states',
try {
assert.equal(f.elements.select.children[0].textContent, '<Scene A>');
assert.equal(f.elements.select.value, 'scene-a');
assert.equal(f.elements.shots.children[0].children[0].children[0].textContent, '<Shot A>');
assert.equal(
f.elements.shots.children[0].children[0].children[0].textContent,
'<Shot A>',
);
assert.equal(f.elements.stop.disabled, true);
assert.equal(f.elements.download.disabled, true);
f.state.hasRun = true;
@@ -55,7 +146,9 @@ test('Scene controls render the supplied selection and running/download states',
assert.equal(f.elements.progress.style.width, '100%');
f.owner.setProgress(-1);
assert.equal(f.elements.progress.textContent, '0%');
} finally { f.restore(); }
} finally {
f.restore();
}
});
test('Scene controls dispatch explicit actions and revoke replaced shot-row listeners', () => {
@@ -69,19 +162,30 @@ test('Scene controls dispatch explicit actions and revoke replaced shot-row list
label.click();
label.dispatchEvent(new Event('dblclick'));
load.click();
assert.deepEqual(f.calls, [['capture'], ['start','scene-a'], ['selectShot','shot-a'], ['renameShot','scene-a','shot-a','Renamed'], ['load','scene-a','shot-a']]);
assert.deepEqual(f.calls, [
['capture'],
['start', 'scene-a'],
['selectShot', 'shot-a'],
['renameShot', 'scene-a', 'shot-a', 'Renamed'],
['load', 'scene-a', 'shot-a'],
]);
f.owner.renderShotList();
label.click();
load.click();
assert.equal(f.calls.length, 5);
f.elements.shots.children[0].children[0].children[1].children[0].click();
assert.equal(f.calls.length, 6);
} finally { f.restore(); }
} finally {
f.restore();
}
});
test('Scene Escape and recording presentation follow playback and release on destruction', () => {
const f = fixture();
const escape = () => f.document.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Escape' }));
const escape = () =>
f.document.dispatchEvent(
Object.assign(new Event('keydown'), { key: 'Escape' }),
);
try {
escape();
assert.deepEqual(f.calls, []);
@@ -98,18 +202,26 @@ test('Scene Escape and recording presentation follow playback and release on des
f.elements.capture.click();
f.owner.run('capture');
assert.equal(f.calls.length, 1);
assert.equal(f.document.body.classList.contains('scene-playback-mode'), false);
assert.equal(
f.document.body.classList.contains('scene-playback-mode'),
false,
);
assert.equal(f.elements.runtime.textContent, '');
assert.equal(f.owner.removers.length, 0);
assert.equal(f.owner.rowRemovers.length, 0);
} finally { f.restore(); }
} finally {
f.restore();
}
});
test('a file import finishing after disposal cannot clear the retained file control', async () => {
const f = fixture();
try {
let finish;
f.actions.import = () => new Promise((resolve) => { finish = resolve; });
f.actions.import = () =>
new Promise((resolve) => {
finish = resolve;
});
f.elements.file.files = [{ name: 'project.json' }];
f.elements.file.value = 'project.json';
f.elements.file.dispatchEvent(new Event('change'));
@@ -118,14 +230,19 @@ test('a file import finishing after disposal cannot clear the retained file cont
await new Promise((resolve) => setImmediate(resolve));
assert.equal(f.elements.file.value, 'project.json');
assert.equal(f.elements.status.textContent, 'Ready');
} finally { f.restore(); }
} finally {
f.restore();
}
});
test('Scene action failures are visible only while that action still owns presentation', async () => {
const f = fixture();
try {
let reject;
f.actions.load = () => new Promise((_resolve, fail) => { reject = fail; });
f.actions.load = () =>
new Promise((_resolve, fail) => {
reject = fail;
});
let pending = f.owner.run('load', 'scene-a', 'shot-a');
reject(new Error('unavailable'));
await pending;
@@ -141,5 +258,7 @@ test('Scene action failures are visible only while that action still owns presen
reject(new Error('disposed'));
await pending;
assert.equal(f.elements.status.textContent, 'Current selection');
} finally { f.restore(); }
} finally {
f.restore();
}
});
+36 -9
View File
@@ -1,14 +1,27 @@
/** Scene panel elements and text-only project presentation. */
export function sceneElements(root = document) {
const ids = {
panel: 'scene-panel', select: 'scene-select', new: 'scene-new-btn',
delete: 'scene-delete-btn', capture: 'scene-capture-btn', update: 'scene-update-shot-btn',
shots: 'scene-shot-list', start: 'scene-start-btn', stop: 'scene-stop-btn',
next: 'scene-next-btn', export: 'scene-export-btn', import: 'scene-import-btn',
file: 'scene-import-file', download: 'scene-download-btn', status: 'scene-status',
progress: 'scene-progress-fill', runtime: 'scene-runtime',
panel: 'scene-panel',
select: 'scene-select',
new: 'scene-new-btn',
delete: 'scene-delete-btn',
capture: 'scene-capture-btn',
update: 'scene-update-shot-btn',
shots: 'scene-shot-list',
start: 'scene-start-btn',
stop: 'scene-stop-btn',
next: 'scene-next-btn',
export: 'scene-export-btn',
import: 'scene-import-btn',
file: 'scene-import-file',
download: 'scene-download-btn',
status: 'scene-status',
progress: 'scene-progress-fill',
runtime: 'scene-runtime',
};
return Object.fromEntries(Object.entries(ids).map(([name, id]) => [name, root.getElementById(id)]));
return Object.fromEntries(
Object.entries(ids).map(([name, id]) => [name, root.getElementById(id)]),
);
}
export function renderSceneOptions(element, { scenes, selectedSceneId }) {
@@ -23,7 +36,11 @@ export function renderSceneOptions(element, { scenes, selectedSceneId }) {
if (selectedSceneId) element.value = selectedSceneId;
}
export function renderSceneShots(element, state, { listen, select, rename, load, remove }) {
export function renderSceneShots(
element,
state,
{ listen, select, rename, load, remove },
) {
if (!element) return;
const scene = state.scenes.find((item) => item.id === state.selectedSceneId);
element.textContent = '';
@@ -74,7 +91,17 @@ export function renderSceneShots(element, state, { listen, select, rename, load,
}
export function presentSceneButtons(elements, running, hasRun) {
for (const name of ['start', 'next', 'select', 'new', 'delete', 'capture', 'update', 'export', 'import']) {
for (const name of [
'start',
'next',
'select',
'new',
'delete',
'capture',
'update',
'export',
'import',
]) {
if (elements[name]) elements[name].disabled = running;
}
if (elements.stop) elements.stop.disabled = !running;