mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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.
This commit is contained in:
1 parent
0adcd61184
commit
ec4c55e78c
10 files changed
+245
-148
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
+54
-66
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
+12
-45
@@ -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);
|
||||
}
|
||||
+26
-1
@@ -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>|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,
|
||||
};
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
import { useCockpitStore } from '../stores/cockpit.js';
|
||||
import { useBhoteKoshiPanelStore } from '../stores/bhoteKoshiPanel.js';
|
||||
import { useContextStore } from '../stores/context.js';
|
||||
import { useSceneStore } from '../stores/scenes.js';
|
||||
import AwarenessPanel from './panels/AwarenessPanel.vue';
|
||||
import BhoteKoshiPanel from './panels/BhoteKoshiPanel.vue';
|
||||
import MissionDetail from './panels/MissionDetail.vue';
|
||||
@@ -17,6 +19,9 @@ const { view: ctx } = storeToRefs(useContextStore());
|
||||
const { presence: cockpit } = storeToRefs(useCockpitStore());
|
||||
// While the Bhote Koshi event is on, its panel is the rail's only content.
|
||||
const { panel: bhotePanel } = storeToRefs(useBhoteKoshiPanelStore());
|
||||
// The Scene runtime chip SceneControls presents while a scene plays.
|
||||
const { panel: scenePanel } = storeToRefs(useSceneStore());
|
||||
const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -93,5 +98,5 @@ const { panel: bhotePanel } = storeToRefs(useBhoteKoshiPanelStore());
|
||||
<BhoteKoshiPanel />
|
||||
</aside>
|
||||
|
||||
<div id="scene-runtime"></div>
|
||||
<div id="scene-runtime" :class="{ active: !!sceneRuntime }">{{ sceneRuntime }}</div>
|
||||
</template>
|
||||
@@ -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 = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -35,7 +55,7 @@ const { toolbar, dialog } = storeToRefs(useSceneSharingStore());
|
||||
<StreetLevelPanel />
|
||||
|
||||
<!-- Scene Director Panel -->
|
||||
<div id="scene-panel" class="panel-collapsible collapsed" data-panel-id="scene-panel">
|
||||
<div id="scene-panel" v-class-toggle="{ running: scene?.running, 'has-shots': scene?.hasShots }" class="panel-collapsible collapsed" data-panel-id="scene-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="scene-panel-inner ui-scroll">
|
||||
<div class="panel-header">
|
||||
@@ -45,38 +65,38 @@ const { toolbar, dialog } = storeToRefs(useSceneSharingStore());
|
||||
</div>
|
||||
<p class="scene-step-heading" data-i18n>SCENE</p>
|
||||
<div class="scene-controls">
|
||||
<select class="gev-field" id="scene-select" aria-label="Scene" data-i18n-aria-label><SceneOptions /></select>
|
||||
<button id="scene-new-btn" type="button" class="gev-action-button scene-btn" title="Start a new, empty scene" data-i18n data-i18n-title>NEW SCENE</button>
|
||||
<button id="scene-delete-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" title="Delete this scene and all of its shots" data-i18n data-i18n-title>DELETE</button>
|
||||
<select class="gev-field" id="scene-select" aria-label="Scene" data-i18n-aria-label :disabled="off('select')" @change="scenes.act('selectScene', $event.currentTarget.value)"><SceneOptions /></select>
|
||||
<button id="scene-new-btn" type="button" :disabled="off('new')" @click="scenes.act('create')" class="gev-action-button scene-btn" title="Start a new, empty scene" data-i18n data-i18n-title>NEW SCENE</button>
|
||||
<button id="scene-delete-btn" type="button" :disabled="off('delete')" @click="scenes.act('deleteScene')" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" title="Delete this scene and all of its shots" data-i18n data-i18n-title>DELETE</button>
|
||||
</div>
|
||||
<p class="scene-step-heading" data-i18n>SHOTS</p>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-capture-btn" type="button" class="gev-action-button scene-btn scene-btn-primary" title="Add the current view, style and layers as the next shot" data-i18n data-i18n-title>+ CAPTURE SHOT</button>
|
||||
<button id="scene-update-shot-btn" type="button" class="gev-action-button scene-btn" title="Replace the selected shot with the current view" data-i18n data-i18n-title>UPDATE SHOT</button>
|
||||
<button id="scene-capture-btn" type="button" :disabled="off('capture')" @click="scenes.act('capture')" class="gev-action-button scene-btn scene-btn-primary" title="Add the current view, style and layers as the next shot" data-i18n data-i18n-title>+ CAPTURE SHOT</button>
|
||||
<button id="scene-update-shot-btn" type="button" :disabled="off('update')" @click="scenes.act('update')" class="gev-action-button scene-btn" title="Replace the selected shot with the current view" data-i18n data-i18n-title>UPDATE SHOT</button>
|
||||
</div>
|
||||
<SceneShotList />
|
||||
<p class="scene-shot-hint" data-i18n>Click a shot to select it, double-click to rename.</p>
|
||||
<p class="scene-step-heading" data-i18n>PREVIEW</p>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-start-btn" type="button" class="gev-action-button scene-btn scene-btn-primary" title="Play from this scene through the rest of the project" data-i18n-title data-i18n-runtime>PLAY</button>
|
||||
<button id="scene-stop-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" title="Stop playback (Esc)" data-i18n data-i18n-title>STOP</button>
|
||||
<button id="scene-next-btn" type="button" class="gev-action-button scene-btn" title="Play the shot after the selected one, moving on to the next scene when needed" data-i18n data-i18n-title>NEXT</button>
|
||||
<button id="scene-start-btn" type="button" class="gev-action-button scene-btn scene-btn-primary" title="Play from this scene through the rest of the project" data-i18n-title :disabled="off('start')" @click="scenes.act('start')">{{ scene?.playLabel ?? t('PLAY', { context: 'scene' }) }}</button>
|
||||
<button id="scene-stop-btn" type="button" :disabled="off('stop')" @click="scenes.act('stop')" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" title="Stop playback (Esc)" data-i18n data-i18n-title>STOP</button>
|
||||
<button id="scene-next-btn" type="button" :disabled="off('next')" @click="scenes.act('next')" class="gev-action-button scene-btn" title="Play the shot after the selected one, moving on to the next scene when needed" data-i18n data-i18n-title>NEXT</button>
|
||||
</div>
|
||||
<p class="scene-step-heading" data-i18n>SAVE & SHARE</p>
|
||||
<div class="scene-controls scene-file-controls">
|
||||
<button id="scene-export-btn" type="button" class="gev-action-button scene-btn" title="Download every scene as a JSON file you can import later" data-i18n data-i18n-title>EXPORT</button>
|
||||
<button id="scene-import-btn" type="button" class="gev-action-button scene-btn" title="Load scenes from an exported JSON file" data-i18n data-i18n-title>IMPORT</button>
|
||||
<button id="scene-download-btn" type="button" class="gev-action-button scene-btn" title="Download the timing log of the last play" data-i18n data-i18n-title>RUN LOG</button>
|
||||
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
|
||||
<button id="scene-export-btn" type="button" :disabled="off('export')" @click="scenes.act('export')" class="gev-action-button scene-btn" title="Download every scene as a JSON file you can import later" data-i18n data-i18n-title>EXPORT</button>
|
||||
<button id="scene-import-btn" type="button" :disabled="off('import')" @click="chooseImport($refs.sceneFile)" class="gev-action-button scene-btn" title="Load scenes from an exported JSON file" data-i18n data-i18n-title>IMPORT</button>
|
||||
<button id="scene-download-btn" type="button" :disabled="off('download')" @click="scenes.act('download')" class="gev-action-button scene-btn" title="Download the timing log of the last play" data-i18n data-i18n-title>RUN LOG</button>
|
||||
<input ref="sceneFile" type="file" id="scene-import-file" accept="application/json,.json" hidden @change="importChosen" />
|
||||
</div>
|
||||
<div v-if="toolbar" data-director-authoring class="scene-controls">
|
||||
<button type="button" class="gev-action-button scene-btn" :title="t('Edit anchors, data packs and shot details')" @click="toolbar.edit()">{{ t('EDIT DETAILS') }}</button>
|
||||
<button type="button" class="gev-action-button scene-btn" :title="t('Download this scene as JSON or an asset bundle')" @click="toolbar.share()">{{ t('SHARE SCENE') }}</button>
|
||||
</div>
|
||||
<div class="scene-progress">
|
||||
<div id="scene-progress-fill">0%</div>
|
||||
<div id="scene-progress-fill" :style="scene ? { width: `${scene.progress}%` } : undefined">{{ scene?.progress ?? 0 }}%</div>
|
||||
</div>
|
||||
<div id="scene-status" role="status" aria-live="polite" data-i18n>Ready</div>
|
||||
<div id="scene-status" role="status" aria-live="polite">{{ scene?.status ?? t('Ready') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user