mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat(ui): add a desktop panel rail to each sidebar
Each side stack hosts a Vue rail listing its panels; selecting one opens it through PanelChrome. The shell owns one Vue runtime and Pinia instance, and a panel store mirrors each side's panels from the DOM. The rail hides with its stack, in Cockpit, clean view and recording, and on phones. Panels without a catalog entry get a generic item, and Data Layers leaves the rail while the f shortcut hides it.
This commit is contained in:
1 parent
eed7657516
commit
3d5d3ef014
29 files changed
+627
-32
No files matched your search
@@ -2943,5 +2943,7 @@
|
||||
"Load scenes from an exported JSON file": "Szenen aus einer exportierten JSON-Datei laden",
|
||||
"Download the timing log of the last play": "Zeitprotokoll der letzten Wiedergabe herunterladen",
|
||||
"Edit anchors, data packs and shot details": "Anker, Datenpakete und Aufnahmedetails bearbeiten",
|
||||
"Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen"
|
||||
"Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen",
|
||||
"Left panels": "Linke Panels",
|
||||
"Right panels": "Rechte Panels"
|
||||
}
|
||||
@@ -3013,5 +3013,7 @@
|
||||
"Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el resto del proyecto",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "Reproduce el plano posterior al seleccionado y pasa a la siguiente escena si hace falta",
|
||||
"Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos",
|
||||
"Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos"
|
||||
"Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos",
|
||||
"Left panels": "Paneles izquierdos",
|
||||
"Right panels": "Paneles derechos"
|
||||
}
|
||||
@@ -3013,5 +3013,7 @@
|
||||
"Load scenes from an exported JSON file": "Charger des scènes depuis un fichier JSON exporté",
|
||||
"Download the timing log of the last play": "Télécharger le journal de minutage de la dernière lecture",
|
||||
"Edit anchors, data packs and shot details": "Modifier les ancres, les paquets de données et les détails des plans",
|
||||
"Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources"
|
||||
"Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources",
|
||||
"Left panels": "Panneaux de gauche",
|
||||
"Right panels": "Panneaux de droite"
|
||||
}
|
||||
@@ -3013,5 +3013,7 @@
|
||||
"Load scenes from an exported JSON file": "Carica le scene da un file JSON esportato",
|
||||
"Download the timing log of the last play": "Scarica il log dei tempi dell'ultima riproduzione",
|
||||
"Edit anchors, data packs and shot details": "Modifica ancore, pacchetti dati e dettagli degli scatti",
|
||||
"Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse"
|
||||
"Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse",
|
||||
"Left panels": "Pannelli a sinistra",
|
||||
"Right panels": "Pannelli a destra"
|
||||
}
|
||||
@@ -2873,5 +2873,7 @@
|
||||
"Load scenes from an exported JSON file": "書き出したJSONファイルからシーンを読み込む",
|
||||
"Download the timing log of the last play": "前回の再生のタイミングログをダウンロード",
|
||||
"Edit anchors, data packs and shot details": "アンカー、データパック、ショットの詳細を編集",
|
||||
"Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード"
|
||||
"Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード",
|
||||
"Left panels": "左パネル",
|
||||
"Right panels": "右パネル"
|
||||
}
|
||||
@@ -3013,5 +3013,7 @@
|
||||
"Load scenes from an exported JSON file": "Carregar cenas de um arquivo JSON exportado",
|
||||
"Download the timing log of the last play": "Baixar o registro de tempos da última reprodução",
|
||||
"Edit anchors, data packs and shot details": "Editar âncoras, pacotes de dados e detalhes dos planos",
|
||||
"Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos"
|
||||
"Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos",
|
||||
"Left panels": "Painéis à esquerda",
|
||||
"Right panels": "Painéis à direita"
|
||||
}
|
||||
@@ -3083,5 +3083,7 @@
|
||||
"Play from this scene through the rest of the project": "Воспроизвести с этой сцены и далее по всему проекту",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "Воспроизвести кадр после выбранного, при необходимости перейдя к следующей сцене",
|
||||
"Edit anchors, data packs and shot details": "Изменить якоря, пакеты данных и параметры кадров",
|
||||
"Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов"
|
||||
"Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов",
|
||||
"Left panels": "Левые панели",
|
||||
"Right panels": "Правые панели"
|
||||
}
|
||||
@@ -3083,5 +3083,7 @@
|
||||
"Load scenes from an exported JSON file": "Завантажити сцени з експортованого файлу JSON",
|
||||
"Download the timing log of the last play": "Завантажити журнал таймінгів останнього відтворення",
|
||||
"Edit anchors, data packs and shot details": "Змінити якорі, пакети даних і параметри кадрів",
|
||||
"Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів"
|
||||
"Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів",
|
||||
"Left panels": "Ліві панелі",
|
||||
"Right panels": "Праві панелі"
|
||||
}
|
||||
@@ -2873,5 +2873,7 @@
|
||||
"Play from this scene through the rest of the project": "从此场景开始播放项目中的其余场景",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "播放所选镜头的下一个镜头,必要时进入下一个场景",
|
||||
"Edit anchors, data packs and shot details": "编辑锚点、数据包和镜头详情",
|
||||
"Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包"
|
||||
"Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包",
|
||||
"Left panels": "左侧面板",
|
||||
"Right panels": "右侧面板"
|
||||
}
|
||||
@@ -2873,5 +2873,7 @@
|
||||
"Play from this scene through the rest of the project": "從此場景開始播放項目中的其餘場景",
|
||||
"Play the shot after the selected one, moving on to the next scene when needed": "播放所選鏡頭的下一個鏡頭,必要時進入下一個場景",
|
||||
"Edit anchors, data packs and shot details": "編輯錨點、數據包和鏡頭詳情",
|
||||
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包"
|
||||
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
|
||||
"Left panels": "左側面板",
|
||||
"Right panels": "右側面板"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { t } from '../i18n/index.js';
|
||||
import UiRail from './kit/UiRail.vue';
|
||||
import {
|
||||
PANEL_RAIL_FALLBACK_ICON,
|
||||
PANEL_RAIL_ITEMS,
|
||||
} from './panelRailCatalog.js';
|
||||
import { usePanelStore } from './stores/panels.js';
|
||||
|
||||
const props = defineProps({
|
||||
/** `left` or `right`. */
|
||||
side: { type: String, required: true },
|
||||
/** Open or close a panel; receives the panel id. */
|
||||
onSelect: { type: Function, required: true },
|
||||
});
|
||||
|
||||
const panels = usePanelStore();
|
||||
const items = computed(() =>
|
||||
panels.sides[props.side]
|
||||
.filter((panel) => panel.available)
|
||||
.map((panel) => {
|
||||
const item = PANEL_RAIL_ITEMS[panel.id];
|
||||
return {
|
||||
id: panel.id,
|
||||
icon: item?.icon ?? PANEL_RAIL_FALLBACK_ICON,
|
||||
// Static markup is translated at boot, so a host's title already is.
|
||||
label: item ? t(item.label) : panel.title,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const active = computed(() => panels.openPanelId(props.side));
|
||||
const label = computed(() =>
|
||||
props.side === 'left' ? t('Left panels') : t('Right panels'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiRail
|
||||
:items="items"
|
||||
:active="active"
|
||||
:side="side"
|
||||
:label="label"
|
||||
:panel-open="active !== null"
|
||||
@select="onSelect"
|
||||
/>
|
||||
</template>
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
t,
|
||||
} from '../i18n/index.js';
|
||||
import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
|
||||
import { PanelNavigation } from './panelNavigation.js';
|
||||
import { createUiRuntime } from './vueRuntime.js';
|
||||
|
||||
/**
|
||||
* Central UI orchestrator for the God's Eye View application.
|
||||
@@ -133,6 +135,8 @@ export class StyleManager extends ShellFacade {
|
||||
readSharedCollapsed: (panelId) =>
|
||||
this._mobileShell?.sharedCollapsed(panelId),
|
||||
});
|
||||
// Vue views share this runtime and its stores; dispose unmounts them.
|
||||
this._ui = createUiRuntime();
|
||||
this._feedback = new ShellFeedback({
|
||||
readLayers: () => this._dataManager?.getAll?.() || [],
|
||||
});
|
||||
@@ -594,6 +598,13 @@ export class StyleManager extends ShellFacade {
|
||||
// Headers first: panel chrome builds its toggle labels from them.
|
||||
this._applyRuntimeStaticHeaderText();
|
||||
this._initPanelChrome();
|
||||
this._panelNavigation = new PanelNavigation({
|
||||
ui: this._ui,
|
||||
stacks: { left: this._leftPanelStack, right: this._rightPanelStack },
|
||||
setPanelCollapsed: (...args) =>
|
||||
this._panelChrome.setPanelCollapsed(...args),
|
||||
});
|
||||
this._panelNavigation.init();
|
||||
this._initLocaleSelector();
|
||||
this._sizeDisplayLabelColumn();
|
||||
this._initLeftPanelAdaptiveLayout();
|
||||
@@ -1776,6 +1787,8 @@ export class StyleManager extends ShellFacade {
|
||||
this._localePicker = null;
|
||||
this._recording.destroy();
|
||||
this._mobileShell?.destroy();
|
||||
this._panelNavigation?.destroy();
|
||||
this._ui.destroy();
|
||||
this._panelChrome.destroy();
|
||||
this._feedback.destroy();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Own rail measurement, layout scheduling and dock tray observation. */
|
||||
import { layoutLeftPanelRail, layoutRightPanelRail } from './panelRails.js';
|
||||
import { syncRadioPanelPlacement } from './radioPanelPlacement.js';
|
||||
import { isPanelRailMutation } from './panelRailCatalog.js';
|
||||
const COCKPIT_LAYOUT_SETTLE_MS = 240;
|
||||
/**
|
||||
* Fixed UI regions that can occupy the left accordion's vertical lane.
|
||||
@@ -233,7 +234,8 @@ export class PanelLayoutController {
|
||||
}
|
||||
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
this._rightStackMutationObserver = new MutationObserver(() => {
|
||||
this._rightStackMutationObserver = new MutationObserver((records) => {
|
||||
if (records.every(isPanelRailMutation)) return;
|
||||
this._scheduleRightPanelLayout();
|
||||
});
|
||||
this._rightStackMutationObserver.observe(stack, {
|
||||
@@ -334,7 +336,8 @@ export class PanelLayoutController {
|
||||
}
|
||||
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
this._leftStackMutationObserver = new MutationObserver(() => {
|
||||
this._leftStackMutationObserver = new MutationObserver((records) => {
|
||||
if (records.every(isPanelRailMutation)) return;
|
||||
this._scheduleLeftPanelLayout();
|
||||
});
|
||||
this._leftStackMutationObserver.observe(stack, {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import PanelRailView from './PanelRailView.vue';
|
||||
import { PANEL_RAIL_HOST_CLASS, PANEL_RAIL_ITEMS } from './panelRailCatalog.js';
|
||||
import { usePanelStore } from './stores/panels.js';
|
||||
|
||||
/**
|
||||
* Own the desktop panel rails: publish each side's panels to the panel store
|
||||
* and open or close a panel when its rail item is selected. Disclosure itself
|
||||
* stays with PanelChrome, which enforces one open panel per side.
|
||||
*/
|
||||
export class PanelNavigation {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {{pinia: object, mount: Function}} options.ui Shell Vue runtime.
|
||||
* @param {{left: HTMLElement|null, right: HTMLElement|null}} options.stacks Side panel stacks.
|
||||
* @param {Function} options.setPanelCollapsed PanelChrome's disclosure operation.
|
||||
*/
|
||||
constructor({ ui, stacks, setPanelCollapsed }) {
|
||||
this._ui = ui;
|
||||
this._stacks = stacks;
|
||||
this._setPanelCollapsed = setPanelCollapsed;
|
||||
this._store = usePanelStore(ui.pinia);
|
||||
this._unmounts = [];
|
||||
this._hosts = [];
|
||||
this._observer = null;
|
||||
this._destroyed = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this._destroyed) return;
|
||||
for (const side of ['left', 'right']) {
|
||||
const stack = this._stacks[side];
|
||||
if (!stack) continue;
|
||||
// Inside the stack, the rail shows and hides with its panels in every
|
||||
// presentation mode (Cockpit, clean view, recording, phone sheets).
|
||||
const host = stack.ownerDocument.createElement('div');
|
||||
host.className = `${PANEL_RAIL_HOST_CLASS} ${PANEL_RAIL_HOST_CLASS}--${side}`;
|
||||
stack.prepend(host);
|
||||
this._hosts.push(host);
|
||||
this._unmounts.push(
|
||||
this._ui.mount(PanelRailView, host, {
|
||||
side,
|
||||
onSelect: (panelId) => this.select(panelId),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
// Only the panels themselves matter: their order, disclosure and
|
||||
// availability, not changes inside them or inside the rails.
|
||||
this._observer = new MutationObserver((records) => {
|
||||
if (records.some((record) => this._isPanelRecord(record))) this.sync();
|
||||
});
|
||||
for (const stack of Object.values(this._stacks)) {
|
||||
if (!stack) continue;
|
||||
this._observer.observe(stack, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'hidden'],
|
||||
});
|
||||
}
|
||||
}
|
||||
this.sync();
|
||||
}
|
||||
|
||||
/** Publish both sides' panels in display order. */
|
||||
sync() {
|
||||
if (this._destroyed) return;
|
||||
for (const side of ['left', 'right']) {
|
||||
const stack = this._stacks[side];
|
||||
const panels = stack
|
||||
? [...stack.children]
|
||||
.filter((panel) => panel.matches('[data-panel-id]'))
|
||||
.map((panel) => {
|
||||
const shownBy = PANEL_RAIL_ITEMS[panel.id]?.shownBy;
|
||||
return {
|
||||
id: panel.id,
|
||||
title:
|
||||
panel
|
||||
.querySelector('.panel-title, .pp-header-label')
|
||||
?.textContent.trim() || panel.id,
|
||||
open: !panel.classList.contains('collapsed'),
|
||||
available:
|
||||
!panel.hidden &&
|
||||
(!shownBy || panel.classList.contains(shownBy)),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
this._store.setSide(side, panels);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a panel from its rail item.
|
||||
* @param {string} panelId Panel element id.
|
||||
*/
|
||||
select(panelId) {
|
||||
if (this._destroyed) return;
|
||||
this._setPanelCollapsed(panelId, this._store.isOpen(panelId), {
|
||||
explicit: true,
|
||||
});
|
||||
}
|
||||
|
||||
_isPanelRecord(record) {
|
||||
const stacks = Object.values(this._stacks);
|
||||
return record.type === 'childList'
|
||||
? stacks.includes(record.target) &&
|
||||
[...record.addedNodes, ...record.removedNodes].some(
|
||||
(node) => !this._hosts.includes(node),
|
||||
)
|
||||
: stacks.includes(record.target.parentElement);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
this._destroyed = true;
|
||||
this._observer?.disconnect();
|
||||
this._observer = null;
|
||||
for (const unmount of this._unmounts) unmount();
|
||||
this._unmounts = [];
|
||||
for (const host of this._hosts) host.remove();
|
||||
this._hosts = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../testSupport/vueDom.mjs';
|
||||
|
||||
let PanelNavigation;
|
||||
let createUiRuntime;
|
||||
let nextTick;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ nextTick } = await import('vue'));
|
||||
({ PanelNavigation } = await import('./panelNavigation.js'));
|
||||
({ createUiRuntime } = await import('./vueRuntime.js'));
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
function panel(id, { collapsed = true, hidden = false, title = '' } = {}) {
|
||||
const element = document.createElement('div');
|
||||
element.id = id;
|
||||
element.dataset.panelId = id;
|
||||
element.className = collapsed
|
||||
? 'panel-collapsible collapsed'
|
||||
: 'panel-collapsible';
|
||||
// Data Layers shows only while `active` (the `f` shortcut toggles it).
|
||||
if (id === 'data-panel') element.classList.add('active');
|
||||
element.hidden = hidden;
|
||||
if (title) element.innerHTML = `<span class="panel-title">${title}</span>`;
|
||||
return element;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
document.body.innerHTML = '';
|
||||
const left = document.createElement('div');
|
||||
left.id = 'left-panel-stack';
|
||||
left.append(panel('data-panel', { collapsed: false }), panel('scene-panel'));
|
||||
const right = document.createElement('div');
|
||||
right.id = 'right-context-rail';
|
||||
right.append(
|
||||
panel('pp-toggles'),
|
||||
panel('weather-panel', { hidden: true }),
|
||||
panel('global-context-panel'),
|
||||
panel('host-panel', { title: 'HOST' }),
|
||||
);
|
||||
document.body.append(left, right);
|
||||
const calls = [];
|
||||
const ui = createUiRuntime();
|
||||
const navigation = new PanelNavigation({
|
||||
ui,
|
||||
stacks: { left, right },
|
||||
setPanelCollapsed: (...args) => calls.push(args),
|
||||
});
|
||||
navigation.init();
|
||||
return { left, right, calls, ui, navigation };
|
||||
}
|
||||
|
||||
const railItems = (stack) =>
|
||||
[...stack.querySelectorAll('.gev-panel-rail [data-rail-item]')].map(
|
||||
(button) => [button.dataset.railItem, button.getAttribute('aria-pressed')],
|
||||
);
|
||||
|
||||
test('each stack gets a rail of its offered panels, marking the open one', async () => {
|
||||
const { left, right, ui, navigation } = setup();
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
left.firstElementChild.className,
|
||||
'gev-panel-rail gev-panel-rail--left',
|
||||
);
|
||||
assert.deepEqual(railItems(left), [
|
||||
['data-panel', 'true'],
|
||||
['scene-panel', 'false'],
|
||||
]);
|
||||
// Hidden panels are not offered; a host's panel gets a generic item.
|
||||
assert.deepEqual(railItems(right), [
|
||||
['pp-toggles', 'false'],
|
||||
['global-context-panel', 'false'],
|
||||
['host-panel', 'false'],
|
||||
]);
|
||||
assert.equal(
|
||||
right
|
||||
.querySelector('[data-rail-item="host-panel"]')
|
||||
.getAttribute('aria-label'),
|
||||
'HOST',
|
||||
);
|
||||
assert.equal(
|
||||
left.querySelector('nav').getAttribute('aria-label'),
|
||||
'Left panels',
|
||||
);
|
||||
navigation.destroy();
|
||||
ui.destroy();
|
||||
});
|
||||
|
||||
test('selecting an item toggles its panel explicitly', async () => {
|
||||
const { left, calls, ui, navigation } = setup();
|
||||
await nextTick();
|
||||
left.querySelector('[data-rail-item="scene-panel"]').click();
|
||||
left.querySelector('[data-rail-item="data-panel"]').click();
|
||||
assert.deepEqual(calls, [
|
||||
['scene-panel', false, { explicit: true }],
|
||||
['data-panel', true, { explicit: true }],
|
||||
]);
|
||||
navigation.destroy();
|
||||
ui.destroy();
|
||||
});
|
||||
|
||||
test('the rails follow disclosure and availability changes', async () => {
|
||||
const { left, right, ui, navigation } = setup();
|
||||
document.getElementById('data-panel').classList.add('collapsed');
|
||||
document.getElementById('scene-panel').classList.remove('collapsed');
|
||||
document.getElementById('weather-panel').hidden = false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
assert.deepEqual(railItems(left), [
|
||||
['data-panel', 'false'],
|
||||
['scene-panel', 'true'],
|
||||
]);
|
||||
assert.deepEqual(railItems(right), [
|
||||
['pp-toggles', 'false'],
|
||||
['weather-panel', 'false'],
|
||||
['global-context-panel', 'false'],
|
||||
['host-panel', 'false'],
|
||||
]);
|
||||
// `f` hides Data Layers outright, so the rail stops offering it.
|
||||
document.getElementById('data-panel').classList.remove('active');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
assert.deepEqual(railItems(left), [['scene-panel', 'true']]);
|
||||
navigation.destroy();
|
||||
ui.destroy();
|
||||
});
|
||||
|
||||
test('destroy removes the rails and stops following the stacks', async () => {
|
||||
const { left, right, calls, ui, navigation } = setup();
|
||||
navigation.destroy();
|
||||
navigation.destroy();
|
||||
assert.equal(left.querySelector('.gev-panel-rail'), null);
|
||||
assert.equal(right.querySelector('.gev-panel-rail'), null);
|
||||
navigation.select('data-panel');
|
||||
assert.deepEqual(calls, []);
|
||||
ui.destroy();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { msg } from '../i18n/index.js';
|
||||
|
||||
/** Class of the element each side stack hosts its rail in. */
|
||||
export const PANEL_RAIL_HOST_CLASS = 'gev-panel-rail';
|
||||
|
||||
/** Glyph for a panel without a catalog entry, such as one a host adds. */
|
||||
export const PANEL_RAIL_FALLBACK_ICON = 'more_horiz';
|
||||
|
||||
/**
|
||||
* How each side panel appears on its rail. Labels are the panels' own titles.
|
||||
* `shownBy` names a class the panel needs to be shown at all (`f` toggles
|
||||
* Data Layers' `active`).
|
||||
*/
|
||||
export const PANEL_RAIL_ITEMS = Object.freeze({
|
||||
'data-panel': {
|
||||
icon: 'layers',
|
||||
label: msg('DATA LAYERS'),
|
||||
shownBy: 'active',
|
||||
},
|
||||
'scene-panel': { icon: 'movie', label: msg('AUTHORING') },
|
||||
'pp-toggles': { icon: 'tune', label: msg('DISPLAY') },
|
||||
'cctv-panel': { icon: 'videocam', label: msg('CCTV') },
|
||||
'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') },
|
||||
'recent-imagery-panel': {
|
||||
icon: 'satellite_alt',
|
||||
label: msg('RECENT IMAGERY'),
|
||||
},
|
||||
'street-level-panel': { icon: 'streetview', label: msg('STREET LEVEL') },
|
||||
'global-context-panel': { icon: 'radar', label: msg('CONTEXT') },
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether a mutation happened inside a panel rail, which no stack layout or
|
||||
* panel state depends on.
|
||||
* @param {MutationRecord} record Observed mutation.
|
||||
*/
|
||||
export function isPanelRailMutation(record) {
|
||||
const target =
|
||||
record.target.nodeType === 1 ? record.target : record.target.parentElement;
|
||||
return Boolean(target?.closest?.(`.${PANEL_RAIL_HOST_CLASS}`));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { reactive } from 'vue';
|
||||
|
||||
/**
|
||||
* @typedef {object} RailPanel
|
||||
* @property {string} id Panel element id.
|
||||
* @property {string} title The panel's translated title.
|
||||
* @property {boolean} open Whether the panel is expanded.
|
||||
* @property {boolean} available Whether the panel is offered (not `hidden`).
|
||||
*/
|
||||
|
||||
const samePanels = (a, b) =>
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(panel, index) =>
|
||||
panel.id === b[index].id &&
|
||||
panel.title === b[index].title &&
|
||||
panel.open === b[index].open &&
|
||||
panel.available === b[index].available,
|
||||
);
|
||||
|
||||
/**
|
||||
* The desktop side panels as the rails present them. Panel disclosure stays
|
||||
* with PanelChrome; this store publishes each side's panels in order.
|
||||
*/
|
||||
export const usePanelStore = defineStore('gev-panels', () => {
|
||||
/** @type {{left: RailPanel[], right: RailPanel[]}} */
|
||||
const sides = reactive({ left: [], right: [] });
|
||||
|
||||
/**
|
||||
* Replace one side's panels, keeping the array when nothing changed.
|
||||
* @param {'left'|'right'} side Sidebar.
|
||||
* @param {RailPanel[]} panels Panels in display order.
|
||||
*/
|
||||
function setSide(side, panels) {
|
||||
if (!samePanels(sides[side], panels)) sides[side] = panels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'left'|'right'} side Sidebar.
|
||||
* @returns {string|null} The open panel's id.
|
||||
*/
|
||||
function openPanelId(side) {
|
||||
return (
|
||||
sides[side].find((panel) => panel.open && panel.available)?.id ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {string} id Panel id. */
|
||||
function isOpen(id) {
|
||||
return [...sides.left, ...sides.right].some(
|
||||
(panel) => panel.id === id && panel.open,
|
||||
);
|
||||
}
|
||||
|
||||
return { sides, setSide, openPanelId, isOpen };
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { usePanelStore } from './panels.js';
|
||||
|
||||
test('the panel store reports each side’s open panel and keeps unchanged sides', () => {
|
||||
const store = usePanelStore(createPinia());
|
||||
const left = [
|
||||
{ id: 'data-panel', open: false, available: true },
|
||||
{ id: 'scene-panel', open: true, available: true },
|
||||
];
|
||||
store.setSide('left', left);
|
||||
store.setSide('right', [
|
||||
{ id: 'weather-panel', open: true, available: false },
|
||||
]);
|
||||
assert.equal(store.openPanelId('left'), 'scene-panel');
|
||||
// An open panel that is not offered is not the side's open panel.
|
||||
assert.equal(store.openPanelId('right'), null);
|
||||
assert.equal(store.isOpen('scene-panel'), true);
|
||||
assert.equal(store.isOpen('data-panel'), false);
|
||||
const published = store.sides.left;
|
||||
store.setSide(
|
||||
'left',
|
||||
left.map((panel) => ({ ...panel })),
|
||||
);
|
||||
assert.equal(store.sides.left, published);
|
||||
});
|
||||
@@ -263,6 +263,33 @@
|
||||
#left-panel-stack.layout-exclusive > [data-panel-id].collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Panel rails (src/ui/panelNavigation.js). Each rail lives inside its stack,
|
||||
so it shows and hides with the stack, and floats in the screen-edge gutter. */
|
||||
.gev-panel-rail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.gev-panel-rail--left {
|
||||
right: calc(100% + 6px);
|
||||
}
|
||||
.gev-panel-rail--right {
|
||||
left: calc(100% + 6px);
|
||||
}
|
||||
body.mobile-shell #left-panel-stack > .gev-panel-rail,
|
||||
body.mobile-shell #right-context-rail > .gev-panel-rail,
|
||||
body.cockpit-mode .gev-panel-rail {
|
||||
display: none;
|
||||
}
|
||||
/* Clean view and recording hide the panels one by one, not their stacks. */
|
||||
body.ui-clean-view .gev-panel-rail,
|
||||
body.recording-mode .gev-panel-rail {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
#right-context-rail.layout-focus > [data-panel-id].collapsed {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
/**
|
||||
* Create the application's Vue runtime: one Pinia instance shared by every
|
||||
* view the shell mounts, and the mounts themselves for teardown.
|
||||
* @returns {{pinia: import('pinia').Pinia, mount: Function, destroy: Function}}
|
||||
*/
|
||||
export function createUiRuntime() {
|
||||
const pinia = createPinia();
|
||||
const apps = new Set();
|
||||
return {
|
||||
pinia,
|
||||
/**
|
||||
* Mount a root component into a host element.
|
||||
* @param {object} component Root component.
|
||||
* @param {Element} host Element the view renders into.
|
||||
* @param {object} [props] Root props.
|
||||
* @returns {Function} Unmount this view.
|
||||
*/
|
||||
mount(component, host, props = {}) {
|
||||
const app = createApp(component, props);
|
||||
app.use(pinia);
|
||||
app.mount(host);
|
||||
apps.add(app);
|
||||
return () => {
|
||||
if (!apps.delete(app)) return;
|
||||
app.unmount();
|
||||
};
|
||||
},
|
||||
destroy() {
|
||||
for (const app of apps) app.unmount();
|
||||
apps.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user