mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(scenes): render the Scene actions panel from a store
The scene interactions owner keeps the pick handler and the interaction session and presents the panel to the gev-scene-interactions store; the SceneInteractions view teleports it into the viewer's container with the same markup and inline styles. Each activation keys its own panel, each button carries the owner it was rendered for, and only the owner the store shows may change or remove it.
This commit is contained in:
1 parent
6398103b60
commit
59e3aaa271
8 files changed
+376
-104
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
+83
-99
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
@@ -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 = {
|
||||
</Teleport>
|
||||
|
||||
<ImagerySplitView />
|
||||
<SceneInteractions />
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { useSceneInteractionsStore } from '../../stores/sceneInteractions.js';
|
||||
|
||||
// The active shot's Scene actions panel, inside the viewer's container, as
|
||||
// the scene interactions owner (src/scenes/interactions.js) presents it.
|
||||
const { panel, controls } = storeToRefs(useSceneInteractionsStore());
|
||||
|
||||
const panelStyle = {
|
||||
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',
|
||||
};
|
||||
const buttonStyle = {
|
||||
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',
|
||||
};
|
||||
// Each button keeps the panel it was rendered for, so one left over from an
|
||||
// earlier panel cannot run the current actions.
|
||||
const onClick = (owner, id) => () => controls.value?.dispatch(id, owner);
|
||||
const outline = (id) => ({
|
||||
...buttonStyle,
|
||||
outline: panel.value.outlined.includes(id) ? '2px solid #6eeaff' : '',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="panel" :to="panel.container">
|
||||
<section :key="panel.id" data-director-interactions="" :aria-label="t('Scene actions')" :style="panelStyle"><strong>{{ t('Scene actions') }}</strong><p role="status">{{ panel.status }}</p><button v-for="item in panel.items" :key="item.id" type="button" :data-director-action="item.id" :disabled="panel.busy" :aria-pressed="String(panel.selected === item.id)" :style="outline(item.id)" :onClick="onClick(panel.owner, item.id)">{{ item.label }}</button><div data-director-action-card=""><template v-if="panel.card"><p>{{ panel.card.text }}</p><a v-if="panel.card.url" :href="panel.card.url" target="_blank" rel="noopener noreferrer" style="color: #6eeaff">{{ t('Source') }}</a></template></div></section>
|
||||
</Teleport>
|
||||
</template>
|
||||
Reference in new issue
Block a user