diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index e12681ca6..25405aac0 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -149,7 +149,10 @@ a feature supplying only its accent. `gev-status` store; `SplitFlapText` plays the departure-board flip that `splitFlap.js` plans, writing only the label's one text node; the toast renders what each owner's `createToast()` publishes there, on that - owner's own timer), then + owner's own timer), the Scene actions panel (`SceneInteractions` + teleports what the scene interactions owner publishes to the + `gev-scene-interactions` store into the viewer's container; the owner + keeps the pick handler and focuses the picked action), then Cockpit (in progress: `CockpitBriefing` renders the briefing card's pages, cycle toggle, regional news and local conditions, and `CockpitContext` the Contact card's scope, navigation, counts, nearest diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c4cb057aa..c5593ccb2 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1132,5 +1132,7 @@ "src/ui/statusChipsView.test.mjs", "src/ui/statusChipOwners.test.mjs", "src/ui/toast.js", - "src/ui/toastView.test.mjs" + "src/ui/toastView.test.mjs", + "src/ui/stores/sceneInteractions.js", + "src/scenes/interactionsView.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d3f80f6dd..ec8b207b9 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -97,7 +97,9 @@ "src/ui/stores/imagerySplit.js", "src/ui/stores/status.js", "src/ui/views/panels/SplitFlapText.vue", - "src/splitFlap.js" + "src/splitFlap.js", + "src/ui/stores/sceneInteractions.js", + "src/ui/views/panels/SceneInteractions.vue" ], "external": ["vue", "pinia"] }, @@ -1147,7 +1149,8 @@ "src/visualStylePolicy.js", "src/ui/vueRuntime.js", "src/ui/stores/status.js", - "src/ui/toast.js" + "src/ui/toast.js", + "src/ui/stores/sceneInteractions.js" ], "external": ["cesium", "vue", "pinia"] }, @@ -3004,7 +3007,8 @@ "src/ui/stores/display.js", "src/ui/stores/imagerySplit.js", "src/ui/stores/status.js", - "src/ui/toast.js" + "src/ui/toast.js", + "src/ui/stores/sceneInteractions.js" ], "external": [ "hls.js", diff --git a/src/scenes/interactions.js b/src/scenes/interactions.js index c7cc22f31..57cf6b436 100644 --- a/src/scenes/interactions.js +++ b/src/scenes/interactions.js @@ -1,68 +1,79 @@ import * as Cesium from 'cesium'; +import { nextTick } from 'vue'; import { isPointerFree } from '../data/inputOwnership.js'; import { createInteractionSession } from '../director/interactions/session.js'; import { t } from '../i18n/index.js'; +import { useSceneInteractionsStore } from '../ui/stores/sceneInteractions.js'; +import { applicationPinia } from '../ui/vueRuntime.js'; + +/** + * Own the active shot's pick handler, accessible actions and plain-text + * feedback. The Scene actions panel is presented to the scene interactions + * store, which its view renders inside the viewer's container. + */ +// Keys each activation's panel, so a new one never reuses an old one's DOM. +let activations = 0; -/** Own the active shot's pick handler, accessible actions and plain-text feedback. */ export function createSceneInteractions( viewer, - { execute, available = () => true }, + { + execute, + available = () => true, + store = useSceneInteractionsStore(applicationPinia()), + }, ) { - let panel, - status, - card, + let panel = null, handler, items = [], targets = new Map(); - const buttons = new Map(); + // Only the owner whose panel the store shows may change or remove it. + const shown = () => panel && store.panel?.owner === panel.owner; + const update = (patch) => { + if (!shown()) return; + panel = { ...panel, ...patch }; + store.publish(panel); + }; const session = createInteractionSession({ execute: (item, signal) => { if (!isPointerFree() || !available()) return false; - card.replaceChildren(); if (item.action.type === 'card') { - const text = document.createElement('p'); - text.textContent = item.action.text; - card.append(text); - if (item.action.url) { - const link = document.createElement('a'); - link.textContent = t('Source'); - link.style.color = '#6eeaff'; - link.href = item.action.url; - link.target = '_blank'; - link.rel = 'noopener noreferrer'; - card.append(link); - } + update({ + card: { text: item.action.text, url: item.action.url || null }, + }); return true; } + update({ card: null }); return execute(item.action, signal); }, changed(state) { - for (const [id, button] of buttons) { - button.disabled = state.busy; - button.setAttribute('aria-pressed', String(state.selected === id)); - button.style.outline = state.selected === id ? '2px solid #6eeaff' : ''; - } + update({ + busy: state.busy, + selected: state.selected, + outlined: state.selected ? [state.selected] : [], + }); }, }); function clear() { session.clear(); handler?.destroy(); handler = null; - panel?.remove(); + if (shown()) { + store.connect(null); + store.publish(null); + } panel = null; - status = null; - card = null; - buttons.clear(); items = []; targets.clear(); } async function dispatch(id) { const current = panel; const ok = await session.dispatch(id); - if (current && current === panel && status) - status.textContent = ok - ? t('Action complete') - : t('Action unavailable or cancelled'); + if (current && panel?.owner === current.owner) + update({ + status: ok + ? t('Action complete') + : t('Action unavailable or cancelled'), + }); return ok; } return { @@ -86,65 +97,35 @@ export function createSceneInteractions( targets.clear(); throw new Error('Interaction feature is missing from the loaded pack'); } - panel = document.createElement('section'); - panel.dataset.directorInteractions = ''; - panel.setAttribute('aria-label', t('Scene actions')); - Object.assign(panel.style, { - position: 'absolute', - left: '12px', - bottom: '80px', - zIndex: '31', - maxWidth: '300px', - maxHeight: '40vh', - overflowY: 'auto', - background: '#08141eed', - color: '#e3faff', - border: '1px solid #2491a8', - padding: '10px', - font: '13px sans-serif', + // Each activation owns its panel; a click on an older one is ignored. + const owner = {}; + panel = { + id: ++activations, + owner, + container: viewer.container, + items: items.map(({ id, label }) => ({ id, label })), + status: t('Select a feature or use Tab and Enter to choose an action.'), + busy: false, + selected: null, + outlined: [], + card: null, + }; + store.publish(panel); + store.connect({ + dispatch(id, from) { + if (from === owner && panel?.owner === owner) void dispatch(id); + }, }); - const owner = panel; - const title = document.createElement('strong'); - title.textContent = t('Scene actions'); - panel.append(title); - status = document.createElement('p'); - status.setAttribute('role', 'status'); - status.textContent = t( - 'Select a feature or use Tab and Enter to choose an action.', - ); - panel.append(status); - for (const item of items) { - const button = document.createElement('button'); - button.type = 'button'; - button.textContent = item.label; - button.dataset.directorAction = item.id; - Object.assign(button.style, { - display: 'block', - background: '#10242e', - color: '#e3faff', - border: '1px solid #397080', - borderRadius: '3px', - padding: '6px 10px', - cursor: 'pointer', - font: 'inherit', - margin: '6px 0', - maxWidth: '100%', - whiteSpace: 'normal', - }); - button.addEventListener('click', () => { - if (panel === owner) void dispatch(item.id); - }); - buttons.set(item.id, button); - panel.append(button); - } - card = document.createElement('div'); - card.dataset.directorActionCard = ''; - panel.append(card); - viewer.container.append(panel); session.activate(items); handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction((event) => { - if (panel !== owner || !isPointerFree() || !available()) return; + if ( + panel?.owner !== owner || + !shown() || + !isPointerFree() || + !available() + ) + return; const picked = viewer.scene.pick(event.position)?.id; const matches = items.filter( (item) => @@ -153,19 +134,22 @@ export function createSceneInteractions( ) === picked, ); if (!matches.length) return; - for (const item of items) { - const button = buttons.get(item.id); - button.style.outline = matches.includes(item) - ? '2px solid #6eeaff' - : ''; - } - status.textContent = t( - 'Selected feature: {feature}. Choose an action.', - { + update({ + outlined: matches.map((item) => item.id), + status: t('Selected feature: {feature}. Choose an action.', { feature: matches[0].target.featureId, - }, - ); - buttons.get(matches[0].id)?.focus({ preventScroll: true }); + }), + }); + // Focus the first match once the panel shows the selection. + void nextTick(() => { + if (panel?.owner !== owner || !shown()) return; + const id = globalThis.CSS?.escape?.(matches[0].id) ?? matches[0].id; + viewer.container + ?.querySelector?.( + `[data-director-interactions] [data-director-action="${id}"]`, + ) + ?.focus({ preventScroll: true }); + }); }, Cesium.ScreenSpaceEventType.LEFT_CLICK); }, }; diff --git a/src/scenes/interactionsView.test.mjs b/src/scenes/interactionsView.test.mjs new file mode 100644 index 000000000..089459b5d --- /dev/null +++ b/src/scenes/interactionsView.test.mjs @@ -0,0 +1,192 @@ +// src/scenes/interactionsView.test.mjs — the Scene actions panel renders in +// the viewer's container from what the scene interactions owner presents. +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 { useSceneInteractionsStore } from '../ui/stores/sceneInteractions.js'; +import SceneInteractions from '../ui/views/panels/SceneInteractions.vue'; +import { createSceneInteractions } from './interactions.js'; + +const target = (featureId) => ({ packId: 'qa', featureId }); +const key = (featureId) => JSON.stringify(['qa', featureId]); +const SHOT = { + interactions: [ + { + id: 'card', + label: 'Read about it', + target: target('point'), + action: { type: 'card', text: 'A note.', url: 'https://example.com/a' }, + }, + { + id: 'fly', + label: 'Fly there', + target: target('point'), + action: { type: 'fly' }, + }, + ], +}; + +function fixture(t, { execute = async () => true } = {}) { + document.body.replaceChildren(); + const container = document.body.appendChild(document.createElement('div')); + const canvas = container.appendChild(document.createElement('canvas')); + const pinia = createPinia(); + const app = createApp(SceneInteractions).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + const executed = []; + const interactions = createSceneInteractions( + { container, scene: { canvas, pick: () => null } }, + { + execute: (action, signal) => { + executed.push(action.type); + return execute(action, signal); + }, + store: useSceneInteractionsStore(pinia), + }, + ); + t.after(() => { + interactions.destroy(); + app.unmount(); + }); + const $ = (selector) => container.querySelector(selector); + const settle = async () => { + for (let flush = 0; flush < 4; flush += 1) await nextTick(); + }; + return { interactions, container, executed, $, settle, pinia }; +} + +test('a shot with actions shows its panel in the viewer container', async (t) => { + const { interactions, $, settle } = fixture(t); + assert.equal($('[data-director-interactions]'), null); + interactions.activate(SHOT, new Map([[key('point'), {}]])); + await settle(); + const panel = $('[data-director-interactions]'); + assert.equal(panel.tagName, 'SECTION'); + assert.equal(panel.getAttribute('aria-label'), 'Scene actions'); + assert.equal(panel.style.position, 'absolute'); + assert.equal(panel.style.zIndex, '31'); + assert.equal(panel.querySelector('strong').textContent, 'Scene actions'); + assert.equal( + panel.querySelector('[role=status]').textContent, + 'Select a feature or use Tab and Enter to choose an action.', + ); + const buttons = [...panel.querySelectorAll('button')]; + assert.deepEqual( + buttons.map((button) => [ + button.dataset.directorAction, + button.textContent, + ]), + [ + ['card', 'Read about it'], + ['fly', 'Fly there'], + ], + ); + assert.ok(buttons.every((button) => button.type === 'button')); + assert.deepEqual( + buttons.map((button) => button.getAttribute('aria-pressed')), + ['false', 'false'], + ); + assert.equal($('[data-director-action-card]').children.length, 0); +}); + +test('an action reports its outcome and a card action shows its text and source', async (t) => { + const { interactions, executed, $, settle } = fixture(t); + interactions.activate(SHOT, new Map([[key('point'), {}]])); + await settle(); + $('[data-director-action="card"]').click(); + await settle(); + const card = $('[data-director-action-card]'); + assert.equal(card.querySelector('p').textContent, 'A note.'); + const link = card.querySelector('a'); + assert.equal(link.textContent, 'Source'); + assert.equal(link.getAttribute('href'), 'https://example.com/a'); + assert.equal(link.target, '_blank'); + assert.equal(link.rel, 'noopener noreferrer'); + assert.equal($('[role=status]').textContent, 'Action complete'); + const card1 = $('[data-director-action="card"]'); + assert.equal(card1.getAttribute('aria-pressed'), 'true'); + // happy-dom orders the shorthand its own way. + assert.deepEqual(card1.style.outline.split(' ').sort(), [ + '#6eeaff', + '2px', + 'solid', + ]); + $('[data-director-action="fly"]').click(); + await settle(); + assert.deepEqual(executed, ['fly']); + assert.equal(card.children.length, 0, 'another action clears the card'); + assert.equal($('[data-director-action="card"]').style.outline, ''); +}); + +test('buttons are disabled while an action runs, and clearing removes the panel', async (t) => { + let finish; + const { interactions, $, settle } = fixture(t, { + execute: () => new Promise((resolve) => (finish = resolve)), + }); + interactions.activate(SHOT, new Map([[key('point'), {}]])); + await settle(); + $('[data-director-action="fly"]').click(); + await settle(); + assert.ok( + [...$('[data-director-interactions]').querySelectorAll('button')].every( + (button) => button.disabled, + ), + ); + finish(false); + await settle(); + assert.equal( + $('[role=status]').textContent, + 'Action unavailable or cancelled', + ); + interactions.clear(); + await settle(); + assert.equal($('[data-director-interactions]'), null); +}); + +test('a button from an earlier panel cannot run the replacement actions', async (t) => { + const { interactions, executed, $, settle } = fixture(t); + interactions.activate(SHOT, new Map([[key('point'), {}]])); + await settle(); + const old = $('[data-director-action="fly"]'); + interactions.clear(); + await settle(); + interactions.activate(SHOT, new Map([[key('point'), {}]])); + await settle(); + assert.ok(old !== $('[data-director-action="fly"]')); + old.click(); + await settle(); + assert.deepEqual(executed, []); + assert.equal(interactions.getState().selected, null); +}); + +test('a button reused before the next render still belongs to its own panel', async (t) => { + const { interactions, executed, $, settle } = fixture(t); + interactions.activate(SHOT, new Map([[key('point'), {}]])); + await settle(); + const old = $('[data-director-action="fly"]'); + interactions.activate(SHOT, new Map([[key('point'), {}]])); + old.click(); + await settle(); + assert.deepEqual(executed, []); + assert.ok(old !== $('[data-director-action="fly"]')); + assert.equal(old.isConnected, false); +}); + +test("an earlier owner leaves a later owner's panel alone", async (t) => { + const { interactions, container, $, settle, pinia } = fixture(t); + const later = createSceneInteractions( + { container, scene: { canvas: $('canvas'), pick: () => null } }, + { execute: async () => true, store: useSceneInteractionsStore(pinia) }, + ); + t.after(() => later.destroy()); + interactions.activate(SHOT, new Map([[key('point'), {}]])); + later.activate(SHOT, new Map([[key('point'), {}]])); + interactions.destroy(); + await settle(); + assert.ok($('[data-director-interactions]')); + $('[data-director-action="fly"]').click(); + await settle(); + assert.equal(later.getState().selected, 'fly'); +}); diff --git a/src/ui/stores/sceneInteractions.js b/src/ui/stores/sceneInteractions.js new file mode 100644 index 000000000..6e30af9a9 --- /dev/null +++ b/src/ui/stores/sceneInteractions.js @@ -0,0 +1,35 @@ +import { defineStore } from 'pinia'; +import { markRaw, shallowRef } from 'vue'; + +/** + * The active shot's Scene actions panel as its view renders it. The scene + * interactions owner (src/scenes/interactions.js) presents the actions, the + * status line and the source card here; the view sends action clicks back to + * the connected controls. + */ +export const useSceneInteractionsStore = defineStore( + 'gev-scene-interactions', + () => { + /** + * `{id, owner, container, items, status, busy, selected, outlined, + * card}`, or null while the shot has no actions. `container` is the + * element it sits in; `owner` is passed back with each dispatch. + */ + const panel = shallowRef(null); + + /** @param {?object} next A fresh presentation; every publish re-renders. */ + function publish(next) { + panel.value = next && markRaw(next); + } + + /** The controls whose `dispatch(id, owner)` an action button calls. */ + const controls = shallowRef(null); + + /** @param {?object} next The controls, or null to disconnect. */ + function connect(next) { + controls.value = next && markRaw(next); + } + + return { panel, publish, controls, connect }; + }, +); diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index b165d1adf..b763f7211 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -6,6 +6,7 @@ import { useLocationStore } from '../stores/location.js'; import { useStatusStore } from '../stores/status.js'; import { vClassToggle } from '../rail/classToggle.js'; import ImagerySplitView from './panels/ImagerySplitView.vue'; +import SceneInteractions from './panels/SceneInteractions.vue'; import SplitFlapText from './panels/SplitFlapText.vue'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. @@ -95,4 +96,5 @@ const vToastText = { + diff --git a/src/ui/views/panels/SceneInteractions.vue b/src/ui/views/panels/SceneInteractions.vue new file mode 100644 index 000000000..88d1013e4 --- /dev/null +++ b/src/ui/views/panels/SceneInteractions.vue @@ -0,0 +1,50 @@ + + +