mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat(ui): render the scene sharing dialogs and bar from a store
createSceneDialog keeps its builder but publishes the dialog's lines, fields, buttons and status to a scene sharing store; SceneDialog.vue, teleported to the body from the scene panels view, draws the same modal with the same styles and opens it with Cancel focused. The EDIT DETAILS / SHARE SCENE bar is drawn by that view while sharing offers it. The status directive is shared with Provider Settings, and the two QA scripts that drove the old DOM read the new dialog.
This commit is contained in:
1 parent
fe963a1219
commit
9d9eb267c5
15 files changed
+842
-355
No files matched your search
@@ -20,6 +20,10 @@
|
||||
takes `omitViews` (for example `['provider-settings']`) for views an
|
||||
application leaves out (samehkhamis).
|
||||
|
||||
- `./scenes` presents the scene sharing dialogs and the EDIT DETAILS / SHARE
|
||||
SCENE bar through a store that the scene panels view renders, so a page
|
||||
that omits `layer-panels` shows neither (samehkhamis).
|
||||
|
||||
- `./ui/display`'s `bindDisplayControls` takes the keyhole fade and outside
|
||||
opacity sliders as `fadeSlider` and `outsideOpacitySlider`, and their
|
||||
actions receive whole percentages; `fadeSliders` still works.
|
||||
|
||||
+3
-2
@@ -205,8 +205,9 @@ Provider Settings. A page places each Vue view in `src/ui/views` with a host,
|
||||
screen (`#loading-screen` with a `.loader-status` line) as static markup so it
|
||||
paints before any script runs. `createApplication()` renders the views before
|
||||
anything reads the page; `omitViews` names views the application leaves out,
|
||||
such as `provider-settings`, and their hosts are removed. An unknown view name
|
||||
is an error. A host that does not initialize Core's locale passes
|
||||
such as `provider-settings`, and their hosts are removed. Leaving out
|
||||
`layer-panels` also leaves out the scene sharing dialogs it draws. An unknown
|
||||
view name is an error. A host that does not initialize Core's locale passes
|
||||
`localePicker: false` to `createApplicationControls` instead of editing the
|
||||
markup. Node tests that import application modules register the component
|
||||
loader with `node --import=<core package>/build/register-vue`.
|
||||
@@ -28,6 +28,7 @@
|
||||
"src/ui/views/panels/SdrCard.vue",
|
||||
"src/ui/views/panels/RadioTuner.vue",
|
||||
"src/ui/views/panels/LocalePicker.vue",
|
||||
"src/ui/views/panels/SceneDialog.vue",
|
||||
"src/ui/localePicker.js",
|
||||
"src/ui/views/panels/PhoneTabBar.vue",
|
||||
"src/ui/views/panels/CockpitBriefing.vue",
|
||||
@@ -76,6 +77,7 @@
|
||||
"src/ui/stores/cockpit.js",
|
||||
"src/ui/stores/locale.js",
|
||||
"src/ui/stores/providerSettings.js",
|
||||
"src/ui/stores/sceneSharing.js",
|
||||
"src/ui/stores/location.js",
|
||||
"src/ui/stores/streetLevel.js",
|
||||
"src/ui/rail/RailChips.vue",
|
||||
@@ -84,7 +86,8 @@
|
||||
"src/ui/rail/rangeValue.js",
|
||||
"src/ui/rail/classToggle.js",
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/rail/fieldValue.js"
|
||||
"src/ui/rail/fieldValue.js",
|
||||
"src/ui/rail/statusText.js"
|
||||
],
|
||||
"external": ["vue", "pinia"]
|
||||
},
|
||||
@@ -1004,6 +1007,7 @@
|
||||
"src/ui/stores/cockpit.js",
|
||||
"src/ui/stores/locale.js",
|
||||
"src/ui/stores/providerSettings.js",
|
||||
"src/ui/stores/sceneSharing.js",
|
||||
"src/ui/stores/location.js",
|
||||
"src/ui/stores/display.js"
|
||||
],
|
||||
@@ -1118,6 +1122,7 @@
|
||||
"src/director/authoring.js",
|
||||
"src/scenes/sharing.js",
|
||||
"src/ui/sceneSharing.js",
|
||||
"src/ui/stores/sceneSharing.js",
|
||||
"src/data/cameraArrival.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
@@ -2816,6 +2821,7 @@
|
||||
"src/director/authoring.js",
|
||||
"src/scenes/sharing.js",
|
||||
"src/ui/sceneSharing.js",
|
||||
"src/ui/stores/sceneSharing.js",
|
||||
"src/layers/weather/imageryOrder.js",
|
||||
"src/layers/weather/imageryHost.js",
|
||||
"src/layers/cyclones/index.js",
|
||||
@@ -2983,6 +2989,7 @@
|
||||
"src/ui/stores/cockpit.js",
|
||||
"src/ui/stores/locale.js",
|
||||
"src/ui/stores/providerSettings.js",
|
||||
"src/ui/stores/sceneSharing.js",
|
||||
"src/ui/stores/location.js",
|
||||
"src/ui/stores/display.js"
|
||||
],
|
||||
|
||||
@@ -427,6 +427,9 @@ try {
|
||||
shot.interactions = [];
|
||||
fields[0].value = JSON.stringify(scene);
|
||||
fields[1].value = JSON.stringify(shot);
|
||||
// Typing fires input; the dialog reads its fields from those events.
|
||||
for (const field of fields)
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await page.click('[data-director-apply-details]');
|
||||
await page.waitForFunction(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* file dropped from the desktop, proved in a real browser.
|
||||
*
|
||||
* The dialog draws a translated row over the native file input
|
||||
* (src/ui/sceneSharing.js, fileChooser). The input must still cover the row,
|
||||
* (src/ui/views/panels/SceneDialog.vue). The input must still cover the row,
|
||||
* or drops miss it. Drops a file on the row's centre via CDP and expects
|
||||
* `input.files` to hold it.
|
||||
*
|
||||
@@ -15,31 +15,34 @@
|
||||
* drop selects the file, 1 otherwise.
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { createServer } from 'vite';
|
||||
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TYPES = {
|
||||
'.js': 'text/javascript',
|
||||
'.mjs': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.html': 'text/html',
|
||||
};
|
||||
|
||||
// The scene panels view draws the dialog; Vite compiles it for the page.
|
||||
const PAGE = `<!doctype html><html lang="en"><body>
|
||||
<div id="view"></div>
|
||||
<script type="module">
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { applicationPinia } from '/src/ui/vueRuntime.js';
|
||||
import LayerPanelsView from '/src/ui/views/LayerPanelsView.vue';
|
||||
import { createSceneDialog } from '/src/ui/sceneSharing.js';
|
||||
window.openDialog = () => {
|
||||
createApp(LayerPanelsView).use(applicationPinia()).mount('#view');
|
||||
window.openDialog = async () => {
|
||||
const dialog = createSceneDialog('Share selected scene', () => {});
|
||||
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
|
||||
files.multiple = true;
|
||||
window.filesInput = files;
|
||||
// Centre of the visible row (the wrapper's last child).
|
||||
const row = files.parentElement.lastElementChild;
|
||||
const r = row.getBoundingClientRect();
|
||||
dialog.input('Choose data-pack files', '', { type: 'file', multiple: true });
|
||||
await nextTick();
|
||||
const input = document.querySelector('[data-director-dialog] input[type=file]');
|
||||
window.filesInput = input;
|
||||
// Centre of the visible row (the label's last child).
|
||||
const r = input.parentElement.lastElementChild.getBoundingClientRect();
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
};
|
||||
window.ready = true;
|
||||
@@ -56,21 +59,21 @@ async function main() {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-'));
|
||||
const pack = path.join(dir, 'pack.json');
|
||||
await writeFile(pack, '{}');
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const vite = await createServer({
|
||||
root: ROOT,
|
||||
configFile: false,
|
||||
logLevel: 'error',
|
||||
plugins: [vue(VUE_PLUGIN_OPTIONS)],
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
appType: 'custom',
|
||||
});
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/') {
|
||||
res.writeHead(200, { 'content-type': 'text/html' });
|
||||
res.end(PAGE);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readFile(path.join(ROOT, req.url));
|
||||
res.writeHead(200, {
|
||||
'content-type': TYPES[path.extname(req.url)] || 'text/plain',
|
||||
});
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
vite.middlewares(req, res, () => res.writeHead(404).end());
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const url = `http://127.0.0.1:${server.address().port}/`;
|
||||
@@ -99,6 +102,7 @@ async function main() {
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
await vite.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
+66
-58
@@ -89,12 +89,13 @@ export function createSceneSharing(director) {
|
||||
await job();
|
||||
} catch (error) {
|
||||
if (alive(owner))
|
||||
dialog.status.textContent =
|
||||
dialog.setStatus(
|
||||
error?.name === 'SceneDocumentError'
|
||||
? error.message
|
||||
: t(
|
||||
'Could not complete this action. Check the file, references and selected assets.',
|
||||
);
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (alive(owner)) busy = false;
|
||||
}
|
||||
@@ -157,33 +158,38 @@ export function createSceneSharing(director) {
|
||||
if (!alive(owner)) return;
|
||||
staged = input;
|
||||
inventory(input);
|
||||
dialog.status.textContent = t('Ready to import');
|
||||
dialog.setStatus(t('Ready to import'));
|
||||
dialog.text(
|
||||
t(
|
||||
'Apply replaces the current project. Export your current project first if you want to keep both.',
|
||||
),
|
||||
);
|
||||
const apply = dialog.button(t('Apply import'), () =>
|
||||
run(owner, async () => {
|
||||
if (expected !== currentProject()) throw new Error('Project changed');
|
||||
apply.disabled = true;
|
||||
const ok = await director.importProjectFile(file, {
|
||||
prepared: input,
|
||||
expectedProject: expected,
|
||||
signal: owner.signal,
|
||||
});
|
||||
if (alive(owner)) {
|
||||
if (ok) close();
|
||||
else {
|
||||
apply.disabled = false;
|
||||
dialog.status.textContent = t(
|
||||
'Import was not applied. The current project may have changed.',
|
||||
);
|
||||
const apply = dialog.button(
|
||||
t('Apply import'),
|
||||
() =>
|
||||
run(owner, async () => {
|
||||
if (expected !== currentProject())
|
||||
throw new Error('Project changed');
|
||||
apply.disabled = true;
|
||||
const ok = await director.importProjectFile(file, {
|
||||
prepared: input,
|
||||
expectedProject: expected,
|
||||
signal: owner.signal,
|
||||
});
|
||||
if (alive(owner)) {
|
||||
if (ok) close();
|
||||
else {
|
||||
apply.disabled = false;
|
||||
dialog.setStatus(
|
||||
t(
|
||||
'Import was not applied. The current project may have changed.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
}),
|
||||
{ hook: 'data-director-apply-import' },
|
||||
);
|
||||
apply.dataset.directorApplyImport = '';
|
||||
});
|
||||
}
|
||||
function edit() {
|
||||
@@ -241,9 +247,9 @@ export function createSceneSharing(director) {
|
||||
JSON.parse(shotText.value),
|
||||
);
|
||||
sceneText.value = json(details);
|
||||
dialog.status.textContent = t('Anchor added to draft');
|
||||
dialog.setStatus(t('Anchor added to draft'));
|
||||
}),
|
||||
dialog.body,
|
||||
{ place: 'body' },
|
||||
);
|
||||
dialog.button(
|
||||
t('Set move start to current camera'),
|
||||
@@ -277,11 +283,11 @@ export function createSceneSharing(director) {
|
||||
details,
|
||||
);
|
||||
shotText.value = json(details);
|
||||
dialog.status.textContent = t(
|
||||
'Move added to draft; destination remains the shot camera',
|
||||
dialog.setStatus(
|
||||
t('Move added to draft; destination remains the shot camera'),
|
||||
);
|
||||
}),
|
||||
dialog.body,
|
||||
{ place: 'body' },
|
||||
);
|
||||
dialog.button(
|
||||
t('Use ordinary flight'),
|
||||
@@ -292,37 +298,39 @@ export function createSceneSharing(director) {
|
||||
delete details.move;
|
||||
shotText.value = json(details);
|
||||
} catch {
|
||||
dialog.status.textContent = t('Invalid shot JSON');
|
||||
dialog.setStatus(t('Invalid shot JSON'));
|
||||
}
|
||||
},
|
||||
dialog.body,
|
||||
{ place: 'body' },
|
||||
);
|
||||
const apply = dialog.button(t('Apply details'), () =>
|
||||
run(owner, async () => {
|
||||
if (expected !== currentProject()) throw new Error('Project changed');
|
||||
const project = editSceneDetails(
|
||||
original,
|
||||
scene.id,
|
||||
shot.id,
|
||||
JSON.parse(sceneText.value),
|
||||
JSON.parse(shotText.value),
|
||||
);
|
||||
const ok = await director.importProjectFile(
|
||||
{
|
||||
name: 'scene details',
|
||||
text: async () => stringifySceneDocument(project),
|
||||
},
|
||||
{
|
||||
prepared: { project, assets: director._bundleAssets.snapshot() },
|
||||
expectedProject: expected,
|
||||
signal: owner.signal,
|
||||
selection: { sceneId: scene.id, shotId: shot.id },
|
||||
},
|
||||
);
|
||||
if (alive(owner) && ok) close();
|
||||
}),
|
||||
dialog.button(
|
||||
t('Apply details'),
|
||||
() =>
|
||||
run(owner, async () => {
|
||||
if (expected !== currentProject()) throw new Error('Project changed');
|
||||
const project = editSceneDetails(
|
||||
original,
|
||||
scene.id,
|
||||
shot.id,
|
||||
JSON.parse(sceneText.value),
|
||||
JSON.parse(shotText.value),
|
||||
);
|
||||
const ok = await director.importProjectFile(
|
||||
{
|
||||
name: 'scene details',
|
||||
text: async () => stringifySceneDocument(project),
|
||||
},
|
||||
{
|
||||
prepared: { project, assets: director._bundleAssets.snapshot() },
|
||||
expectedProject: expected,
|
||||
signal: owner.signal,
|
||||
selection: { sceneId: scene.id, shotId: shot.id },
|
||||
},
|
||||
);
|
||||
if (alive(owner) && ok) close();
|
||||
}),
|
||||
{ hook: 'data-director-apply-details' },
|
||||
);
|
||||
apply.dataset.directorApplyDetails = '';
|
||||
}
|
||||
function share() {
|
||||
let project;
|
||||
@@ -361,13 +369,13 @@ export function createSceneSharing(director) {
|
||||
});
|
||||
const files = dialog.input(t('Choose data-pack files'), '', {
|
||||
type: 'file',
|
||||
multiple: true,
|
||||
});
|
||||
files.multiple = true;
|
||||
const folder = dialog.input(t('Or choose a data-pack folder'), '', {
|
||||
type: 'file',
|
||||
multiple: true,
|
||||
directory: true,
|
||||
});
|
||||
folder.multiple = true;
|
||||
folder.setAttribute('webkitdirectory', '');
|
||||
dialog.button(t('Download asset bundle'), () =>
|
||||
run(owner, async () => {
|
||||
const selected = [...files.files, ...folder.files];
|
||||
@@ -410,7 +418,7 @@ export function createSceneSharing(director) {
|
||||
);
|
||||
if (alive(owner)) {
|
||||
download(text, 'scene.gevbundle.json');
|
||||
dialog.status.textContent = t('Asset bundle downloaded');
|
||||
dialog.setStatus(t('Asset bundle downloaded'));
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// src/scenes/sharingDialogs.test.mjs — scene sharing drives its authoring bar
|
||||
// and dialogs through the store, drawn by the scene panels view (happy-dom).
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createSceneSharing } from './sharing.js';
|
||||
import { stringifySceneDocument } from '../director/document.js';
|
||||
import { useSceneSharingStore } from '../ui/stores/sceneSharing.js';
|
||||
import LayerPanelsView from '../ui/views/LayerPanelsView.vue';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
|
||||
HTMLDialogElement.prototype.showModal ??= function () {
|
||||
this.open = true;
|
||||
};
|
||||
|
||||
const project = () => ({
|
||||
version: 6,
|
||||
scenes: [
|
||||
{
|
||||
id: 'one',
|
||||
title: 'Example',
|
||||
shots: [
|
||||
{
|
||||
id: 'shot',
|
||||
title: 'Original',
|
||||
durationSec: 3,
|
||||
holdSec: 2,
|
||||
camera: { lat: 1, lon: 2, alt: 300, pitch: -60 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function fixture(t, { imported = true } = {}) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
// The owner presents through the application's store.
|
||||
const pinia = applicationPinia();
|
||||
const app = createApp(LayerPanelsView).use(pinia);
|
||||
app.mount(host);
|
||||
const imports = [];
|
||||
let finishImport = null;
|
||||
const director = {
|
||||
_project: project(),
|
||||
_selectedSceneId: 'one',
|
||||
_selectedShotId: 'shot',
|
||||
_getSelectedScene() {
|
||||
return this._project.scenes[0];
|
||||
},
|
||||
_updateStatus() {},
|
||||
styleManager: {
|
||||
getCameraState: () => ({
|
||||
lat: 5,
|
||||
lon: 6,
|
||||
alt: 700,
|
||||
heading: 0,
|
||||
pitch: -30,
|
||||
roll: 0,
|
||||
}),
|
||||
},
|
||||
_bundleAssets: { snapshot: () => new Map() },
|
||||
_dataPacks: { sourceIds: () => [] },
|
||||
dataManager: { getAll: () => [] },
|
||||
importProjectFile(file, options) {
|
||||
imports.push(options);
|
||||
return new Promise((resolve) => {
|
||||
finishImport = () => resolve(imported);
|
||||
});
|
||||
},
|
||||
};
|
||||
const sharing = createSceneSharing(director);
|
||||
t.after(() => {
|
||||
sharing.destroy();
|
||||
app.unmount();
|
||||
});
|
||||
return {
|
||||
sharing,
|
||||
imports,
|
||||
finish: () => finishImport?.(),
|
||||
store: useSceneSharingStore(pinia),
|
||||
};
|
||||
}
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const button = (label) =>
|
||||
[...document.querySelectorAll('[data-director-dialog] button')].find(
|
||||
(node) => node.textContent === label,
|
||||
);
|
||||
const status = () => $('[data-director-dialog] [role=status]').textContent;
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
test('mounting offers the authoring bar and destroying withdraws it', async (t) => {
|
||||
const { sharing, store } = fixture(t);
|
||||
sharing.mount();
|
||||
await nextTick();
|
||||
assert.ok($('[data-director-authoring]'));
|
||||
sharing.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('[data-director-authoring]'), null);
|
||||
assert.equal(store.toolbar, null);
|
||||
});
|
||||
|
||||
test('EDIT DETAILS drafts in its fields, says what changed and applies the draft', async (t) => {
|
||||
const { sharing, imports, finish } = fixture(t);
|
||||
sharing.edit();
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
$('[data-director-dialog]').getAttribute('aria-label'),
|
||||
'Edit scene details',
|
||||
);
|
||||
const [scene, shot] = document.querySelectorAll(
|
||||
'[data-director-dialog] textarea',
|
||||
);
|
||||
assert.deepEqual(JSON.parse(scene.value), {});
|
||||
assert.equal(JSON.parse(shot.value).durationSec, 3);
|
||||
// Camera helpers sit in the body, Apply in the footer by its hook.
|
||||
const capture = button('Capture camera as anchor');
|
||||
assert.equal(capture.parentElement.nextElementSibling.role, 'status');
|
||||
capture.click();
|
||||
await settle();
|
||||
assert.equal(status(), 'Anchor added to draft');
|
||||
assert.equal(JSON.parse(scene.value).anchors[0].lat, 5);
|
||||
|
||||
shot.value = '{';
|
||||
shot.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
button('Use ordinary flight').click();
|
||||
await nextTick();
|
||||
assert.equal(status(), 'Invalid shot JSON');
|
||||
shot.value = JSON.stringify({
|
||||
camera: { lat: 1, lon: 2, alt: 300, pitch: -60 },
|
||||
durationSec: 3,
|
||||
holdSec: 4,
|
||||
});
|
||||
shot.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
$('[data-director-apply-details]').click();
|
||||
await settle();
|
||||
assert.equal(imports.length, 1);
|
||||
assert.equal(imports[0].prepared.project.scenes[0].shots[0].holdSec, 4);
|
||||
assert.equal(imports[0].prepared.project.scenes[0].anchors[0].id, 'anchor-1');
|
||||
finish();
|
||||
await settle();
|
||||
assert.equal($('[data-director-dialog]'), null);
|
||||
assert.equal(sharing.getState().open, false);
|
||||
});
|
||||
|
||||
test('SHARE SCENE offers the JSON download and file and folder choosers', async (t) => {
|
||||
const { sharing } = fixture(t);
|
||||
sharing.share();
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
$('[data-director-dialog]').getAttribute('aria-label'),
|
||||
'Share selected scene',
|
||||
);
|
||||
assert.ok(button('Download scene JSON'));
|
||||
assert.ok(button('Download asset bundle'));
|
||||
const [files, folder] = document.querySelectorAll(
|
||||
'[data-director-dialog] input[type=file]',
|
||||
);
|
||||
assert.equal(files.multiple, true);
|
||||
assert.equal(files.hasAttribute('webkitdirectory'), false);
|
||||
assert.equal(folder.multiple, true);
|
||||
assert.equal(folder.hasAttribute('webkitdirectory'), true);
|
||||
});
|
||||
|
||||
test('an import review holds Apply disabled while the import runs and reopens it on refusal', async (t) => {
|
||||
const { sharing, imports, finish } = fixture(t, { imported: false });
|
||||
const text = stringifySceneDocument(project());
|
||||
await sharing.preview({
|
||||
name: 'scene.json',
|
||||
size: text.length,
|
||||
text: async () => text,
|
||||
});
|
||||
await settle();
|
||||
assert.equal(status(), 'Ready to import');
|
||||
const apply = $('[data-director-apply-import]');
|
||||
assert.equal(apply.textContent, 'Apply import');
|
||||
apply.click();
|
||||
await settle();
|
||||
assert.equal(imports.length, 1);
|
||||
assert.equal($('[data-director-apply-import]').disabled, true);
|
||||
finish();
|
||||
await settle();
|
||||
assert.equal($('[data-director-apply-import]').disabled, false);
|
||||
assert.equal(
|
||||
status(),
|
||||
'Import was not applied. The current project may have changed.',
|
||||
);
|
||||
button('Cancel').click();
|
||||
await nextTick();
|
||||
assert.equal($('[data-director-dialog]'), null);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Write a live region's message, even a repeated one, so a screen reader
|
||||
* announces it again. The value is `{text, seq}`; the owner bumps `seq` for
|
||||
* every message it says.
|
||||
*/
|
||||
export const vStatusText = {
|
||||
mounted(el, { value }) {
|
||||
el.textContent = value.text;
|
||||
},
|
||||
updated(el, { value, oldValue }) {
|
||||
if (value.seq !== oldValue?.seq || value.text !== oldValue?.text)
|
||||
el.textContent = value.text;
|
||||
},
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import { assertSameNode } from '../testSupport/domAssert.mjs';
|
||||
import { createStateChannel } from '../app/stateChannel.js';
|
||||
import { sceneElements } from './scenePresentation.js';
|
||||
import { mountSceneSharing } from './sceneSharing.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { installVueDom } from '../testSupport/vueDom.mjs';
|
||||
|
||||
@@ -585,74 +584,3 @@ test('Authoring PLAY uses the scene context, not the radio PLAY', (context) => {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('The sharing row sits under SAVE & SHARE, after the file row', () => {
|
||||
const saved = globalThis.document;
|
||||
const node = (className = '') => {
|
||||
const element = {
|
||||
className,
|
||||
children: [],
|
||||
dataset: {},
|
||||
parent: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
append(...items) {
|
||||
for (const item of items) {
|
||||
item.parent = element;
|
||||
element.children.push(item);
|
||||
}
|
||||
},
|
||||
appendChild(item) {
|
||||
element.append(item);
|
||||
},
|
||||
after(item) {
|
||||
const siblings = element.parent.children;
|
||||
item.parent = element.parent;
|
||||
siblings.splice(siblings.indexOf(element) + 1, 0, item);
|
||||
},
|
||||
remove() {
|
||||
const siblings = element.parent?.children;
|
||||
if (siblings) siblings.splice(siblings.indexOf(element), 1);
|
||||
},
|
||||
querySelector(selector) {
|
||||
const name = selector.slice(1);
|
||||
const walk = (at) => {
|
||||
for (const child of at.children) {
|
||||
if (child.className.split(' ').includes(name)) return child;
|
||||
const found = walk(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(element);
|
||||
},
|
||||
};
|
||||
return element;
|
||||
};
|
||||
globalThis.document = { createElement: () => node() };
|
||||
try {
|
||||
const panel = node();
|
||||
const inner = node('scene-panel-inner');
|
||||
const fileRow = node('scene-controls scene-file-controls');
|
||||
const status = node('scene-status');
|
||||
panel.append(inner);
|
||||
inner.append(fileRow, status);
|
||||
const dispose = mountSceneSharing({ edit() {}, share() {} }, panel);
|
||||
const bar = inner.children[1];
|
||||
assert.equal(bar.className, 'scene-controls');
|
||||
assertSameNode(inner.children[2], status);
|
||||
assert.ok(bar.children.every((button) => button.title));
|
||||
dispose();
|
||||
assert.deepEqual(inner.children, [fileRow, status]);
|
||||
|
||||
// Without the file row, the bar still lands inside the panel.
|
||||
const plain = node();
|
||||
const plainInner = node('scene-panel-inner');
|
||||
plain.append(plainInner);
|
||||
mountSceneSharing({ edit() {}, share() {} }, plain)();
|
||||
mountSceneSharing({ edit() {}, share() {} }, plain);
|
||||
assert.equal(plainInner.children.length, 1);
|
||||
} finally {
|
||||
globalThis.document = saved;
|
||||
}
|
||||
});
|
||||
+147
-186
@@ -1,210 +1,171 @@
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import { createActionButton } from './actionButton.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { useSceneSharingStore } from './stores/sceneSharing.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
|
||||
/** Small owned modal for scene authoring and import review; text never becomes markup. */
|
||||
export function createSceneDialog(title, onClose) {
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.dataset.directorDialog = '';
|
||||
dialog.className = 'director-sharing-dialog';
|
||||
dialog.setAttribute('aria-label', title);
|
||||
Object.assign(dialog.style, {
|
||||
background: '#08141e',
|
||||
color: '#e3faff',
|
||||
border: '1px solid #397080',
|
||||
borderRadius: '6px',
|
||||
padding: '20px',
|
||||
width: 'min(720px,calc(100vw - 32px))',
|
||||
boxSizing: 'border-box',
|
||||
position: 'fixed',
|
||||
inset: '0',
|
||||
margin: 'auto',
|
||||
maxHeight: 'calc(100vh - 32px)',
|
||||
overflow: 'auto',
|
||||
font: '14px/1.5 sans-serif',
|
||||
});
|
||||
const heading = document.createElement('h2');
|
||||
heading.textContent = title;
|
||||
heading.style.marginBottom = '12px';
|
||||
dialog.append(heading);
|
||||
const body = document.createElement('div'),
|
||||
footer = document.createElement('div'),
|
||||
status = document.createElement('p');
|
||||
status.setAttribute('role', 'status');
|
||||
dialog.append(body, status, footer);
|
||||
const listeners = [];
|
||||
function listen(node, type, fn) {
|
||||
node.addEventListener(type, fn);
|
||||
listeners.push(() => node.removeEventListener(type, fn));
|
||||
}
|
||||
function text(value, parent = body) {
|
||||
const p = document.createElement('p');
|
||||
p.textContent = value;
|
||||
p.style.margin = '8px 0';
|
||||
parent.append(p);
|
||||
return p;
|
||||
}
|
||||
function button(label, fn, parent = footer) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = label;
|
||||
Object.assign(b.style, {
|
||||
background: '#10242e',
|
||||
color: '#e3faff',
|
||||
border: '1px solid #397080',
|
||||
borderRadius: '3px',
|
||||
padding: '7px 11px',
|
||||
margin: '6px 6px 6px 0',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
let dialogCount = 0;
|
||||
|
||||
/**
|
||||
* Small owned modal for scene authoring and import review; text never becomes
|
||||
* markup. The dialog is presented through the scene sharing store and drawn
|
||||
* by SceneDialog.vue; the returned builder adds its lines, fields and buttons
|
||||
* in order. Body content comes first, then the status line, then the footer
|
||||
* row, which starts with Cancel.
|
||||
* @param {string} title
|
||||
* @param {() => void} onClose Cancel, Escape.
|
||||
*/
|
||||
export function createSceneDialog(
|
||||
title,
|
||||
onClose,
|
||||
store = useSceneSharingStore(applicationPinia()),
|
||||
) {
|
||||
const id = ++dialogCount;
|
||||
const items = [];
|
||||
const actions = new Map();
|
||||
let status = '',
|
||||
statusSeq = 0,
|
||||
nextItem = 0,
|
||||
disposed = false;
|
||||
const controls = {
|
||||
click(itemId) {
|
||||
const item = items.find((entry) => entry.id === itemId);
|
||||
if (!disposed && !item?.disabled) actions.get(itemId)?.();
|
||||
},
|
||||
setValue(itemId, value) {
|
||||
const item = items.find((entry) => entry.id === itemId);
|
||||
if (item) item.value = value;
|
||||
},
|
||||
setFiles(itemId, files) {
|
||||
const item = items.find((entry) => entry.id === itemId);
|
||||
if (!item) return;
|
||||
item.files = [...files];
|
||||
publish();
|
||||
},
|
||||
cancel() {
|
||||
if (!disposed) onClose();
|
||||
},
|
||||
};
|
||||
function publish() {
|
||||
if (disposed || store.controls !== controls) return;
|
||||
store.publishDialog({
|
||||
id,
|
||||
title,
|
||||
items: items.map((item) => ({ ...item })),
|
||||
status,
|
||||
statusSeq,
|
||||
});
|
||||
listen(b, 'click', fn);
|
||||
parent.append(b);
|
||||
return b;
|
||||
}
|
||||
function input(label, value, { type = 'text', multiline = false } = {}) {
|
||||
const wrapper = document.createElement('label');
|
||||
wrapper.style.display = 'block';
|
||||
wrapper.textContent = label;
|
||||
const node = document.createElement(multiline ? 'textarea' : 'input');
|
||||
if (!multiline) node.type = type;
|
||||
node.value = value;
|
||||
node.setAttribute('aria-label', label);
|
||||
Object.assign(node.style, {
|
||||
display: 'block',
|
||||
boxSizing: 'border-box',
|
||||
width: '100%',
|
||||
padding: '7px',
|
||||
margin: '6px 0 14px',
|
||||
color: '#e3faff',
|
||||
background: '#10242e',
|
||||
border: '1px solid #397080',
|
||||
font: multiline ? '12px monospace' : 'inherit',
|
||||
});
|
||||
if (multiline) node.rows = 9;
|
||||
wrapper.append(node);
|
||||
if (type === 'file') fileChooser(wrapper, node);
|
||||
body.append(wrapper);
|
||||
return node;
|
||||
function add(item) {
|
||||
items.push({ id: nextItem++, ...item });
|
||||
publish();
|
||||
return items.at(-1);
|
||||
}
|
||||
function text(value) {
|
||||
add({ kind: 'text', place: 'body', value });
|
||||
}
|
||||
/**
|
||||
* The browser draws a file input's button and "no file" text in its own
|
||||
* language. Stretch the native input, transparent, over the whole row (so
|
||||
* click, focus and drops stay native) and draw translated text under it.
|
||||
* @param {string} label
|
||||
* @param {() => void} fn
|
||||
* @param {object} [options]
|
||||
* @param {'body'|'footer'} [options.place]
|
||||
* @param {string} [options.hook] A data attribute name, e.g.
|
||||
* 'data-director-apply-import', for automation to find the button by.
|
||||
* @returns {{disabled: boolean}}
|
||||
*/
|
||||
function fileChooser(wrapper, node) {
|
||||
wrapper.style.position = 'relative';
|
||||
Object.assign(node.style, {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
border: '0',
|
||||
opacity: '0',
|
||||
cursor: 'pointer',
|
||||
zIndex: '1',
|
||||
function button(label, fn, { place = 'footer', hook, autofocus } = {}) {
|
||||
const item = add({
|
||||
kind: 'button',
|
||||
place,
|
||||
label,
|
||||
disabled: false,
|
||||
hook,
|
||||
autofocus,
|
||||
});
|
||||
const row = document.createElement('span');
|
||||
row.setAttribute('aria-hidden', 'true');
|
||||
Object.assign(row.style, {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
margin: '6px 0 14px',
|
||||
padding: '7px',
|
||||
background: '#10242e',
|
||||
border: '1px solid #397080',
|
||||
});
|
||||
const chooser = document.createElement('span');
|
||||
chooser.textContent = t('Choose files');
|
||||
Object.assign(chooser.style, {
|
||||
padding: '3px 9px',
|
||||
border: '1px solid #397080',
|
||||
borderRadius: '3px',
|
||||
background: '#08141e',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
const chosen = document.createElement('span');
|
||||
const showChosen = () => {
|
||||
const count = node.files?.length || 0;
|
||||
// One file shows its name, like the browser's own picker did.
|
||||
chosen.textContent =
|
||||
count === 1
|
||||
? node.files[0].name
|
||||
: count
|
||||
? tn(count, '{count} file selected', '{count} files selected')
|
||||
: t('No file chosen');
|
||||
actions.set(item.id, fn);
|
||||
return {
|
||||
get disabled() {
|
||||
return item.disabled;
|
||||
},
|
||||
set disabled(value) {
|
||||
item.disabled = Boolean(value);
|
||||
publish();
|
||||
},
|
||||
};
|
||||
showChosen();
|
||||
row.append(chooser, chosen);
|
||||
wrapper.append(row);
|
||||
listen(node, 'change', showChosen);
|
||||
listen(node, 'focus', () => {
|
||||
chooser.style.outline = '2px solid #e3faff';
|
||||
});
|
||||
listen(node, 'blur', () => {
|
||||
chooser.style.outline = '';
|
||||
});
|
||||
}
|
||||
button(t('Cancel'), onClose);
|
||||
listen(dialog, 'cancel', (event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
});
|
||||
document.body.append(dialog);
|
||||
dialog.showModal();
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {string} value
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.type]
|
||||
* @param {boolean} [options.multiline]
|
||||
* @param {boolean} [options.multiple] File inputs: several files.
|
||||
* @param {boolean} [options.directory] File inputs: a whole folder.
|
||||
* @returns {{value: string, readonly files: File[]}}
|
||||
*/
|
||||
function input(
|
||||
label,
|
||||
value,
|
||||
{ type = 'text', multiline = false, multiple, directory } = {},
|
||||
) {
|
||||
const item = add({
|
||||
kind: 'input',
|
||||
place: 'body',
|
||||
label,
|
||||
value,
|
||||
type: multiline ? null : type,
|
||||
multiline,
|
||||
multiple: Boolean(multiple),
|
||||
directory: Boolean(directory),
|
||||
files: [],
|
||||
});
|
||||
return {
|
||||
get value() {
|
||||
return item.value;
|
||||
},
|
||||
set value(next) {
|
||||
item.value = next;
|
||||
publish();
|
||||
},
|
||||
get files() {
|
||||
return item.files;
|
||||
},
|
||||
};
|
||||
}
|
||||
function setStatus(value) {
|
||||
status = value;
|
||||
statusSeq++;
|
||||
publish();
|
||||
}
|
||||
store.connect(controls);
|
||||
// Cancel takes the opening focus, as the first control drawn.
|
||||
button(t('Cancel'), onClose, { autofocus: true });
|
||||
return {
|
||||
body,
|
||||
footer,
|
||||
status,
|
||||
text,
|
||||
button,
|
||||
input,
|
||||
listen,
|
||||
element: dialog,
|
||||
setStatus,
|
||||
dispose() {
|
||||
for (const remove of listeners.splice(0)) remove();
|
||||
dialog.close();
|
||||
dialog.remove();
|
||||
if (disposed) return;
|
||||
if (store.controls === controls) {
|
||||
store.connect(null);
|
||||
store.publishDialog(null);
|
||||
}
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Add controls only when their owner is present; dispose removes every listener and element. */
|
||||
/**
|
||||
* Offer the EDIT DETAILS / SHARE SCENE bar beside the scene panel's file
|
||||
* controls; the returned function withdraws it, unless another owner has
|
||||
* offered its own since.
|
||||
*/
|
||||
export function mountSceneSharing(
|
||||
{ edit, share },
|
||||
panel = document.getElementById('scene-panel'),
|
||||
store = useSceneSharingStore(applicationPinia()),
|
||||
) {
|
||||
if (!panel) return () => {};
|
||||
const bar = document.createElement('div');
|
||||
bar.dataset.directorAuthoring = '';
|
||||
bar.className = 'scene-controls';
|
||||
const entries = [
|
||||
[t('EDIT DETAILS'), t('Edit anchors, data packs and shot details'), edit],
|
||||
[
|
||||
t('SHARE SCENE'),
|
||||
t('Download this scene as JSON or an asset bundle'),
|
||||
share,
|
||||
],
|
||||
];
|
||||
const removers = [];
|
||||
for (const [text, title, fn] of entries) {
|
||||
const b = createActionButton({
|
||||
label: text,
|
||||
title,
|
||||
className: 'scene-btn',
|
||||
});
|
||||
b.addEventListener('click', fn);
|
||||
removers.push(() => b.removeEventListener('click', fn));
|
||||
bar.append(b);
|
||||
}
|
||||
// Beside EXPORT / IMPORT, under SAVE & SHARE.
|
||||
const fileRow = panel.querySelector?.('.scene-file-controls');
|
||||
if (fileRow) fileRow.after(bar);
|
||||
else (panel.querySelector?.('.scene-panel-inner') || panel).appendChild(bar);
|
||||
const actions = { edit, share };
|
||||
store.offerToolbar(actions);
|
||||
return () => {
|
||||
for (const remove of removers) remove();
|
||||
bar.remove();
|
||||
if (store.toolbar === actions) store.offerToolbar(null);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// src/ui/sceneSharingView.test.mjs — scene sharing's authoring bar and its
|
||||
// dialog render from the scene sharing store, over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useSceneSharingStore } from './stores/sceneSharing.js';
|
||||
import { createSceneDialog, mountSceneSharing } from './sceneSharing.js';
|
||||
import LayerPanelsView from './views/LayerPanelsView.vue';
|
||||
|
||||
// happy-dom may lack the modal methods; record what the view asks for.
|
||||
const opened = [];
|
||||
HTMLDialogElement.prototype.showModal ??= function () {
|
||||
this.open = true;
|
||||
};
|
||||
const showModal = HTMLDialogElement.prototype.showModal;
|
||||
HTMLDialogElement.prototype.showModal = function () {
|
||||
opened.push(this.getAttribute('aria-label'));
|
||||
return showModal.call(this);
|
||||
};
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(LayerPanelsView).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
return { store: useSceneSharingStore(pinia) };
|
||||
}
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const $$ = (selector) => [...document.querySelectorAll(selector)];
|
||||
const labels = (nodes) => nodes.map((node) => node.textContent);
|
||||
|
||||
test('the authoring bar sits under SAVE & SHARE, after the file row, while offered', async (t) => {
|
||||
const { store } = fixture(t);
|
||||
assert.equal($('[data-director-authoring]'), null);
|
||||
const calls = [];
|
||||
const withdraw = mountSceneSharing(
|
||||
{ edit: () => calls.push('edit'), share: () => calls.push('share') },
|
||||
store,
|
||||
);
|
||||
await nextTick();
|
||||
const bar = $('[data-director-authoring]');
|
||||
assert.equal(
|
||||
bar.previousElementSibling.classList.contains('scene-file-controls'),
|
||||
true,
|
||||
);
|
||||
assert.equal(bar.className, 'scene-controls');
|
||||
const buttons = $$('[data-director-authoring] button');
|
||||
assert.deepEqual(labels(buttons), ['EDIT DETAILS', 'SHARE SCENE']);
|
||||
assert.ok(buttons.every((button) => button.title));
|
||||
assert.ok(
|
||||
buttons.every((button) => button.classList.contains('gev-action-button')),
|
||||
);
|
||||
buttons[0].click();
|
||||
buttons[1].click();
|
||||
assert.deepEqual(calls, ['edit', 'share']);
|
||||
|
||||
// A newer offer survives the older one's withdrawal.
|
||||
const newer = mountSceneSharing({ edit() {}, share() {} }, store);
|
||||
withdraw();
|
||||
await nextTick();
|
||||
assert.ok($('[data-director-authoring]'));
|
||||
newer();
|
||||
await nextTick();
|
||||
assert.equal($('[data-director-authoring]'), null);
|
||||
});
|
||||
|
||||
test('a dialog draws its lines, fields and buttons in order and opens modal', async (t) => {
|
||||
const { store } = fixture(t);
|
||||
opened.length = 0;
|
||||
const clicks = [];
|
||||
const dialog = createSceneDialog('Edit scene details', () => {}, store);
|
||||
dialog.text('Drafts are validated.');
|
||||
const details = dialog.input('Anchors', '{"a":1}', { multiline: true });
|
||||
const anchor = dialog.input('New anchor ID', 'anchor-1');
|
||||
dialog.button('Capture', () => clicks.push('capture'), { place: 'body' });
|
||||
const apply = dialog.button('Apply details', () => clicks.push('apply'), {
|
||||
hook: 'data-director-apply-details',
|
||||
});
|
||||
await nextTick();
|
||||
const root = $('dialog[data-director-dialog]');
|
||||
assert.equal(root.parentElement, document.body);
|
||||
assert.equal(root.getAttribute('aria-label'), 'Edit scene details');
|
||||
assert.equal(root.classList.contains('director-sharing-dialog'), true);
|
||||
assert.deepEqual(opened, ['Edit scene details']);
|
||||
assert.equal($('dialog h2').textContent, 'Edit scene details');
|
||||
const [body, status, footer] = [...root.children].slice(1);
|
||||
assert.equal(status.getAttribute('role'), 'status');
|
||||
assert.deepEqual(
|
||||
[...body.children].map((node) => node.tagName),
|
||||
['P', 'LABEL', 'LABEL', 'BUTTON'],
|
||||
);
|
||||
// Cancel comes first in the footer and takes the opening focus.
|
||||
assert.deepEqual(labels([...footer.children]), ['Cancel', 'Apply details']);
|
||||
assert.equal(footer.children[0].hasAttribute('autofocus'), true);
|
||||
assert.equal(footer.children[1].hasAttribute('autofocus'), false);
|
||||
assert.equal(
|
||||
footer.children[1].hasAttribute('data-director-apply-details'),
|
||||
true,
|
||||
);
|
||||
|
||||
const textarea = $('dialog textarea');
|
||||
assert.equal(textarea.value, '{"a":1}');
|
||||
assert.equal(textarea.getAttribute('aria-label'), 'Anchors');
|
||||
assert.equal($('dialog input').value, 'anchor-1');
|
||||
textarea.value = '{"b":2}';
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
assert.equal(details.value, '{"b":2}');
|
||||
anchor.value = 'anchor-2';
|
||||
await nextTick();
|
||||
assert.equal($('dialog input').value, 'anchor-2');
|
||||
|
||||
body.children[3].click();
|
||||
footer.children[1].click();
|
||||
assert.deepEqual(clicks, ['capture', 'apply']);
|
||||
apply.disabled = true;
|
||||
await nextTick();
|
||||
assert.equal(footer.children[1].disabled, true);
|
||||
assert.equal(apply.disabled, true);
|
||||
// A disabled action does not run, even when asked directly.
|
||||
store.controls.click(
|
||||
store.dialog.items.find((item) => item.label === 'Apply details').id,
|
||||
);
|
||||
footer.children[1].click();
|
||||
assert.deepEqual(clicks, ['capture', 'apply']);
|
||||
|
||||
// The owner's write reaches a focused field; typing is kept on republish.
|
||||
textarea.focus();
|
||||
details.value = '{"c":3}';
|
||||
await nextTick();
|
||||
assert.equal(textarea.value, '{"c":3}');
|
||||
textarea.value = '{"d":4}';
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
dialog.setStatus('Anchor added to draft');
|
||||
await nextTick();
|
||||
assert.equal(textarea.value, '{"d":4}');
|
||||
assert.equal(details.value, '{"d":4}');
|
||||
});
|
||||
|
||||
test('the status line re-announces a repeated message', async (t) => {
|
||||
const { store } = fixture(t);
|
||||
const dialog = createSceneDialog('Review scene import', () => {}, store);
|
||||
await nextTick();
|
||||
const status = $('dialog [role=status]');
|
||||
assert.equal(status.textContent, '');
|
||||
const writes = [];
|
||||
const text = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent');
|
||||
Object.defineProperty(status, 'textContent', {
|
||||
configurable: true,
|
||||
get: () => text.get.call(status),
|
||||
set(value) {
|
||||
writes.push(value);
|
||||
text.set.call(status, value);
|
||||
},
|
||||
});
|
||||
dialog.setStatus('Ready to import');
|
||||
await nextTick();
|
||||
dialog.setStatus('Ready to import');
|
||||
await nextTick();
|
||||
assert.deepEqual(writes, ['Ready to import', 'Ready to import']);
|
||||
});
|
||||
|
||||
test('Cancel and Escape close through the owner; dispose removes the dialog', async (t) => {
|
||||
const { store } = fixture(t);
|
||||
let closes = 0;
|
||||
const dialog = createSceneDialog(
|
||||
'Share selected scene',
|
||||
() => closes++,
|
||||
store,
|
||||
);
|
||||
await nextTick();
|
||||
$('dialog div:last-of-type button').click();
|
||||
const cancel = new Event('cancel', { cancelable: true });
|
||||
$('dialog').dispatchEvent(cancel);
|
||||
assert.equal(cancel.defaultPrevented, true);
|
||||
assert.equal(closes, 2);
|
||||
dialog.dispose();
|
||||
await nextTick();
|
||||
assert.equal($('dialog'), null);
|
||||
assert.equal(store.controls, null);
|
||||
});
|
||||
|
||||
test('a replaced dialog opens fresh and its late dispose leaves the new one', async (t) => {
|
||||
const { store } = fixture(t);
|
||||
opened.length = 0;
|
||||
const first = createSceneDialog('Review scene import', () => {}, store);
|
||||
await nextTick();
|
||||
const firstRoot = $('dialog');
|
||||
const second = createSceneDialog('Edit scene details', () => {}, store);
|
||||
first.dispose();
|
||||
first.text('stale');
|
||||
await nextTick();
|
||||
assert.equal($$('dialog').length, 1);
|
||||
assert.equal($('dialog') === firstRoot, false);
|
||||
assert.equal($('dialog').getAttribute('aria-label'), 'Edit scene details');
|
||||
assert.deepEqual(opened, ['Review scene import', 'Edit scene details']);
|
||||
assert.equal(
|
||||
$$('dialog p').some((p) => p.textContent === 'stale'),
|
||||
false,
|
||||
);
|
||||
second.dispose();
|
||||
});
|
||||
|
||||
test('a file field draws translated chooser text over a native input', async (t) => {
|
||||
const { store } = fixture(t);
|
||||
const dialog = createSceneDialog('Share selected scene', () => {}, store);
|
||||
const files = dialog.input('Choose data-pack files', '', {
|
||||
type: 'file',
|
||||
multiple: true,
|
||||
});
|
||||
dialog.input('Or choose a data-pack folder', '', {
|
||||
type: 'file',
|
||||
multiple: true,
|
||||
directory: true,
|
||||
});
|
||||
await nextTick();
|
||||
const [pick, folder] = $$('dialog input[type=file]');
|
||||
assert.equal(pick.multiple, true);
|
||||
assert.equal(pick.hasAttribute('webkitdirectory'), false);
|
||||
assert.equal(folder.hasAttribute('webkitdirectory'), true);
|
||||
assert.equal(pick.style.opacity, '0');
|
||||
const chosen = () =>
|
||||
pick.parentElement.querySelector('[aria-hidden] span:last-child')
|
||||
.textContent;
|
||||
assert.equal(chosen(), 'No file chosen');
|
||||
|
||||
const one = new File(['x'], 'pack.json');
|
||||
Object.defineProperty(pick, 'files', { configurable: true, value: [one] });
|
||||
pick.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await nextTick();
|
||||
assert.equal(chosen(), 'pack.json');
|
||||
assert.equal(files.files[0] === one, true);
|
||||
Object.defineProperty(pick, 'files', {
|
||||
configurable: true,
|
||||
value: [one, new File(['y'], 'b.json')],
|
||||
});
|
||||
pick.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await nextTick();
|
||||
assert.equal(chosen(), '2 files selected');
|
||||
assert.equal(files.files.length, 2);
|
||||
|
||||
const chooser = pick.parentElement.querySelector('[aria-hidden] span');
|
||||
pick.dispatchEvent(new FocusEvent('focus'));
|
||||
await nextTick();
|
||||
assert.notEqual(chooser.style.outline, '');
|
||||
pick.dispatchEvent(new FocusEvent('blur'));
|
||||
await nextTick();
|
||||
assert.equal(chooser.style.outline, '');
|
||||
dialog.dispose();
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { markRaw, shallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* Scene sharing as its views render it. The sharing owner
|
||||
* (src/scenes/sharing.js) offers the EDIT DETAILS / SHARE SCENE bar and
|
||||
* presents its one open dialog here; the views send clicks, typing and file
|
||||
* choices back to the connected dialog.
|
||||
*/
|
||||
export const useSceneSharingStore = defineStore('gev-scene-sharing', () => {
|
||||
/** The bar's actions ({edit, share}), or null while no owner offers it. */
|
||||
const toolbar = shallowRef(null);
|
||||
|
||||
/** @param {?{edit: Function, share: Function}} next */
|
||||
function offerToolbar(next) {
|
||||
toolbar.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
/** The open dialog's presentation, or null while none is open. */
|
||||
const dialog = shallowRef(null);
|
||||
|
||||
/** @param {?object} next A fresh presentation; every publish re-renders. */
|
||||
function publishDialog(next) {
|
||||
dialog.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
/** The open dialog the view calls, or null while none is wired. */
|
||||
const controls = shallowRef(null);
|
||||
|
||||
/** @param {?object} next The dialog, or null to disconnect. */
|
||||
function connect(next) {
|
||||
controls.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
return { toolbar, offerToolbar, dialog, publishDialog, controls, connect };
|
||||
});
|
||||
@@ -1,8 +1,16 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { useSceneSharingStore } from '../stores/sceneSharing.js';
|
||||
import SceneDialog from './panels/SceneDialog.vue';
|
||||
import SceneOptions from './SceneOptions.vue';
|
||||
import SceneShotList from './SceneShotList.vue';
|
||||
import CctvPanel from './panels/CctvPanel.vue';
|
||||
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());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -61,6 +69,10 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue';
|
||||
<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 />
|
||||
</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>
|
||||
@@ -69,4 +81,7 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue';
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<SceneDialog v-if="dialog" :key="dialog.id" :view="dialog" />
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { computed, onBeforeUnmount, onMounted, shallowRef } from 'vue';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
import { vFieldValue } from '../rail/fieldValue.js';
|
||||
import { vStatusText } from '../rail/statusText.js';
|
||||
import { useLocaleStore } from '../stores/locale.js';
|
||||
import { useProviderSettingsStore } from '../stores/providerSettings.js';
|
||||
import LocalePicker from './panels/LocalePicker.vue';
|
||||
@@ -34,17 +35,7 @@ onMounted(() => {
|
||||
});
|
||||
onBeforeUnmount(() => themeObserver?.disconnect());
|
||||
|
||||
// The status line is a live region: write each message, even a repeated one,
|
||||
// so a screen reader announces it again.
|
||||
const vStatusText = {
|
||||
mounted(el, { value }) {
|
||||
el.textContent = value.text;
|
||||
},
|
||||
updated(el, { value, oldValue }) {
|
||||
if (value.seq !== oldValue?.seq || value.text !== oldValue?.text)
|
||||
el.textContent = value.text;
|
||||
},
|
||||
};
|
||||
// The status line is a live region; vStatusText re-announces repeats.
|
||||
const statusText = computed(() => ({
|
||||
text:
|
||||
view.value?.status ??
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onBeforeUnmount, onMounted, shallowRef, useTemplateRef } from 'vue';
|
||||
import { t, tn } from '../../../i18n/index.js';
|
||||
import { vStatusText } from '../../rail/statusText.js';
|
||||
import { useSceneSharingStore } from '../../stores/sceneSharing.js';
|
||||
|
||||
// One open scene sharing dialog, as src/ui/sceneSharing.js presents it. The
|
||||
// parent keys this by dialog, so each one opens as a fresh modal.
|
||||
const props = defineProps({ view: { type: Object, required: true } });
|
||||
const { controls } = storeToRefs(useSceneSharingStore());
|
||||
|
||||
const root = useTemplateRef('root');
|
||||
onMounted(() => root.value?.showModal?.());
|
||||
onBeforeUnmount(() => {
|
||||
if (root.value?.open) root.value.close();
|
||||
});
|
||||
|
||||
const bodyItems = computed(() => props.view.items.filter((item) => item.place === 'body'));
|
||||
const footerItems = computed(() => props.view.items.filter((item) => item.place === 'footer'));
|
||||
// Typing reaches the item before any republish, so a field is rewritten only
|
||||
// when the owner changes its value, focused or not, as the old code did.
|
||||
const statusText = computed(() => ({ text: props.view.status, seq: props.view.statusSeq }));
|
||||
|
||||
// The browser draws a file input's button and "no file" text in its own
|
||||
// language. The native input is stretched, transparent, over the whole row
|
||||
// (so click, focus and drops stay native) and translated text is drawn under.
|
||||
const focusedFile = shallowRef(null);
|
||||
const chosenText = (item) => {
|
||||
const count = item.files.length;
|
||||
// One file shows its name, like the browser's own picker did.
|
||||
return count === 1
|
||||
? item.files[0].name
|
||||
: count
|
||||
? tn(count, '{count} file selected', '{count} files selected')
|
||||
: t('No file chosen');
|
||||
};
|
||||
const onFiles = (item, event) => controls.value?.setFiles(item.id, event.target.files ?? []);
|
||||
|
||||
const dialogStyle = { background: '#08141e', color: '#e3faff', border: '1px solid #397080', borderRadius: '6px', padding: '20px', width: 'min(720px,calc(100vw - 32px))', boxSizing: 'border-box', position: 'fixed', inset: '0', margin: 'auto', maxHeight: 'calc(100vh - 32px)', overflow: 'auto', font: '14px/1.5 sans-serif' };
|
||||
const buttonStyle = { background: '#10242e', color: '#e3faff', border: '1px solid #397080', borderRadius: '3px', padding: '7px 11px', margin: '6px 6px 6px 0', cursor: 'pointer', font: 'inherit' };
|
||||
const fieldStyle = (multiline) => ({ display: 'block', boxSizing: 'border-box', width: '100%', padding: '7px', margin: '6px 0 14px', color: '#e3faff', background: '#10242e', border: '1px solid #397080', font: multiline ? '12px monospace' : 'inherit' });
|
||||
const fileInputStyle = { ...fieldStyle(false), position: 'absolute', inset: '0', width: '100%', height: '100%', margin: '0', padding: '0', border: '0', opacity: '0', cursor: 'pointer', zIndex: '1' };
|
||||
const fileRowStyle = { display: 'flex', alignItems: 'center', gap: '10px', margin: '6px 0 14px', padding: '7px', background: '#10242e', border: '1px solid #397080' };
|
||||
const chooserStyle = (focused) => ({ padding: '3px 9px', border: '1px solid #397080', borderRadius: '3px', background: '#08141e', cursor: 'pointer', outline: focused ? '2px solid #e3faff' : '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog ref="root" data-director-dialog class="director-sharing-dialog" :aria-label="view.title" :style="dialogStyle" @cancel.prevent="controls?.cancel()">
|
||||
<h2 style="margin-bottom: 12px">{{ view.title }}</h2>
|
||||
<div>
|
||||
<template v-for="item in bodyItems" :key="item.id">
|
||||
<p v-if="item.kind === 'text'" style="margin: 8px 0">{{ item.value }}</p>
|
||||
<button v-else-if="item.kind === 'button'" type="button" :style="buttonStyle" :disabled="item.disabled" v-bind="item.hook ? { [item.hook]: '' } : {}" @click="controls?.click(item.id)">{{ item.label }}</button>
|
||||
<label v-else-if="item.type === 'file'" :style="{ display: 'block', position: 'relative' }">{{ item.label }}<input type="file" :aria-label="item.label" :multiple="item.multiple" :webkitdirectory="item.directory || undefined" :style="fileInputStyle" @change="onFiles(item, $event)" @focus="focusedFile = item.id" @blur="focusedFile = null" /><span aria-hidden="true" :style="fileRowStyle"><span :style="chooserStyle(focusedFile === item.id)">{{ t('Choose files') }}</span><span>{{ chosenText(item) }}</span></span></label>
|
||||
<label v-else style="display: block">{{ item.label }}<textarea v-if="item.multiline" rows="9" :aria-label="item.label" :style="fieldStyle(true)" :value="item.value" @input="controls?.setValue(item.id, $event.target.value)"></textarea><input v-else :type="item.type" :aria-label="item.label" :style="fieldStyle(false)" :value="item.value" @input="controls?.setValue(item.id, $event.target.value)" /></label>
|
||||
</template>
|
||||
</div>
|
||||
<p role="status" v-status-text="statusText"></p>
|
||||
<div>
|
||||
<button v-for="item in footerItems" :key="item.id" type="button" :style="buttonStyle" :disabled="item.disabled" :autofocus="item.autofocus || undefined" v-bind="item.hook ? { [item.hook]: '' } : {}" @click="controls?.click(item.id)">{{ item.label }}</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
Reference in new issue
Block a user