Merge main into the Utilities panel, rebuilt on the Vue panel rail

main moved the panels to Vue views and a one-open-panel rail, and removed
the two templates this branch edited. This merge carries the branch onto
that architecture; it adds no behaviour of its own beyond the port.

- The Utilities panel is a RailPanel in LayerPanelsView with its own rail
  item (handyman). LayerPanel renders the Directions rows into its list with
  LayerRows, and falls back to Data Layers without it.
- Draw leaves DisplayPanel for DrawControls, DrawMarkList and DrawSwatches,
  which render what the draw tool publishes to the display store and call
  its actions, as GeoImport does. drawMarks.js words the rows; the DOM-built
  drawMarkList.js is gone. A row's fly-to is an immediate navigation like
  Import's ('drawing' gets its Cockpit refusal), the name field keeps a
  draft through v-field-value, and the colours are one radio group with a
  single tab stop.
- UiSegmented draws an option's icon, named by its label, for the shape
  buttons.
- The catalogs take main's languages with this branch's strings, Arabic
  included, and the tests follow the views to their new homes.
This commit is contained in:
Said Turksever committed 2026-11-02 16:43:29 +00:00
commit 4ebb287d36
699 files changed
+46673 -20476

No files matched your search

+127 -156
View File
@@ -1,5 +1,7 @@
import { UiLifetime } from './uiLifetime.js';
import { t, msg } from '../i18n/index.js';
import { prefersCollapsed, readPanelState } from './rail/panelState.js';
import { usePanelChromeStore } from './stores/panelChrome.js';
import { applicationPinia } from './vueRuntime.js';
import { displayPanelScroller } from './displayPanelScroll.js';
import { PanelPositionControls } from './panelPositionControls.js';
import { PanelLayoutController } from './panelLayoutController.js';
@@ -8,6 +10,7 @@ import {
collapsePanelOnEscape,
createHoverDisclosure,
} from './panelDisclosure.js';
const SHARE_PANEL_STATE_SPECS = Object.freeze([
{ id: 'control-panel', pinnable: true },
{ id: 'location-bar', pinnable: true },
@@ -22,6 +25,12 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
{ id: 'pp-toggles' },
{ id: 'param-slider-panel' },
]);
/** The command dock trays with a pin button. */
const PINNABLE_PANEL_IDS = new Set(
SHARE_PANEL_STATE_SPECS.filter((spec) => spec.pinnable).map(
(spec) => spec.id,
),
);
/** Standard map-view panels cleared out of the way on a fresh Cockpit entry. */
const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
'data-panel',
@@ -48,6 +57,7 @@ export class PanelChrome {
readScrollRestoreOwner,
readDisplayScrollTop,
readSharedCollapsed = () => undefined,
store = usePanelChromeStore(applicationPinia()),
}) {
Object.assign(this, elements, operations, {
readHud,
@@ -60,10 +70,21 @@ export class PanelChrome {
});
this._disposed = false;
this._lifetime = new UiLifetime();
// The collapse, dock and pin buttons render each panel's state from the
// store; their clicks take effect only once the chrome is initialized.
this._chromeStore = store;
this._chromeToken = store.connect({
toggle: (panelId) => this._togglePanel(panelId),
pin: (panelId) => {
if (this._disclosurePanelIds)
this._setCommandDockPanelPinState(panelId);
},
});
this._cockpitPanelRestore = null;
this._cockpitContextCollapsedForDataPanel = false;
this._panelPosition = new PanelPositionControls({
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
presentPanel: (panel, patch) => this._presentPanel(panel, patch),
shownPanel: (panel) => this._shownPanel(panel),
showToast: (message) => this._showToast(message),
});
this._panelLayout = new PanelLayoutController({
@@ -72,7 +93,7 @@ export class PanelChrome {
variant: this.hud.getVariant(),
}),
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
presentPanel: (panel, patch) => this._presentPanel(panel, patch),
readDisplayScrollTop: () =>
this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop
@@ -102,24 +123,23 @@ export class PanelChrome {
for (const control of this._panelDisclosureControls || [])
control.destroy();
this._panelDisclosureControls = [];
const targets = new Map();
document
.querySelectorAll('.panel-collapse-btn[data-collapse-target]')
.forEach((button) => {
const targetId = button.dataset.collapseTarget;
if (!targetId) return;
if (!targets.has(targetId)) targets.set(targetId, []);
targets.get(targetId).push(button);
});
for (const [targetId, buttons] of targets) {
this._disclosurePanelIds = new Set();
const targets = new Set(
[
...document.querySelectorAll(
'.panel-collapse-btn[data-collapse-target]',
),
]
.map((button) => button.dataset.collapseTarget)
.filter(Boolean),
);
for (const targetId of targets) {
const panel = document.getElementById(targetId);
if (!panel) continue;
this._disclosurePanelIds.add(targetId);
this._panelDisclosureControls.push(
bindPanelDisclosure({
panel,
buttons,
onChange: (collapsed, options) =>
this.setPanelCollapsed(targetId, collapsed, options),
onEscape: (event) => this._collapsePanelOnEscape(event, targetId),
}),
);
@@ -145,7 +165,6 @@ export class PanelChrome {
openDelayMs: 140,
closeDelayMs: 420,
});
this._initCommandDockPins();
this._initCommandDockTrayMetrics();
this._maybeNotifyLayoutReset();
}
@@ -159,24 +178,35 @@ export class PanelChrome {
onChange: (collapsed, options) =>
this.setPanelCollapsed(panelId, collapsed, options),
beforeCollapse: () => {
if (panelId !== 'location-bar' || !this._locationSearch) return;
this._locationSearch.classList.remove('expanded');
this._locationSearch.value = '';
this._locationSearch.blur();
if (panelId === 'location-bar') this._closeLocationSearch?.();
},
// A desktop side panel hides entirely once collapsed; its rail item
// takes the focus its own disclosure can no longer hold.
returnFocusTo: () => {
const panel = document.getElementById(panelId);
if (!panel?.getClientRects || panel.getClientRects().length > 0)
return null;
const item = document.querySelector?.(
`.ui-rail [data-rail-item="${panelId}"]`,
);
return item &&
!item.disabled &&
item.getClientRects().length > 0 &&
getComputedStyle(item).visibility === 'visible'
? item
: null;
},
});
}
_initCommandDockPins() {
document
.querySelectorAll('.dock-pin-btn[data-pin-target]')
.forEach((button) => {
this._lifetime.listen(button, 'click', (event) => {
event.stopPropagation();
const panelId = button.dataset.pinTarget;
this._setCommandDockPanelPinState(panelId);
});
});
/** A collapse button's click: flip its panel. */
_togglePanel(panelId) {
if (this._disposed || !this._disclosurePanelIds?.has(panelId)) return;
const panel = document.getElementById(panelId);
if (!panel) return;
this.setPanelCollapsed(panelId, !this._shownPanel(panel).collapsed, {
explicit: true,
});
}
_setCommandDockPanelPinState(
@@ -184,24 +214,23 @@ export class PanelChrome {
pin,
{ restore = false, persist = true, syncShare = true } = {},
) {
if (this._disposed || !PINNABLE_PANEL_IDS.has(panelId)) return undefined;
const panelEl = document.getElementById(panelId);
const button = document.querySelector(
`.dock-pin-btn[data-pin-target="${panelId}"]`,
);
if (!panelEl || !button) return undefined;
if (!panelEl) return undefined;
const shouldPin =
typeof pin === 'boolean'
? pin
: !panelEl.classList.contains('dock-pinned');
panelEl.classList.toggle('dock-pinned', shouldPin);
button.setAttribute('aria-pressed', String(shouldPin));
document
.querySelectorAll('#command-dock .dock-pinned-top')
.forEach((pinnedPanel) => {
pinnedPanel.classList.remove('dock-pinned-top');
typeof pin === 'boolean' ? pin : !this._shownPanel(panelEl).pinned;
// The tray pinned last draws on top; unpinning hands the top to the
// other tray if it is pinned.
this._presentPanel(panelEl, { pinned: shouldPin, pinnedTop: shouldPin });
for (const otherId of PINNABLE_PANEL_IDS) {
if (otherId === panelId) continue;
const other = document.getElementById(otherId);
if (!other) continue;
this._presentPanel(other, {
pinnedTop: !shouldPin && this._shownPanel(other).pinned,
});
}
if (shouldPin) {
panelEl.classList.add('dock-pinned-top');
this.setPanelCollapsed(panelId, false, {
explicit: !restore,
restore,
@@ -209,10 +238,6 @@ export class PanelChrome {
syncShare: false,
});
} else {
const remainingPinnedPanel = document.querySelector(
'#command-dock .dock-pinned',
);
remainingPinnedPanel?.classList.add('dock-pinned-top');
if (!restore && !panelEl.matches(':hover')) {
this.setPanelCollapsed(panelId, true, {
explicit: true,
@@ -292,66 +317,32 @@ export class PanelChrome {
return this._panelLayout._scheduleLeftPanelLayout(options0);
}
_syncPanelCollapseButton(panelEl) {
const isRightRail = [
'pp-toggles',
'cctv-panel',
'weather-panel',
'recent-imagery-panel',
'street-level-panel',
'global-context-panel',
].includes(panelEl?.id);
const collapsed = panelEl.classList.contains('collapsed');
panelEl
.querySelectorAll('.panel-collapse-btn[data-collapse-target]')
.forEach((btn) => {
const owner = btn.closest('[data-panel-id], #param-slider-panel');
if (owner !== panelEl) return;
if (isRightRail) {
btn.textContent = collapsed ? '◀' : '▶';
} else {
btn.textContent = collapsed ? '+' : '−';
}
btn.setAttribute('aria-expanded', String(!collapsed));
const panelName =
panelEl
.querySelector('.panel-title, .pp-header-label')
?.textContent?.trim() || t('panel');
const titleKey = collapsed
? msg('Expand {name}')
: msg('Collapse {name}');
btn.title = t(titleKey, { name: panelName });
btn.setAttribute('aria-label', t(titleKey, { name: panelName }));
if (panelEl.id === 'radio-panel') {
// Radio keeps the wording of its static button (context.html).
btn.title = t(
collapsed ? msg('Expand Radio') : msg('Collapse Radio'),
);
btn.setAttribute(
'aria-label',
t(
collapsed
? msg('Expand Radio section')
: msg('Collapse Radio section'),
),
);
}
});
const dockToggle = panelEl.querySelector(
`[data-dock-toggle-target="${panelEl.id}"]`,
);
if (dockToggle) {
const panelName =
panelEl
.querySelector('.panel-title, .location-toolbar-label')
?.textContent?.trim() || t('panel');
const titleKey = collapsed
? msg('Expand {name}')
: msg('Collapse {name}');
dockToggle.setAttribute('aria-expanded', String(!collapsed));
dockToggle.setAttribute('aria-label', t(titleKey, { name: panelName }));
dockToggle.title = t(titleKey, { name: panelName });
}
/**
* Present a panel's state, merged over what it shows now, to the panel
* chrome store; its root and buttons draw it from there.
* @param {Element|string} panelOrId
* @param {Partial<import('./stores/panelChrome.js').PanelState>} [patch]
*/
_presentPanel(panelOrId, patch = {}) {
const panelEl =
typeof panelOrId === 'string'
? document.getElementById(panelOrId)
: panelOrId;
if (!panelEl?.id) return;
this._chromeStore.publish(this._chromeToken, panelEl.id, {
...this._shownPanel(panelEl),
...patch,
});
}
/**
* A panel's state as PanelChrome presented it, or its markup's before the
* first presentation.
* @param {Element} panelEl
* @returns {import('./stores/panelChrome.js').PanelState}
*/
_shownPanel(panelEl) {
return this._chromeStore.panels[panelEl.id] ?? readPanelState(panelEl);
}
_buildSharePanelState() {
@@ -361,18 +352,14 @@ export class PanelChrome {
if (!panelEl) continue;
// Responsive auto-collapse is presentation only; the recipient should
// restore the user's explicit expanded preference at its own viewport.
const collapsed =
panelEl.classList.contains('layout-auto-collapsed') ||
panelEl.classList.contains('cyber-accordion-collapsed')
? false
: panelEl.classList.contains('collapsed');
const shown = this._shownPanel(panelEl);
const collapsed = prefersCollapsed(shown);
// A panel the phone shell only borrowed for a sheet shares its own state.
const entry = {
id: spec.id,
collapsed: this.readSharedCollapsed(spec.id) ?? collapsed,
};
if (spec.pinnable)
entry.pinned = panelEl.classList.contains('dock-pinned');
if (spec.pinnable) entry.pinned = shown.pinned;
specs.push(entry);
}
return specs.length ? { specs } : null;
@@ -419,21 +406,22 @@ export class PanelChrome {
if (explicit && !restore)
this.shareLinkManager?.claimRestoreLane?.('panel', panelId);
const nextCollapsed = Boolean(collapsed);
const wasAutoCollapsed =
panelEl.classList.contains('layout-auto-collapsed') ||
panelEl.classList.contains('cyber-accordion-collapsed');
// A user opening a Cyber rail panel owns the whole accordion, including
// peers that were only presentation-collapsed during a saved-state restore.
const wasAutoCollapsed = Boolean(this._shownPanel(panelEl).auto);
// One panel is open per desktop side: opening a side panel, whether the
// user asked or a feature revealed it (Street Level's viewer, a selected
// radio station), owns the whole accordion, including peers that were
// only presentation-collapsed during a saved-state restore.
const sideStack = [this._leftPanelStack, this._rightPanelStack].find(
(stack) => stack && panelEl.parentElement === stack,
);
if (
explicit &&
!restore &&
!nextCollapsed &&
document.documentElement?.dataset.uiTheme === 'cyber' &&
panelEl.parentElement === this._rightPanelStack &&
sideStack &&
// Phone sheets show several rail panels at once.
!document.body?.classList?.contains('mobile-shell')
) {
for (const peer of this._rightPanelStack.children) {
for (const peer of sideStack.children) {
if (
peer !== panelEl &&
peer.matches('[data-panel-id]') &&
@@ -458,7 +446,7 @@ export class PanelChrome {
: null;
const priorLeftOwner = this._panelLayout._leftStackPreferredPanelId;
const priorRightOwner = this._panelLayout._rightStackPreferredPanelId;
if (explicit && !restore && !nextCollapsed && leftOwnerPanel) {
if (!restore && !nextCollapsed && leftOwnerPanel) {
this._panelLayout._leftStackPreferredPanelId = leftOwnerPanel.id;
} else if (
explicit &&
@@ -468,7 +456,7 @@ export class PanelChrome {
) {
this._panelLayout._leftStackPreferredPanelId = null;
}
if (explicit && !restore && !nextCollapsed && rightOwnerPanel) {
if (!restore && !nextCollapsed && rightOwnerPanel) {
this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id;
} else if (
explicit &&
@@ -480,13 +468,12 @@ export class PanelChrome {
}
// Reveal Radio's owner before the no-op check: the nested section may
// already be expanded while its Context parent is closed.
const contextPanel = document.getElementById('global-context-panel');
if (
document.documentElement?.dataset.uiTheme === 'cyber' &&
!nextCollapsed &&
panelId === 'radio-panel' &&
document
.getElementById('global-context-panel')
?.classList.contains('collapsed')
contextPanel &&
this._shownPanel(contextPanel).collapsed
) {
this.setPanelCollapsed('global-context-panel', false, {
explicit,
@@ -496,10 +483,9 @@ export class PanelChrome {
});
}
if (
panelEl.classList.contains('collapsed') === nextCollapsed &&
this._shownPanel(panelEl).collapsed === nextCollapsed &&
!wasAutoCollapsed
) {
this._syncPanelCollapseButton(panelEl);
if (priorLeftOwner !== this._panelLayout._leftStackPreferredPanelId) {
this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
}
@@ -508,10 +494,6 @@ export class PanelChrome {
}
return;
}
panelEl.classList.remove(
'layout-auto-collapsed',
'cyber-accordion-collapsed',
);
if (
!nextCollapsed &&
this.cockpitView?.active &&
@@ -523,23 +505,9 @@ export class PanelChrome {
this.cockpitView.setContextCollapsed(true);
}
}
if (
!nextCollapsed &&
panelId === 'radio-panel' &&
document.documentElement?.dataset.uiTheme !== 'cyber' &&
document
.getElementById('global-context-panel')
?.classList.contains('collapsed')
) {
this.setPanelCollapsed('global-context-panel', false, {
restore,
persist,
syncShare,
});
}
if (!nextCollapsed && !restore && panelId === 'location-bar') {
const otherPanel = document.getElementById('control-panel');
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
if (otherPanel && !this._shownPanel(otherPanel).pinned) {
this.setPanelCollapsed('control-panel', true, {
restore,
persist,
@@ -548,7 +516,7 @@ export class PanelChrome {
}
} else if (!nextCollapsed && !restore && panelId === 'control-panel') {
const otherPanel = document.getElementById('location-bar');
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
if (otherPanel && !this._shownPanel(otherPanel).pinned) {
this.setPanelCollapsed('location-bar', true, {
restore,
persist,
@@ -556,7 +524,7 @@ export class PanelChrome {
});
}
}
panelEl.classList.toggle('collapsed', nextCollapsed);
this._presentPanel(panelEl, { collapsed: nextCollapsed, auto: null });
if (
nextCollapsed &&
this.cockpitView?.active &&
@@ -566,7 +534,6 @@ export class PanelChrome {
this._cockpitContextCollapsedForDataPanel = false;
this.cockpitView.setContextCollapsed(false);
}
this._syncPanelCollapseButton(panelEl);
if (persist !== false)
this._savePanelCollapsedState(panelId, nextCollapsed);
if (panelId === 'pp-toggles') {
@@ -599,7 +566,7 @@ export class PanelChrome {
if (panel) {
this._cockpitPanelRestore.set(
panelId,
panel.classList.contains('collapsed'),
this._shownPanel(panel).collapsed,
);
}
this.setPanelCollapsed(panelId, true, {
@@ -624,8 +591,12 @@ export class PanelChrome {
}
destroy() {
if (this._disposed) return;
// Cockpit's own teardown follows the chrome's: put back the panels its
// entry closed while this owner can still present them.
this.exitCockpit();
this._disposed = true;
this._lifetime.destroy();
this._chromeStore.detach(this._chromeToken);
this._panelLayout.destroy();
for (const control of this._panelDisclosureControls || [])
control.destroy();