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 = ''; +}