From ec4c55e78c46a89d56dc972ad607fc48f709f64c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 23:04:31 -0700 Subject: [PATCH] feat(scenes): render the Scene panel's controls from the store SceneControls no longer takes element handles or writes the Scene panel's button states, PLAY label, status line, progress bar or runtime chip: it presents them to the gev-scenes store, and the panel's buttons, scene select and IMPORT chooser call its actions there. The chooser opens only while an owner presents the panel and is cleared once its import settles. On destroy the panel keeps its last status and buttons, without shots or the runtime chip, as before. --- CHANGELOG.md | 9 +++ docs/VUE-MIGRATION.md | 4 +- scripts/qa-scene-controls.mjs | 3 +- src/scenes/director.js | 4 +- src/ui/sceneControls.js | 120 ++++++++++++++----------------- src/ui/sceneControls.test.mjs | 112 +++++++++++++++++++++++++---- src/ui/scenePresentation.js | 57 ++++----------- src/ui/stores/scenes.js | 27 ++++++- src/ui/views/ContextView.vue | 7 +- src/ui/views/LayerPanelsView.vue | 50 +++++++++---- 10 files changed, 245 insertions(+), 148 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f319ba18..da0f93ac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,15 @@ them to the welcome view's `gev-first-run` store (its new `store` option) and connects the controls the view's buttons call (samehkhamis). +- `SceneControls` (`./ui/scenes`) no longer takes `elements` or writes the + Scene panel's buttons, status, progress or runtime chip: it presents them + to the `gev-scenes` store and the panel's buttons call its actions there. + `sceneElements`, `presentSceneButtons`, `presentSceneProgress` and + `presentSceneRuntime` give way to `sceneButtonsDisabled` and + `scenePercent`. It no longer stands down when the page has no Scene + panel: ESC still stops a playing scene and the runtime chip still shows + (samehkhamis). + ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language - Show a Street Level view facing 359.5° or more as 0°. The viewer caption diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 734801db2..ffafe08c7 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -91,7 +91,9 @@ a feature supplying only its accent. the rail cards, card blocks and timeline (`src/ui/rail/`) render through Vue's synchronous `render()` behind `createRailCards()`, `createRailCardBlocks()` and `createRailTimeline()`. Order: Scenes (done: `SceneOptions` and - `SceneShotList` over the `gev-scenes` store), Weather (done: the owner + `SceneShotList` over the `gev-scenes` store; the panel's buttons, status + line, progress bar and the runtime chip render the panel SceneControls + presents there, and the buttons and file chooser call its actions), Weather (done: the owner renders `WeatherReadout` from the view it computes), Recent imagery (done: `RecentImageryReadout` and its day cards, the same way), Street level (done: `StreetLevelPanel` renders the `gev-street-level` diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index 72521d298..bda4a407c 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -446,8 +446,7 @@ try { const retainedLabel = document.querySelector('.scene-shot-label'); const stopping = director.destroy(); const notificationsAtStop = window.__qaSceneState.length; - const stoppedSynchronously = - controls.destroyed && controls.removers.length === 0; + const stoppedSynchronously = controls.destroyed; retainedLabel?.click(); document.getElementById('scene-new-btn').click(); controls.run('capture'); diff --git a/src/scenes/director.js b/src/scenes/director.js index 88e4e3a8f..180fb3332 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -395,8 +395,8 @@ export class SceneDirector { } /** - * Wire up all scene-panel DOM event listeners and render the initial UI state. - * Exits silently if the scene-select element is missing (headless/test mode). + * Present the Scene panel through SceneControls, which publishes it to the + * store the panel views render; a page without them simply draws nothing. */ _initUI() { this._sharing?.mount(); diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js index 43b322957..220d35d99 100644 --- a/src/ui/sceneControls.js +++ b/src/ui/sceneControls.js @@ -1,11 +1,6 @@ /** Own Scene panel listeners and presentation through project reads and actions. */ import { t, msg } from '../i18n/index.js'; -import { - sceneElements, - presentSceneButtons, - presentSceneProgress, - presentSceneRuntime, -} from './scenePresentation.js'; +import { sceneButtonsDisabled, scenePercent } from './scenePresentation.js'; import { useSceneStore } from './stores/scenes.js'; import { applicationPinia } from './vueRuntime.js'; @@ -18,19 +13,27 @@ export class SceneControls { read, actions, subscribe, - elements = sceneElements(), store = useSceneStore(applicationPinia()), }) { this.read = read; this.actions = actions; - this.elements = elements; this.store = store; this.destroyed = false; this.actionGeneration = 0; - this.removers = []; this.playbackKeyRemover = null; - if (!elements.select) return; - // The SceneShotList view renders the rows; their actions come back here. + // The panel views render the buttons, status, progress and runtime chip. + this.panel = { + running: false, + hasShots: false, + disabled: sceneButtonsDisabled(false, false), + status: '', + progress: 0, + runtime: '', + // The radio's PLAY means "listen"; this one plays the scene. + playLabel: t('PLAY', { context: 'scene' }), + }; + // The SceneShotList view renders the rows and the panel its buttons; + // their actions come back here. this.storeToken = store.attach({ select: (id) => { this.run('selectShot', id); @@ -42,10 +45,20 @@ export class SceneControls { this.run('renameShot', sceneId, shotId, title), load: (sceneId, shotId) => this.run('load', sceneId, shotId), remove: (sceneId, shotId) => this.deleteShot(sceneId, shotId), + selectScene: (sceneId) => this.run('selectScene', sceneId), + create: () => this.createScene(), + deleteScene: () => this.deleteSelectedScene(), + capture: () => this.run('capture'), + update: () => this.run('update'), + next: () => this.run('next'), + export: () => this.run('export'), + download: () => this.run('download'), + start: () => this.run('start', this.read().selectedSceneId), + stop: () => this.run('stop', msg('Stopped')), + importFile: (file) => + this.run(this.actions.reviewImport ? 'reviewImport' : 'import', file), }); - // The radio's PLAY means "listen"; this one plays the scene. - const playLabel = t('PLAY', { context: 'scene' }); - if (elements.start) elements.start.textContent = playLabel; + this.presentPanel({}); if (subscribe) this.unsubscribe = subscribe((notification) => this.present(notification), @@ -54,34 +67,6 @@ export class SceneControls { this.renderSceneSelect(); this.renderShotList(); } - this.listen(elements.select, 'change', () => - this.run('selectScene', elements.select.value), - ); - for (const [element, action] of Object.entries({ - capture: 'capture', - update: 'update', - next: 'next', - export: 'export', - download: 'download', - })) { - this.listen(elements[element], 'click', () => this.run(action)); - } - this.listen(elements.new, 'click', () => this.createScene()); - this.listen(elements.delete, 'click', () => this.deleteSelectedScene()); - this.listen(elements.start, 'click', () => - this.run('start', this.read().selectedSceneId), - ); - this.listen(elements.stop, 'click', () => this.run('stop', msg('Stopped'))); - this.listen(elements.import, 'click', () => elements.file?.click()); - this.listen(elements.file, 'change', async () => { - const file = elements.file?.files?.[0]; - if (!file) return; - await this.run( - this.actions.reviewImport ? 'reviewImport' : 'import', - file, - ); - if (!this.destroyed) elements.file.value = ''; - }); if (!subscribe) { this.updateStatus(t('Ready')); this.setProgress(0); @@ -149,13 +134,13 @@ export class SceneControls { this.setButtons(state.running); } - listen(target, type, callback) { - if (!target) return; - const listener = (event) => { - if (!this.destroyed) return callback(event); - }; - target.addEventListener(type, listener); - this.removers.push(() => target.removeEventListener(type, listener)); + /** Merge `patch` into the panel and publish it. */ + presentPanel(patch) { + if (this.destroyed) return; + Object.assign(this.panel, patch); + // A newer owner's panel is left alone. + if (this.store.owns(this.storeToken)) + this.store.publishPanel({ ...this.panel }); } run(action, ...args) { @@ -226,29 +211,31 @@ export class SceneControls { (item) => item.id === state.selectedSceneId, ); const shots = scene?.shots ?? []; - presentSceneButtons(this.elements, running, state.hasRun, { - hasScene: !!scene, - // PLAY and NEXT run on into the following scenes. - playableShots: (state.scenes ?? []).reduce( - (total, item) => total + (item.shots?.length ?? 0), - 0, - ), - hasSelectedShot: shots.some((shot) => shot.id === state.selectedShotId), + this.presentPanel({ + running: !!running, + hasShots: shots.length > 0, + disabled: sceneButtonsDisabled(running, state.hasRun, { + hasScene: !!scene, + // PLAY and NEXT run on into the following scenes. + playableShots: (state.scenes ?? []).reduce( + (total, item) => total + (item.shots?.length ?? 0), + 0, + ), + hasSelectedShot: shots.some((shot) => shot.id === state.selectedShotId), + }), }); - this.elements.panel?.classList.toggle('has-shots', shots.length > 0); } setProgress(progress) { - if (!this.destroyed) presentSceneProgress(this.elements.progress, progress); + this.presentPanel({ progress: scenePercent(progress) }); } updateStatus(text) { - if (!this.destroyed && this.elements.status) - this.elements.status.textContent = text; + this.presentPanel({ status: text }); } updateRuntime(text) { - if (!this.destroyed) presentSceneRuntime(this.elements.runtime, text); + this.presentPanel({ runtime: text || '' }); } setPlaybackActive(active) { @@ -278,10 +265,11 @@ export class SceneControls { this.unsubscribe?.(); this.unsubscribe = null; this.setPlaybackKeyboardEnabled(false); - for (const remove of this.removers.splice(0)) remove(); - if (this.storeToken) this.store.detach(this.storeToken); - this.elements.panel?.classList.toggle('has-shots', false); - presentSceneRuntime(this.elements.runtime, ''); + // The panel keeps its last status and buttons, without shots or the + // runtime chip. + if (this.store.owns(this.storeToken)) + this.store.publishPanel({ ...this.panel, hasShots: false, runtime: '' }); + this.store.detach(this.storeToken); this.setPlaybackActive(false); } } diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index 4e45ea0a2..b0aea6cfd 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -2,7 +2,6 @@ import test, { after, before } from 'node:test'; import assert from 'node:assert/strict'; import { assertSameNode } from '../testSupport/domAssert.mjs'; import { createStateChannel } from '../app/stateChannel.js'; -import { sceneElements } from './scenePresentation.js'; import { setLocale } from '../i18n/index.js'; import { installVueDom } from '../testSupport/vueDom.mjs'; @@ -13,6 +12,7 @@ let SceneControls; let SceneDirector; let LayerPanelsView; let applicationPinia; +let useSceneStore; let uninstallDom; before(async () => { @@ -21,6 +21,7 @@ before(async () => { ({ SceneControls } = await import('./sceneControls.js')); ({ SceneDirector } = await import('../scenes/director.js')); ({ applicationPinia } = await import('./vueRuntime.js')); + ({ useSceneStore } = await import('./stores/scenes.js')); LayerPanelsView = (await import('./views/LayerPanelsView.vue')).default; }); after(() => uninstallDom()); @@ -33,8 +34,26 @@ function fixture() { const view = createApp(LayerPanelsView).use(applicationPinia()); view.mount(document.getElementById('layer-panels-host')); window.prompt = () => 'Renamed'; - const elements = sceneElements(document); - elements.shots = document.getElementById('scene-shot-list'); + const byId = (id) => document.getElementById(id); + const elements = { + panel: byId('scene-panel'), + select: byId('scene-select'), + new: byId('scene-new-btn'), + delete: byId('scene-delete-btn'), + capture: byId('scene-capture-btn'), + update: byId('scene-update-shot-btn'), + start: byId('scene-start-btn'), + stop: byId('scene-stop-btn'), + next: byId('scene-next-btn'), + export: byId('scene-export-btn'), + import: byId('scene-import-btn'), + file: byId('scene-import-file'), + download: byId('scene-download-btn'), + status: byId('scene-status'), + progress: byId('scene-progress-fill'), + runtime: byId('scene-runtime'), + shots: byId('scene-shot-list'), + }; const state = { scenes: [ { @@ -81,7 +100,7 @@ function fixture() { }, ]), ); - const owner = new SceneControls({ read: () => state, actions, elements }); + const owner = new SceneControls({ read: () => state, actions }); return { owner, state, @@ -110,27 +129,32 @@ test('Scene controls render the supplied selection and running/download states', f.elements.shots.children[0].children[1].textContent, 'NORMAL · OFF · 4.0s + 0.9s', ); + await nextTick(); assert.equal(f.elements.stop.disabled, true); assert.equal(f.elements.download.disabled, true); f.state.hasRun = true; f.owner.setButtons(true); + await nextTick(); assert.equal(f.elements.start.disabled, true); assert.equal(f.elements.capture.disabled, true); assert.equal(f.elements.stop.disabled, false); assert.equal(f.elements.download.disabled, false); assert.equal(f.elements.panel.classList.contains('running'), true); f.owner.setProgress(2); + await nextTick(); assert.equal(f.elements.progress.style.width, '100%'); f.owner.setProgress(-1); + await nextTick(); assert.equal(f.elements.progress.textContent, '0%'); } finally { f.restore(); } }); -test('Authoring disables idle actions that would do nothing', () => { +test('Authoring disables idle actions that would do nothing', async () => { const f = fixture(); try { + await nextTick(); assert.equal(f.elements.start.textContent, 'PLAY'); assert.equal(f.elements.start.disabled, false); assert.equal(f.elements.update.disabled, false); @@ -138,6 +162,7 @@ test('Authoring disables idle actions that would do nothing', () => { f.state.selectedShotId = null; f.owner.setButtons(false); + await nextTick(); assert.equal(f.elements.update.disabled, true); assert.equal(f.elements.start.disabled, false); @@ -146,12 +171,14 @@ test('Authoring disables idle actions that would do nothing', () => { f.state.scenes[0].shots = []; f.state.scenes.push({ id: 'scene-b', title: 'B', shots: [{ id: 'b1' }] }); f.owner.setButtons(false); + await nextTick(); assert.equal(f.elements.start.disabled, false); assert.equal(f.elements.next.disabled, false); assert.equal(f.elements.update.disabled, true); f.state.scenes.pop(); f.owner.setButtons(false); + await nextTick(); assert.equal(f.elements.start.disabled, true); assert.equal(f.elements.next.disabled, true); assert.equal(f.elements.capture.disabled, false); @@ -160,6 +187,7 @@ test('Authoring disables idle actions that would do nothing', () => { f.state.scenes = []; f.state.selectedSceneId = null; f.owner.setButtons(false); + await nextTick(); assert.equal(f.elements.delete.disabled, true); assert.equal(f.elements.capture.disabled, true); assert.equal(f.elements.new.disabled, false); @@ -197,7 +225,6 @@ test('Scene controls dispatch panel and shot-row actions, and rows outlive repub const newer = new SceneControls({ read: () => f.state, actions: f.actions, - elements: f.elements, }); f.owner.destroy(); await nextTick(); @@ -211,7 +238,7 @@ test('Scene controls dispatch panel and shot-row actions, and rows outlive repub } }); -test('Scene Escape and recording presentation follow playback and release on destruction', () => { +test('Scene Escape and recording presentation follow playback and release on destruction', async () => { const f = fixture(); const escape = () => f.document.dispatchEvent( @@ -224,6 +251,9 @@ test('Scene Escape and recording presentation follow playback and release on des f.owner.setPlaybackActive(true); f.owner.setPlaybackKeyboardEnabled(true); f.owner.updateRuntime('Scene A / Shot A'); + // The Context view renders the chip from the store. + const panel = () => useSceneStore(applicationPinia()).panel; + assert.equal(panel().runtime, 'Scene A / Shot A'); escape(); assert.deepEqual(f.calls, [['stop', 'Stopped (Esc)']]); f.owner.destroy(); @@ -237,8 +267,8 @@ test('Scene Escape and recording presentation follow playback and release on des f.document.body.classList.contains('scene-playback-mode'), false, ); - assert.equal(f.elements.runtime.textContent, ''); - assert.equal(f.owner.removers.length, 0); + assert.equal(panel().runtime, ''); + assert.equal(panel().hasShots, false); } finally { f.restore(); } @@ -248,8 +278,10 @@ test('a file import finishing after disposal cannot clear the retained file cont const f = fixture(); try { let finish; - f.actions.import = () => + let imported; + f.actions.import = (file) => new Promise((resolve) => { + imported = file; finish = resolve; }); // A file input's files and value are read-only to scripts. @@ -261,9 +293,12 @@ test('a file import finishing after disposal cannot clear the retained file cont writable: true, }); f.elements.file.dispatchEvent(new Event('change')); + assert.deepEqual(imported, { name: 'project.json' }); f.owner.destroy(); finish(); await new Promise((resolve) => setImmediate(resolve)); + await nextTick(); + // The chooser is left as it was once its owner is gone. assert.equal(f.elements.file.value, 'project.json'); assert.equal(f.elements.status.textContent, 'Ready'); } finally { @@ -282,24 +317,27 @@ test('Scene action failures are visible only while that action still owns presen let pending = f.owner.run('load', 'scene-a', 'shot-a'); reject(new Error('unavailable')); await pending; + await nextTick(); assert.equal(f.elements.status.textContent, 'Scene action failed'); pending = f.owner.run('load', 'scene-a', 'shot-a'); f.owner.run('selectShot', 'shot-a'); f.owner.updateStatus('Current selection'); reject(new Error('obsolete')); await pending; + await nextTick(); assert.equal(f.elements.status.textContent, 'Current selection'); pending = f.owner.run('load', 'scene-a', 'shot-a'); f.owner.destroy(); reject(new Error('disposed')); await pending; + await nextTick(); assert.equal(f.elements.status.textContent, 'Current selection'); } finally { f.restore(); } }); -test('creation and deletion prompts precede their model actions and remain inert after disposal', () => { +test('creation and deletion prompts precede their model actions and remain inert after disposal', async () => { const f = fixture(); try { let prompts = 0; @@ -493,9 +531,9 @@ test('Scene controls consume current state, preserve rows on progress, and unsub const owner = new SceneControls({ read: () => f.state, actions: f.actions, - elements: f.elements, subscribe: (listener) => channel.subscribe(listener), }); + await nextTick(); assert.equal(f.elements.status.textContent, 'Ready to resume'); await nextTick(); const row = f.elements.shots.children[0]; @@ -505,10 +543,12 @@ test('Scene controls consume current state, preserve rows on progress, and unsub assertSameNode(f.elements.shots.children[0], row); f.state.status = 'Project exported'; channel.publish({ type: 'project-exported' }); + await nextTick(); assert.equal(f.elements.status.textContent, 'Project exported'); owner.destroy(); f.state.status = 'Late'; channel.publish({ type: 'status-changed' }); + await nextTick(); assert.equal(f.elements.status.textContent, 'Project exported'); assert.equal(owner.unsubscribe, null); channel.destroy(); @@ -517,7 +557,7 @@ test('Scene controls consume current state, preserve rows on progress, and unsub } }); -test('Authoring refreshes idle buttons from published changes', () => { +test('Authoring refreshes idle buttons from published changes', async () => { const f = fixture(); try { f.owner.destroy(); @@ -525,19 +565,21 @@ test('Authoring refreshes idle buttons from published changes', () => { const owner = new SceneControls({ read: () => f.state, actions: f.actions, - elements: f.elements, subscribe: (listener) => channel.subscribe(listener), }); const disabled = (name) => f.elements[name].disabled; + await nextTick(); assert.equal(disabled('start'), false); assert.equal(f.elements.panel.classList.contains('has-shots'), true); f.state.selectedShotId = null; channel.publish({ type: 'selection-changed' }); + await nextTick(); assert.equal(disabled('update'), true); f.state.scenes[0].shots = []; channel.publish({ type: 'shot-deleted' }); + await nextTick(); assert.equal(disabled('start'), true); assert.equal(disabled('next'), true); assert.equal(f.elements.panel.classList.contains('has-shots'), false); @@ -545,16 +587,19 @@ test('Authoring refreshes idle buttons from published changes', () => { // A run that ends hands the buttons back with the idle rules applied. f.state.running = true; channel.publish({ type: 'buttons-changed' }); + await nextTick(); assert.equal(disabled('capture'), true); assert.equal(disabled('stop'), false); f.state.running = false; channel.publish({ type: 'buttons-changed' }); + await nextTick(); assert.equal(disabled('capture'), false); assert.equal(disabled('start'), true); f.state.scenes = []; f.state.selectedSceneId = null; channel.publish({ type: 'scene-deleted' }); + await nextTick(); assert.equal(disabled('delete'), true); assert.equal(disabled('capture'), true); assert.equal(disabled('new'), false); @@ -563,10 +608,12 @@ test('Authoring refreshes idle buttons from published changes', () => { f.state.selectedSceneId = 'scene-b'; f.state.selectedShotId = 's'; channel.publish({ type: 'project-imported' }); + await nextTick(); assert.equal(disabled('start'), false); assert.equal(f.elements.panel.classList.contains('has-shots'), true); owner.destroy(); + await nextTick(); assert.equal(f.elements.panel.classList.contains('has-shots'), false); channel.destroy(); } finally { @@ -574,13 +621,48 @@ test('Authoring refreshes idle buttons from published changes', () => { } }); -test('Authoring PLAY uses the scene context, not the radio PLAY', (context) => { +test('Authoring PLAY uses the scene context, not the radio PLAY', async (context) => { context.after(() => setLocale('en')); setLocale('es'); const f = fixture(); try { + await nextTick(); assert.equal(f.elements.start.textContent, 'REPROD.'); } finally { f.restore(); } }); + +test('IMPORT clears its chooser only once the import settles, and is inert without an owner', async () => { + const f = fixture(); + try { + let finish; + f.actions.import = () => + new Promise((resolve) => { + finish = resolve; + }); + Object.defineProperty(f.elements.file, 'files', { + value: [{ name: 'project.json' }], + }); + Object.defineProperty(f.elements.file, 'value', { + value: 'project.json', + writable: true, + }); + f.elements.file.dispatchEvent(new Event('change')); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(f.elements.file.value, 'project.json', 'still importing'); + finish(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(f.elements.file.value, ''); + + let opened = 0; + f.elements.file.click = () => opened++; + f.elements.import.click(); + assert.equal(opened, 1); + f.owner.destroy(); + f.elements.import.click(); + assert.equal(opened, 1, 'no chooser once the owner is gone'); + } finally { + f.restore(); + } +}); diff --git a/src/ui/scenePresentation.js b/src/ui/scenePresentation.js index 4a5a23e74..8c028c6d8 100644 --- a/src/ui/scenePresentation.js +++ b/src/ui/scenePresentation.js @@ -1,5 +1,5 @@ -/** Scene panel elements and text-only project presentation; the shot list - * and scene options are the SceneShotList and SceneOptions views. */ +/** The Scene panel's text and button presentation; the panel views render + * it. */ import { formatNumber, msg, t } from '../i18n/index.js'; // Labels for what a shot stores; anything else shows as stored. Style names @@ -21,30 +21,6 @@ const DETECTION_MODE_LABELS = Object.freeze({ DENSE: msg('DENSE'), }); -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', - 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)]), - ); -} - /** * A shot's summary line: its style, detection mode and timing. * @param {object} shot @@ -79,8 +55,7 @@ export function describeShot(shot) { * NEXT when no scene in the project has a shot (both continue into the * following scenes). */ -export function presentSceneButtons( - elements, +export function sceneButtonsDisabled( running, hasRun, { hasScene = true, playableShots = 1, hasSelectedShot = true } = {}, @@ -92,6 +67,7 @@ export function presentSceneButtons( start: playableShots === 0, next: playableShots === 0, }; + const disabled = {}; for (const name of [ 'start', 'next', @@ -102,23 +78,14 @@ export function presentSceneButtons( 'update', 'export', 'import', - ]) { - if (elements[name]) elements[name].disabled = running || !!idleOff[name]; - } - if (elements.stop) elements.stop.disabled = !running; - if (elements.download) elements.download.disabled = !hasRun; - elements.panel?.classList.toggle('running', running); + ]) + disabled[name] = running || !!idleOff[name]; + disabled.stop = !running; + disabled.download = !hasRun; + return disabled; } -export function presentSceneProgress(element, progress) { - if (!element) return; - const percentage = Math.round(Math.max(0, Math.min(1, progress)) * 100); - element.style.width = `${percentage}%`; - element.textContent = `${percentage}%`; -} - -export function presentSceneRuntime(element, text) { - if (!element) return; - element.textContent = text; - element.classList.toggle('active', !!text); +/** @returns {number} Whole percent, 0–100. */ +export function scenePercent(progress) { + return Math.round(Math.max(0, Math.min(1, progress)) * 100); } diff --git a/src/ui/stores/scenes.js b/src/ui/stores/scenes.js index 230c3f1ef..0e961f1c9 100644 --- a/src/ui/stores/scenes.js +++ b/src/ui/stores/scenes.js @@ -7,6 +7,10 @@ import { markRaw, shallowRef } from 'vue'; * @property {(sceneId: string, shotId: string, title: string) => void} rename * @property {(sceneId: string, shotId: string) => void} load * @property {(sceneId: string, shotId: string) => void} remove + * @property {(sceneId: string) => void} selectScene The select's choice. + * @property {(file: File) => Promise|void} importFile A chosen file. + * @property {() => void} create, deleteScene, capture, update, start, stop, + * next, export, download The panel's buttons. */ /** @@ -24,6 +28,18 @@ export const useSceneStore = defineStore('gev-scenes', () => { // Counts option renders: each one re-applies the select's choice, which // the person may have changed since. const listedRevision = shallowRef(0); + /** + * The panel's buttons, status line, progress and runtime chip: + * `{running, hasShots, disabled: {[button]: boolean}, status, progress, + * runtime, playLabel}`, or null until an owner presents them. + */ + const panel = shallowRef(null); + + /** @param {?object} next A fresh presentation; every publish re-renders. */ + function publishPanel(next) { + panel.value = next && markRaw(next); + } + /** Whether an owner presents the project; until then the list is empty. */ const attached = shallowRef(false); /** @type {{handlers: SceneRowHandlers}|null} */ @@ -70,12 +86,18 @@ export const useSceneStore = defineStore('gev-scenes', () => { publishOptions({}); } + /** @returns {boolean} Whether `token` is the owner presenting now. */ + function owns(token) { + return !!token && token === owner; + } + /** * Forward a row action to the owner, if one is attached. * @param {keyof SceneRowHandlers} name + * @returns {*} What the owner's handler returned (an import's promise). */ function act(name, ...args) { - owner?.handlers[name]?.(...args); + return owner?.handlers[name]?.(...args); } return { @@ -85,11 +107,14 @@ export const useSceneStore = defineStore('gev-scenes', () => { listedSceneId, listedRevision, attached, + panel, + publishPanel, attach, publish, publishOptions, selectShot, detach, + owns, act, }; }); diff --git a/src/ui/views/ContextView.vue b/src/ui/views/ContextView.vue index 91e32d06a..a48257065 100644 --- a/src/ui/views/ContextView.vue +++ b/src/ui/views/ContextView.vue @@ -1,10 +1,12 @@ diff --git a/src/ui/views/LayerPanelsView.vue b/src/ui/views/LayerPanelsView.vue index 823f4d7ef..9d3366799 100644 --- a/src/ui/views/LayerPanelsView.vue +++ b/src/ui/views/LayerPanelsView.vue @@ -2,6 +2,8 @@ import { storeToRefs } from 'pinia'; import { t } from '../../i18n/index.js'; import { useSceneSharingStore } from '../stores/sceneSharing.js'; +import { useSceneStore } from '../stores/scenes.js'; +import { vClassToggle } from '../rail/classToggle.js'; import SceneDialog from './panels/SceneDialog.vue'; import SceneOptions from './SceneOptions.vue'; import SceneShotList from './SceneShotList.vue'; @@ -11,6 +13,24 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue'; // Scene sharing (src/scenes/sharing.js) offers its authoring bar and presents // its dialog through this store; without this view, neither is drawn. const { toolbar, dialog } = storeToRefs(useSceneSharingStore()); +// SceneControls presents the Scene panel's buttons, status and progress and +// answers the buttons through the store. +const scenes = useSceneStore(); +const { panel: scene } = storeToRefs(scenes); +const off = (name) => scene.value?.disabled[name] ?? false; +// The hidden file input is the IMPORT button's chooser, open only while an +// owner presents the panel. It is cleared once the import settles, so the same +// file can be chosen again, unless the owner went away meanwhile. +function chooseImport(input) { + if (scenes.attached) input.click(); +} +async function importChosen(event) { + const input = event.currentTarget; + const file = input.files?.[0]; + if (!file || !scenes.attached) return; + await scenes.act('importFile', file); + if (scenes.attached) input.value = ''; +}