mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(ui): render the Scene panel's shot list and scene options as components
SceneShotList and SceneOptions render the project from a gev-scenes store instead of scenePresentation building rows with createElement. SceneControls stays the owner: it publishes the director's state and attaches the row actions, and a late destroy cannot detach a newer owner. Rows keep their identity across selection; a rename keeps its typed text across republishing and ends with its row. The select still changes its choice only when its options are rendered. Vue keeps whitespace in every component, so Prettier formats single-file components with strict whitespace sensitivity, and the test loader parses templates with the same options as the browser build. The i18n check reads view text without its interpolations, whose t() calls are scanned with the sources.
This commit is contained in:
1 parent
6802c9c8b0
commit
d9fdcfc641
13 files changed
+518
-306
No files matched your search
+7
-1
@@ -2,5 +2,11 @@
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"endOfLine": "lf"
|
||||
"endOfLine": "lf",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.vue",
|
||||
"options": { "htmlWhitespaceSensitivity": "strict" }
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
-1
@@ -9,7 +9,11 @@ import { VUE_TEMPLATE_OPTIONS } from './vueOptions.js';
|
||||
* Styles are dropped: tests read the global stylesheet separately.
|
||||
*/
|
||||
export function compileVueModule(source, filename) {
|
||||
const { descriptor, errors } = parse(source, { filename });
|
||||
// The template is parsed with the SFC; parse it as the browser build does.
|
||||
const { descriptor, errors } = parse(source, {
|
||||
filename,
|
||||
templateParseOptions: VUE_TEMPLATE_OPTIONS.compilerOptions,
|
||||
});
|
||||
if (errors.length) throw errors[0];
|
||||
const id = filename;
|
||||
if (descriptor.scriptSetup) {
|
||||
|
||||
+6
-10
@@ -1,16 +1,12 @@
|
||||
/**
|
||||
* Template compiler options shared by the browser build, the boundary check
|
||||
* and the unit-test loader. Whitespace between nodes is kept so a view renders
|
||||
* as its static markup did: inline layouts depend on text between nodes.
|
||||
*/
|
||||
const VUE_COMPILER_OPTIONS = Object.freeze({ whitespace: 'preserve' });
|
||||
|
||||
/**
|
||||
* SFC template options. Asset URLs such as `/pin.svg` stay literal, as they
|
||||
* were in the static markup, instead of becoming module imports.
|
||||
* Template options shared by the browser build, the boundary check and the
|
||||
* unit-test loader. Whitespace between nodes is kept (as a single space) so
|
||||
* an application view renders as its static markup did; Prettier formats
|
||||
* single-file components with strict whitespace sensitivity to match. Asset
|
||||
* URLs such as `/pin.svg` stay literal instead of becoming module imports.
|
||||
*/
|
||||
export const VUE_TEMPLATE_OPTIONS = Object.freeze({
|
||||
compilerOptions: VUE_COMPILER_OPTIONS,
|
||||
compilerOptions: Object.freeze({ whitespace: 'preserve' }),
|
||||
transformAssetUrls: false,
|
||||
});
|
||||
|
||||
|
||||
+17
-5
@@ -61,11 +61,23 @@ They contain no application state.
|
||||
need no change. Views belong to the page and are never unmounted. The
|
||||
loading screen stays static so it paints before any script runs. Markup
|
||||
tests read `renderApplicationHtml()` from `src/testSupport`, which renders
|
||||
the views on the server. Vue keeps the whitespace between elements as a
|
||||
single space, so a view renders as its template did; no view markup sits
|
||||
under a `white-space: pre` rule. The markup has moved, unchanged: every
|
||||
template except the loading screen is a view. Next each view takes over
|
||||
its wiring. Order: Scenes, Weather, Recent imagery,
|
||||
the views on the server. Templates compile with Vue's `preserve`
|
||||
whitespace, which keeps the whitespace between elements as a single
|
||||
space, so a view renders as its template did; no view markup sits under a
|
||||
`white-space: pre` rule. Application views (`src/ui/views/*View.vue`) are
|
||||
kept out of Prettier; other components are formatted with strict
|
||||
whitespace sensitivity, so their source spacing is what renders. The
|
||||
markup has moved, unchanged:
|
||||
every template except the loading screen is a view.
|
||||
|
||||
Next each view takes over its wiring: markup an owner builds with
|
||||
`createElement` becomes component markup that renders from a Pinia store
|
||||
the owner publishes to, and user actions come back to the owner through
|
||||
the store, so the owner keeps every decision. Static markup that the
|
||||
translation pass rewrites (`data-i18n*`) stays outside `v-if` and
|
||||
`v-for`, because re-rendering it would bring back the English; component
|
||||
text uses `t()` instead. Order: Scenes (done: `SceneOptions` and
|
||||
`SceneShotList` over the `gev-scenes` store), Weather, Recent imagery,
|
||||
Street level, CCTV, Layers, Display, Context and Radio, the command dock,
|
||||
the phone shell, then Cockpit. Layers that write into the right rail
|
||||
directly gain a slot instead.
|
||||
|
||||
+8
-2
@@ -431,13 +431,19 @@ function markupFiles() {
|
||||
);
|
||||
}
|
||||
|
||||
/** A file's markup; for a single-file component, its template block. */
|
||||
/**
|
||||
* A file's markup; for a single-file component, its template block without
|
||||
* interpolations, which are code: their t() calls are read with the sources.
|
||||
*/
|
||||
function readMarkup(file) {
|
||||
const source = readFileSync(path.join(ROOT, file), 'utf8');
|
||||
if (!file.endsWith('.vue')) return source;
|
||||
const start = source.indexOf('<template>');
|
||||
const end = source.lastIndexOf('</template>');
|
||||
return start === -1 ? '' : source.slice(start + '<template>'.length, end);
|
||||
if (start === -1) return '';
|
||||
return source
|
||||
.slice(start + '<template>'.length, end)
|
||||
.replace(/\{\{[\s\S]*?\}\}/g, '');
|
||||
}
|
||||
|
||||
// Text that never reaches the page as UI: tool results and descriptions for
|
||||
|
||||
@@ -28,7 +28,23 @@
|
||||
"src/ui/views/LayerPanelsView.vue",
|
||||
"src/ui/views/ProviderSettingsView.vue",
|
||||
"src/ui/views/SceneChromeView.vue",
|
||||
"src/ui/views/WelcomeView.vue"
|
||||
"src/ui/views/WelcomeView.vue",
|
||||
"src/ui/views/SceneOptions.vue",
|
||||
"src/ui/views/SceneShotList.vue",
|
||||
"src/ui/stores/scenes.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
],
|
||||
"external": ["vue", "pinia"]
|
||||
},
|
||||
@@ -708,6 +724,9 @@
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/stores/scenes.js",
|
||||
"src/ui/views/SceneOptions.vue",
|
||||
"src/ui/views/SceneShotList.vue",
|
||||
"src/ui/actionButton.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
@@ -721,9 +740,10 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js"
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/vueRuntime.js"
|
||||
],
|
||||
"external": []
|
||||
"external": ["vue", "pinia"]
|
||||
},
|
||||
"ui-shell": {
|
||||
"exports": ["./ui/shell"],
|
||||
@@ -842,6 +862,9 @@
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/stores/scenes.js",
|
||||
"src/ui/views/SceneOptions.vue",
|
||||
"src/ui/views/SceneShotList.vue",
|
||||
"src/ui/actionButton.js",
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/shareFollow.js",
|
||||
@@ -979,6 +1002,9 @@
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/stores/scenes.js",
|
||||
"src/ui/views/SceneOptions.vue",
|
||||
"src/ui/views/SceneShotList.vue",
|
||||
"src/ui/actionButton.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/director/clock.js",
|
||||
@@ -1025,9 +1051,10 @@
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/visualStylePolicy.js"
|
||||
"src/visualStylePolicy.js",
|
||||
"src/ui/vueRuntime.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": ["cesium", "vue", "pinia"]
|
||||
},
|
||||
"live-observations": {
|
||||
"exports": ["./sources/live"],
|
||||
@@ -2519,6 +2546,9 @@
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/stores/scenes.js",
|
||||
"src/ui/views/SceneOptions.vue",
|
||||
"src/ui/views/SceneShotList.vue",
|
||||
"src/ui/actionButton.js",
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/shareFollow.js",
|
||||
|
||||
+35
-31
@@ -2,25 +2,47 @@
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import {
|
||||
sceneElements,
|
||||
renderSceneOptions,
|
||||
renderSceneShots,
|
||||
presentSceneSelection,
|
||||
presentSceneButtons,
|
||||
presentSceneProgress,
|
||||
presentSceneRuntime,
|
||||
} from './scenePresentation.js';
|
||||
import { useSceneStore } from './stores/scenes.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
|
||||
export class SceneControls {
|
||||
constructor({ read, actions, subscribe, elements = sceneElements() }) {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} [options.store] Scene store the panel views render.
|
||||
*/
|
||||
constructor({
|
||||
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.rowRemovers = [];
|
||||
this.playbackKeyRemover = null;
|
||||
if (!elements.select) return;
|
||||
// The SceneShotList view renders the rows; their actions come back here.
|
||||
this.storeToken = store.attach({
|
||||
select: (id) => {
|
||||
this.run('selectShot', id);
|
||||
if (this.destroyed) return;
|
||||
store.selectShot(this.read().selectedShotId);
|
||||
this.setButtons(this.read().running);
|
||||
},
|
||||
rename: (sceneId, shotId, title) =>
|
||||
this.run('renameShot', sceneId, shotId, title),
|
||||
load: (sceneId, shotId) => this.run('load', sceneId, shotId),
|
||||
remove: (sceneId, shotId) => this.deleteShot(sceneId, shotId),
|
||||
});
|
||||
// The radio's PLAY means "listen"; this one plays the scene.
|
||||
const playLabel = t('PLAY', { context: 'scene' });
|
||||
if (elements.start) elements.start.textContent = playLabel;
|
||||
@@ -96,7 +118,7 @@ export class SceneControls {
|
||||
)
|
||||
this.renderShotList();
|
||||
if (type === 'selection-changed')
|
||||
presentSceneSelection(this.elements.shots, state.selectedShotId);
|
||||
this.store.selectShot(state.selectedShotId);
|
||||
if (
|
||||
initial ||
|
||||
[
|
||||
@@ -127,13 +149,13 @@ export class SceneControls {
|
||||
this.setButtons(state.running);
|
||||
}
|
||||
|
||||
listen(target, type, callback, removers = this.removers) {
|
||||
listen(target, type, callback) {
|
||||
if (!target) return;
|
||||
const listener = (event) => {
|
||||
if (!this.destroyed) return callback(event);
|
||||
};
|
||||
target.addEventListener(type, listener);
|
||||
removers.push(() => target.removeEventListener(type, listener));
|
||||
this.removers.push(() => target.removeEventListener(type, listener));
|
||||
}
|
||||
|
||||
run(action, ...args) {
|
||||
@@ -151,8 +173,9 @@ export class SceneControls {
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish the project to the SceneOptions and SceneShotList views. */
|
||||
renderSceneSelect() {
|
||||
if (!this.destroyed) renderSceneOptions(this.elements.select, this.read());
|
||||
if (!this.destroyed) this.store.publishOptions(this.read());
|
||||
}
|
||||
|
||||
createScene() {
|
||||
@@ -193,22 +216,7 @@ export class SceneControls {
|
||||
}
|
||||
|
||||
renderShotList() {
|
||||
if (this.destroyed) return;
|
||||
for (const remove of this.rowRemovers.splice(0)) remove();
|
||||
renderSceneShots(this.elements.shots, this.read(), {
|
||||
listen: (element, type, callback) =>
|
||||
this.listen(element, type, callback, this.rowRemovers),
|
||||
select: (id) => {
|
||||
this.run('selectShot', id);
|
||||
if (this.destroyed) return;
|
||||
presentSceneSelection(this.elements.shots, this.read().selectedShotId);
|
||||
this.setButtons(this.read().running);
|
||||
},
|
||||
rename: (sceneId, shotId, title) =>
|
||||
this.run('renameShot', sceneId, shotId, title),
|
||||
load: (sceneId, shotId) => this.run('load', sceneId, shotId),
|
||||
remove: (sceneId, shotId) => this.deleteShot(sceneId, shotId),
|
||||
});
|
||||
if (!this.destroyed) this.store.publish(this.read());
|
||||
}
|
||||
|
||||
setButtons(running) {
|
||||
@@ -270,12 +278,8 @@ export class SceneControls {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
this.setPlaybackKeyboardEnabled(false);
|
||||
for (const remove of [
|
||||
...this.removers.splice(0),
|
||||
...this.rowRemovers.splice(0),
|
||||
])
|
||||
remove();
|
||||
if (this.elements.shots) this.elements.shots.textContent = '';
|
||||
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, '');
|
||||
this.setPlaybackActive(false);
|
||||
|
||||
+143
-124
@@ -1,88 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStateChannel } from '../app/stateChannel.js';
|
||||
import { SceneControls } from './sceneControls.js';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { sceneElements } from './scenePresentation.js';
|
||||
import { mountSceneSharing } from './sceneSharing.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { installVueDom } from '../testSupport/vueDom.mjs';
|
||||
|
||||
// Vue binds the document when it loads, so it loads after the DOM.
|
||||
let createApp;
|
||||
let nextTick;
|
||||
let SceneControls;
|
||||
let SceneDirector;
|
||||
let LayerPanelsView;
|
||||
let applicationPinia;
|
||||
|
||||
let uninstallDom;
|
||||
before(async () => {
|
||||
uninstallDom = installVueDom();
|
||||
({ createApp, nextTick } = await import('vue'));
|
||||
({ SceneControls } = await import('./sceneControls.js'));
|
||||
({ SceneDirector } = await import('../scenes/director.js'));
|
||||
({ applicationPinia } = await import('./vueRuntime.js'));
|
||||
LayerPanelsView = (await import('./views/LayerPanelsView.vue')).default;
|
||||
});
|
||||
after(() => uninstallDom());
|
||||
|
||||
/** Mount the real Scene panel view and an owner over it. */
|
||||
function fixture() {
|
||||
class Element extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.children = [];
|
||||
this.style = {};
|
||||
this.dataset = {};
|
||||
this.value = '';
|
||||
this._text = '';
|
||||
const classes = new Set();
|
||||
this.classList = {
|
||||
toggle(name, active) {
|
||||
if (active) classes.add(name);
|
||||
else classes.delete(name);
|
||||
},
|
||||
contains: (name) => classes.has(name),
|
||||
};
|
||||
}
|
||||
appendChild(element) {
|
||||
this.children.push(element);
|
||||
element.parentNode = this;
|
||||
}
|
||||
append(...nodes) {
|
||||
for (const node of nodes) this.appendChild(node);
|
||||
}
|
||||
remove() {
|
||||
if (this.parentNode)
|
||||
this.parentNode.children = this.parentNode.children.filter(
|
||||
(node) => node !== this,
|
||||
);
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
}
|
||||
focus() {}
|
||||
select() {}
|
||||
set textContent(value) {
|
||||
this._text = value;
|
||||
this.children = [];
|
||||
}
|
||||
get textContent() {
|
||||
return (
|
||||
this._text + this.children.map((node) => node.textContent).join('')
|
||||
);
|
||||
}
|
||||
click() {
|
||||
this.dispatchEvent(new Event('click'));
|
||||
}
|
||||
}
|
||||
const saved = { document: globalThis.document, window: globalThis.window };
|
||||
const document = Object.assign(new EventTarget(), {
|
||||
createElement: () => new Element(),
|
||||
body: new Element(),
|
||||
});
|
||||
globalThis.document = document;
|
||||
globalThis.window = { prompt: () => 'Renamed' };
|
||||
const elements = Object.fromEntries(
|
||||
[
|
||||
'panel',
|
||||
'select',
|
||||
'new',
|
||||
'delete',
|
||||
'capture',
|
||||
'update',
|
||||
'shots',
|
||||
'start',
|
||||
'stop',
|
||||
'next',
|
||||
'export',
|
||||
'import',
|
||||
'file',
|
||||
'download',
|
||||
'status',
|
||||
'progress',
|
||||
'runtime',
|
||||
].map((name) => [name, new Element()]),
|
||||
);
|
||||
const saved = { prompt: window.prompt, confirm: window.confirm };
|
||||
document.body.innerHTML =
|
||||
'<div id="layer-panels-host"></div><div id="scene-runtime"></div>';
|
||||
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 state = {
|
||||
scenes: [
|
||||
{
|
||||
@@ -137,21 +89,26 @@ function fixture() {
|
||||
elements,
|
||||
calls,
|
||||
document,
|
||||
/** The first shot row's label, once the view has rendered. */
|
||||
label: () => elements.shots.children[0]?.children[0].children[0],
|
||||
restore() {
|
||||
owner.destroy();
|
||||
Object.assign(globalThis, saved);
|
||||
view.unmount();
|
||||
Object.assign(window, saved);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Scene controls render the supplied selection and running/download states', () => {
|
||||
test('Scene controls render the supplied selection and running/download states', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await nextTick();
|
||||
assert.equal(f.elements.select.children[0].textContent, '<Scene A>');
|
||||
assert.equal(f.elements.select.value, 'scene-a');
|
||||
assert.equal(f.label().textContent, '<Shot A>');
|
||||
assert.equal(
|
||||
f.elements.shots.children[0].children[0].children[0].textContent,
|
||||
'<Shot A>',
|
||||
f.elements.shots.children[0].children[1].textContent,
|
||||
'NORMAL · OFF · 4.0s + 0.9s',
|
||||
);
|
||||
assert.equal(f.elements.stop.disabled, true);
|
||||
assert.equal(f.elements.download.disabled, true);
|
||||
@@ -211,16 +168,18 @@ test('Authoring disables idle actions that would do nothing', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('Scene controls dispatch explicit actions and revoke replaced shot-row listeners', () => {
|
||||
test('Scene controls dispatch panel and shot-row actions, and rows outlive republishing', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await nextTick();
|
||||
const row = f.elements.shots.children[0];
|
||||
const label = row.children[0].children[0];
|
||||
const label = f.label();
|
||||
const load = row.children[0].children[1].children[0];
|
||||
f.elements.capture.click();
|
||||
f.elements.start.click();
|
||||
label.click();
|
||||
label.dispatchEvent(new Event('dblclick'));
|
||||
await nextTick();
|
||||
label.children[0].value = 'Renamed';
|
||||
label.children[0].dispatchEvent(new Event('blur'));
|
||||
load.click();
|
||||
@@ -232,11 +191,21 @@ test('Scene controls dispatch explicit actions and revoke replaced shot-row list
|
||||
['load', 'scene-a', 'shot-a'],
|
||||
]);
|
||||
f.owner.renderShotList();
|
||||
label.click();
|
||||
load.click();
|
||||
assert.equal(f.calls.length, 5);
|
||||
f.elements.shots.children[0].children[0].children[1].children[0].click();
|
||||
assert.equal(f.calls.length, 6);
|
||||
await nextTick();
|
||||
assert.equal(f.elements.shots.children[0], row);
|
||||
// An owner destroyed after a newer one attached leaves the newer list.
|
||||
const newer = new SceneControls({
|
||||
read: () => f.state,
|
||||
actions: f.actions,
|
||||
elements: f.elements,
|
||||
});
|
||||
f.owner.destroy();
|
||||
await nextTick();
|
||||
f.label().click();
|
||||
assert.deepEqual(f.calls.at(-1), ['selectShot', 'shot-a']);
|
||||
newer.destroy();
|
||||
await nextTick();
|
||||
assert.equal(f.elements.shots.children.length, 0);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
@@ -270,7 +239,6 @@ test('Scene Escape and recording presentation follow playback and release on des
|
||||
);
|
||||
assert.equal(f.elements.runtime.textContent, '');
|
||||
assert.equal(f.owner.removers.length, 0);
|
||||
assert.equal(f.owner.rowRemovers.length, 0);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
@@ -284,8 +252,14 @@ test('a file import finishing after disposal cannot clear the retained file cont
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
f.elements.file.files = [{ name: 'project.json' }];
|
||||
f.elements.file.value = 'project.json';
|
||||
// A file input's files and value are read-only to scripts.
|
||||
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'));
|
||||
f.owner.destroy();
|
||||
finish();
|
||||
@@ -373,60 +347,55 @@ test('the real director preserves a selected shot label for the following double
|
||||
let director;
|
||||
try {
|
||||
f.owner.destroy();
|
||||
const ids = {
|
||||
'scene-panel': 'panel',
|
||||
'scene-select': 'select',
|
||||
'scene-new-btn': 'new',
|
||||
'scene-delete-btn': 'delete',
|
||||
'scene-capture-btn': 'capture',
|
||||
'scene-update-shot-btn': 'update',
|
||||
'scene-shot-list': 'shots',
|
||||
'scene-start-btn': 'start',
|
||||
'scene-stop-btn': 'stop',
|
||||
'scene-next-btn': 'next',
|
||||
'scene-export-btn': 'export',
|
||||
'scene-import-btn': 'import',
|
||||
'scene-import-file': 'file',
|
||||
'scene-download-btn': 'download',
|
||||
'scene-status': 'status',
|
||||
'scene-progress-fill': 'progress',
|
||||
'scene-runtime': 'runtime',
|
||||
};
|
||||
f.document.getElementById = (id) => f.elements[ids[id]] || null;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => JSON.stringify({ version: 3, scenes: f.state.scenes }),
|
||||
setItem() {},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
getItem: () => JSON.stringify({ version: 3, scenes: f.state.scenes }),
|
||||
setItem() {},
|
||||
},
|
||||
});
|
||||
director = new SceneDirector({ camera: { cancelFlight() {} } }, {}, {});
|
||||
const label = f.elements.shots.children[0].children[0].children[0];
|
||||
await nextTick();
|
||||
const label = f.label();
|
||||
label.click();
|
||||
assert.equal(f.elements.shots.children[0].children[0].children[0], label);
|
||||
await nextTick();
|
||||
assert.equal(f.label(), label);
|
||||
label.dispatchEvent(new Event('dblclick'));
|
||||
await nextTick();
|
||||
label.children[0].value = 'Renamed';
|
||||
label.children[0].dispatchEvent(new Event('blur'));
|
||||
assert.equal(director._getSelectedScene().shots[0].title, 'Renamed');
|
||||
} finally {
|
||||
await director?.destroy();
|
||||
globalThis.localStorage = previousStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: previousStorage,
|
||||
});
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('inline shot names save on Enter, cancel on Escape, and reject blank names', () => {
|
||||
test('inline shot names save on Enter, cancel on Escape, and reject blank names', async () => {
|
||||
const f = fixture();
|
||||
const key = (name) => Object.assign(new Event('keydown'), { key: name });
|
||||
const key = (name) => new KeyboardEvent('keydown', { key: name });
|
||||
try {
|
||||
const label = f.elements.shots.children[0].children[0].children[0];
|
||||
await nextTick();
|
||||
const label = f.label();
|
||||
for (const [value, commitKey] of [
|
||||
['Cancelled', 'Escape'],
|
||||
[' ', 'Enter'],
|
||||
[' New name ', 'Enter'],
|
||||
]) {
|
||||
label.dispatchEvent(new Event('dblclick'));
|
||||
await nextTick();
|
||||
const input = label.children[0];
|
||||
assert.equal(document.activeElement, input);
|
||||
input.value = value;
|
||||
input.dispatchEvent(key(commitKey));
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
await nextTick();
|
||||
}
|
||||
assert.deepEqual(f.calls, [
|
||||
['renameShot', 'scene-a', 'shot-a', 'New name'],
|
||||
@@ -441,7 +410,55 @@ test('inline shot names save on Enter, cancel on Escape, and reject blank names'
|
||||
}
|
||||
});
|
||||
|
||||
test('Scene controls consume current state, preserve rows on progress, and unsubscribe on destruction', () => {
|
||||
test('a rename keeps typed text across republishing and ends with its scene', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await nextTick();
|
||||
const label = f.label();
|
||||
label.dispatchEvent(new Event('dblclick'));
|
||||
await nextTick();
|
||||
label.children[0].value = 'Typing';
|
||||
f.owner.renderShotList();
|
||||
await nextTick();
|
||||
assert.equal(label.children[0].value, 'Typing');
|
||||
f.state.scenes.push({
|
||||
id: 'scene-b',
|
||||
title: 'B',
|
||||
shots: [{ id: 'b1', title: 'B1', durationSec: 1, holdSec: 1 }],
|
||||
});
|
||||
f.state.selectedSceneId = 'scene-b';
|
||||
f.owner.renderShotList();
|
||||
await nextTick();
|
||||
f.state.selectedSceneId = 'scene-a';
|
||||
f.owner.renderShotList();
|
||||
await nextTick();
|
||||
assert.equal(f.label().children.length, 0);
|
||||
assert.equal(f.label().textContent, '<Shot A>');
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the scene select follows option renders, as before', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await nextTick();
|
||||
f.state.scenes.push({ id: 'scene-b', title: 'B', shots: [] });
|
||||
f.owner.renderSceneSelect();
|
||||
await nextTick();
|
||||
f.state.selectedSceneId = 'scene-b';
|
||||
f.owner.renderShotList();
|
||||
await nextTick();
|
||||
assert.equal(f.elements.select.value, 'scene-a');
|
||||
f.owner.renderSceneSelect();
|
||||
await nextTick();
|
||||
assert.equal(f.elements.select.value, 'scene-b');
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Scene controls consume current state, preserve rows on progress, and unsubscribe on destruction', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.owner.destroy();
|
||||
@@ -460,9 +477,11 @@ test('Scene controls consume current state, preserve rows on progress, and unsub
|
||||
subscribe: (listener) => channel.subscribe(listener),
|
||||
});
|
||||
assert.equal(f.elements.status.textContent, 'Ready to resume');
|
||||
await nextTick();
|
||||
const row = f.elements.shots.children[0];
|
||||
f.state.progress = 0.5;
|
||||
channel.publish({ type: 'progress-changed' });
|
||||
await nextTick();
|
||||
assert.equal(f.elements.shots.children[0], row);
|
||||
f.state.status = 'Project exported';
|
||||
channel.publish({ type: 'project-exported' });
|
||||
|
||||
+26
-125
@@ -1,6 +1,6 @@
|
||||
/** Scene panel elements and text-only project presentation. */
|
||||
/** Scene panel elements and text-only project presentation; the shot list
|
||||
* and scene options are the SceneShotList and SceneOptions views. */
|
||||
import { formatNumber, msg, t } from '../i18n/index.js';
|
||||
import { createActionButton } from './actionButton.js';
|
||||
|
||||
// Labels for what a shot stores; anything else shows as stored. Style names
|
||||
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
|
||||
@@ -29,7 +29,6 @@ export function sceneElements(root = document) {
|
||||
delete: 'scene-delete-btn',
|
||||
capture: 'scene-capture-btn',
|
||||
update: 'scene-update-shot-btn',
|
||||
shots: 'scene-shot-list',
|
||||
start: 'scene-start-btn',
|
||||
stop: 'scene-stop-btn',
|
||||
next: 'scene-next-btn',
|
||||
@@ -46,129 +45,31 @@ export function sceneElements(root = document) {
|
||||
);
|
||||
}
|
||||
|
||||
export function renderSceneOptions(element, { scenes, selectedSceneId }) {
|
||||
if (!element) return;
|
||||
element.textContent = '';
|
||||
for (const scene of scenes) {
|
||||
const option = document.createElement('option');
|
||||
option.value = scene.id;
|
||||
option.textContent = scene.title;
|
||||
element.appendChild(option);
|
||||
}
|
||||
if (selectedSceneId) element.value = selectedSceneId;
|
||||
}
|
||||
|
||||
export function renderSceneShots(
|
||||
element,
|
||||
state,
|
||||
{ listen, select, rename, load, remove },
|
||||
) {
|
||||
if (!element) return;
|
||||
const scene = state.scenes.find((item) => item.id === state.selectedSceneId);
|
||||
element.textContent = '';
|
||||
if (!scene || scene.shots.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'scene-shot-empty';
|
||||
empty.textContent = t(
|
||||
'No shots yet. Use CAPTURE SHOT to save current look.',
|
||||
);
|
||||
element.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const shot of scene.shots) {
|
||||
// Built-in titles arrive localized as displayTitle; the stored title
|
||||
// stays English, so a rename happens only when the shown text changes.
|
||||
const shownTitle = shot.displayTitle || shot.title;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'scene-shot-row';
|
||||
row.dataset.sceneShotId = shot.id;
|
||||
row.classList.toggle('active', shot.id === state.selectedShotId);
|
||||
const top = document.createElement('div');
|
||||
top.className = 'scene-shot-top';
|
||||
const label = document.createElement('div');
|
||||
label.className = 'scene-shot-label';
|
||||
label.textContent = shownTitle;
|
||||
label.title = t('Double-click to rename');
|
||||
listen(label, 'click', () => select(shot.id));
|
||||
listen(label, 'dblclick', () => {
|
||||
if (label.children.length) return;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'scene-shot-rename';
|
||||
input.setAttribute('aria-label', t('Shot name'));
|
||||
input.value = shownTitle;
|
||||
let finished = false;
|
||||
const finish = (save) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
const title = input.value.trim();
|
||||
label.textContent = shownTitle;
|
||||
if (save && title && title !== shownTitle)
|
||||
rename(scene.id, shot.id, title);
|
||||
};
|
||||
listen(input, 'keydown', (event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter' || event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
finish(event.key === 'Enter');
|
||||
}
|
||||
});
|
||||
listen(input, 'blur', () => finish(true));
|
||||
label.textContent = '';
|
||||
label.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
/**
|
||||
* A shot's summary line: its style, detection mode and timing.
|
||||
* @param {object} shot
|
||||
* @returns {string}
|
||||
*/
|
||||
export function describeShot(shot) {
|
||||
const mode = shot.visual?.detection?.mode || 'OFF';
|
||||
const style = shot.visual?.style || 'normal';
|
||||
const styleLabel = STYLE_LABELS[style]
|
||||
? t(STYLE_LABELS[style])
|
||||
: style.toUpperCase();
|
||||
const modeLabel = DETECTION_MODE_LABELS[mode]
|
||||
? t(DETECTION_MODE_LABELS[mode])
|
||||
: mode;
|
||||
const seconds = (value) =>
|
||||
formatNumber(value, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'scene-shot-actions';
|
||||
const loadButton = createActionButton({
|
||||
label: t('LOAD'),
|
||||
className: 'scene-shot-btn',
|
||||
size: 'compact',
|
||||
});
|
||||
listen(loadButton, 'click', () => load(scene.id, shot.id));
|
||||
const deleteButton = createActionButton({
|
||||
label: t('DEL'),
|
||||
className: 'scene-shot-btn scene-shot-danger',
|
||||
size: 'compact',
|
||||
variant: 'danger',
|
||||
});
|
||||
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
|
||||
actions.appendChild(loadButton);
|
||||
actions.appendChild(deleteButton);
|
||||
top.appendChild(label);
|
||||
top.appendChild(actions);
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'scene-shot-meta';
|
||||
const mode = shot.visual?.detection?.mode || 'OFF';
|
||||
const style = shot.visual?.style || 'normal';
|
||||
const styleLabel = STYLE_LABELS[style]
|
||||
? t(STYLE_LABELS[style])
|
||||
: style.toUpperCase();
|
||||
const modeLabel = DETECTION_MODE_LABELS[mode]
|
||||
? t(DETECTION_MODE_LABELS[mode])
|
||||
: mode;
|
||||
const seconds = (value) =>
|
||||
formatNumber(value, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
// English keeps its compact "4.0s + 0.9s"; other languages space the unit.
|
||||
const timing = t('{duration}s + {hold}s', {
|
||||
duration: seconds(shot.durationSec),
|
||||
hold: seconds(shot.holdSec),
|
||||
});
|
||||
meta.textContent = `${styleLabel} · ${modeLabel} · ${timing}`;
|
||||
row.appendChild(top);
|
||||
row.appendChild(meta);
|
||||
element.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
export function presentSceneSelection(element, selectedShotId) {
|
||||
for (const row of element?.children || []) {
|
||||
row.classList.toggle('active', row.dataset.sceneShotId === selectedShotId);
|
||||
}
|
||||
// English keeps its compact "4.0s + 0.9s"; other languages space the unit.
|
||||
const timing = t('{duration}s + {hold}s', {
|
||||
duration: seconds(shot.durationSec),
|
||||
hold: seconds(shot.holdSec),
|
||||
});
|
||||
return `${styleLabel} · ${modeLabel} · ${timing}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { markRaw, shallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* @typedef {object} SceneRowHandlers
|
||||
* @property {(shotId: string) => void} select
|
||||
* @property {(sceneId: string, shotId: string, title: string) => void} rename
|
||||
* @property {(sceneId: string, shotId: string) => void} load
|
||||
* @property {(sceneId: string, shotId: string) => void} remove
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Scene panel's project list as the panel views render it. SceneControls
|
||||
* owns the project presentation: it publishes here and attaches the row
|
||||
* actions; the views only render and forward clicks.
|
||||
*/
|
||||
export const useSceneStore = defineStore('gev-scenes', () => {
|
||||
// Scenes arrive as fresh read-only copies; only replacing them re-renders.
|
||||
const scenes = shallowRef(markRaw([]));
|
||||
const selectedSceneId = shallowRef(null);
|
||||
const selectedShotId = shallowRef(null);
|
||||
// The select's choice, updated only when its options are rendered.
|
||||
const listedSceneId = shallowRef(null);
|
||||
/** Whether an owner presents the project; until then the list is empty. */
|
||||
const attached = shallowRef(false);
|
||||
/** @type {{handlers: SceneRowHandlers}|null} */
|
||||
let owner = null;
|
||||
|
||||
/**
|
||||
* Present the project for an owner, replacing any earlier owner.
|
||||
* @param {SceneRowHandlers} handlers
|
||||
* @returns {object} Token for detach().
|
||||
*/
|
||||
function attach(handlers) {
|
||||
owner = { handlers };
|
||||
attached.value = true;
|
||||
return owner;
|
||||
}
|
||||
|
||||
/** Publish the shot list; `scenes` is copied so every publish re-renders. */
|
||||
function publish(state) {
|
||||
scenes.value = markRaw([...(state.scenes ?? [])]);
|
||||
selectedSceneId.value = state.selectedSceneId ?? null;
|
||||
selectedShotId.value = state.selectedShotId ?? null;
|
||||
}
|
||||
|
||||
/** Publish the project and the select's choice. */
|
||||
function publishOptions(state) {
|
||||
publish(state);
|
||||
listedSceneId.value = state.selectedSceneId ?? null;
|
||||
}
|
||||
|
||||
/** @param {string|null} id */
|
||||
function selectShot(id) {
|
||||
selectedShotId.value = id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop presenting for the owner holding `token`: the list empties and row
|
||||
* clicks do nothing. A later owner's presentation is left alone.
|
||||
*/
|
||||
function detach(token) {
|
||||
if (token !== owner) return;
|
||||
owner = null;
|
||||
attached.value = false;
|
||||
publishOptions({});
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a row action to the owner, if one is attached.
|
||||
* @param {keyof SceneRowHandlers} name
|
||||
*/
|
||||
function act(name, ...args) {
|
||||
owner?.handlers[name]?.(...args);
|
||||
}
|
||||
|
||||
return {
|
||||
scenes,
|
||||
selectedSceneId,
|
||||
selectedShotId,
|
||||
listedSceneId,
|
||||
attached,
|
||||
attach,
|
||||
publish,
|
||||
publishOptions,
|
||||
selectShot,
|
||||
detach,
|
||||
act,
|
||||
};
|
||||
});
|
||||
@@ -1,3 +1,8 @@
|
||||
<script setup>
|
||||
import SceneOptions from './SceneOptions.vue';
|
||||
import SceneShotList from './SceneShotList.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="left-panel-stack">
|
||||
<!-- Data Layer Toggle Panel -->
|
||||
@@ -154,7 +159,7 @@
|
||||
</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></select>
|
||||
<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>
|
||||
</div>
|
||||
@@ -163,7 +168,7 @@
|
||||
<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>
|
||||
</div>
|
||||
<div id="scene-shot-list" class="scene-shot-list"></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">
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useSceneStore } from '../stores/scenes.js';
|
||||
|
||||
// The Scene select's options; selecting one stays with SceneControls.
|
||||
const { scenes, listedSceneId } = storeToRefs(useSceneStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<option
|
||||
v-for="scene in scenes"
|
||||
:key="scene.id"
|
||||
:value="scene.id"
|
||||
:selected="scene.id === listedSceneId"
|
||||
>{{ scene.title }}</option
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { describeShot } from '../scenePresentation.js';
|
||||
import { useSceneStore } from '../stores/scenes.js';
|
||||
|
||||
// The selected scene's shots. Rows keep their identity across selection so a
|
||||
// click followed by a double-click lands on the same label.
|
||||
const store = useSceneStore();
|
||||
const { scenes, selectedSceneId, selectedShotId, attached } =
|
||||
storeToRefs(store);
|
||||
const scene = computed(
|
||||
() => scenes.value.find((item) => item.id === selectedSceneId.value) ?? null,
|
||||
);
|
||||
/** The row being renamed, with the scene it was shown in. */
|
||||
const editing = ref(null);
|
||||
const renameInput = ref(null);
|
||||
|
||||
// A rename ends with its row: another scene, a deleted shot or no owner.
|
||||
watch([scene, attached], () => {
|
||||
if (
|
||||
editing.value &&
|
||||
!(
|
||||
attached.value &&
|
||||
scene.value?.id === editing.value.sceneId &&
|
||||
scene.value.shots.some((shot) => shot.id === editing.value.shotId)
|
||||
)
|
||||
)
|
||||
editing.value = null;
|
||||
});
|
||||
|
||||
// Built-in titles arrive localized as displayTitle; the stored title stays
|
||||
// English, so a rename happens only when the shown text changes.
|
||||
const shownTitle = (shot) => shot.displayTitle || shot.title;
|
||||
const isEditing = (shot) => editing.value?.shotId === shot.id;
|
||||
|
||||
function act(name, shot, ...args) {
|
||||
if (scene.value) store.act(name, scene.value.id, shot.id, ...args);
|
||||
}
|
||||
|
||||
async function startRename(shot) {
|
||||
if (isEditing(shot) || !scene.value) return;
|
||||
editing.value = { sceneId: scene.value.id, shotId: shot.id };
|
||||
await nextTick();
|
||||
const input = renameInput.value?.[0];
|
||||
if (!input) return;
|
||||
// Set once: a re-render while typing keeps what was typed.
|
||||
input.value = shownTitle(shot);
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
function finishRename(shot, input, save) {
|
||||
const rename = editing.value;
|
||||
if (rename?.shotId !== shot.id) return;
|
||||
editing.value = null;
|
||||
const title = input.value.trim();
|
||||
if (save && title && title !== shownTitle(shot))
|
||||
store.act('rename', rename.sceneId, shot.id, title);
|
||||
}
|
||||
|
||||
function onRenameKey(shot, event) {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter' || event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
finishRename(shot, event.target, event.key === 'Enter');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="scene-shot-list" class="scene-shot-list">
|
||||
<template v-if="attached">
|
||||
<div v-if="!scene?.shots.length" class="scene-shot-empty">{{
|
||||
t('No shots yet. Use CAPTURE SHOT to save current look.')
|
||||
}}</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="shot in scene.shots"
|
||||
:key="shot.id"
|
||||
class="scene-shot-row"
|
||||
:class="{ active: shot.id === selectedShotId }"
|
||||
:data-scene-shot-id="shot.id"
|
||||
>
|
||||
<div class="scene-shot-top">
|
||||
<div
|
||||
class="scene-shot-label"
|
||||
:title="t('Double-click to rename')"
|
||||
@click="store.act('select', shot.id)"
|
||||
@dblclick="startRename(shot)"
|
||||
><input
|
||||
v-if="isEditing(shot)"
|
||||
ref="renameInput"
|
||||
type="text"
|
||||
class="scene-shot-rename"
|
||||
:aria-label="t('Shot name')"
|
||||
@keydown="onRenameKey(shot, $event)"
|
||||
@blur="finishRename(shot, $event.target, true)"
|
||||
/><template v-else>{{ shownTitle(shot) }}</template></div
|
||||
>
|
||||
<div class="scene-shot-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="gev-action-button gev-action-button--compact scene-shot-btn"
|
||||
@click="act('load', shot)"
|
||||
>{{ t('LOAD') }}</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="gev-action-button gev-action-button--danger gev-action-button--compact scene-shot-btn scene-shot-danger"
|
||||
@click="act('remove', shot)"
|
||||
>{{ t('DEL') }}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scene-shot-meta">{{ describeShot(shot) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user