mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(ui): portable, resizable rail panels for CCTV
A drag spec can now be `portable`: dragging the panel by its header lifts
it out of the right rail into a fixed window, resizable from all four
edges and corners, that remembers its place and size under the existing
godsEyeView.v8.panelPos.<id> key ({ left, top, width?, height?, floating })
and snaps back on a header double-click. Only CCTV opts in here; the rail
skips .panel-floating panels when measuring and allocating, and its
placement rules in layers.css are guarded so no !important is needed.
PanelPositionControls gains an optional onPanelResized hook, threaded
through PanelChrome to a no-op on the application shell.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
317c0a7e0c
commit
96a1e710ea
9 files changed
+525
-26
No files matched your search
@@ -45,7 +45,11 @@ test('desktop Display participates in Tactical exclusivity without changing mobi
|
|||||||
/panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/,
|
/panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/,
|
||||||
);
|
);
|
||||||
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
|
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
|
||||||
assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
|
// A panel floating out of the rail (portable mode) is exempt from exclusivity.
|
||||||
|
assert.match(
|
||||||
|
css,
|
||||||
|
/#right-context-rail\.layout-exclusive\s*> \[data-panel-id\]\.collapsed:not\(\.panel-floating\) \{/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => {
|
test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export class StyleManager extends ShellFacade {
|
|||||||
_setRadioDisclosure: (...args) => this._setRadioDisclosure(...args),
|
_setRadioDisclosure: (...args) => this._setRadioDisclosure(...args),
|
||||||
_syncCctvPanelViewport: (...args) =>
|
_syncCctvPanelViewport: (...args) =>
|
||||||
this._syncCctvPanelViewport(...args),
|
this._syncCctvPanelViewport(...args),
|
||||||
|
_onPanelResized: (...args) => this._onPanelResized(...args),
|
||||||
_syncContextRadioLauncherState: (...args) =>
|
_syncContextRadioLauncherState: (...args) =>
|
||||||
this._syncContextRadioLauncherState(...args),
|
this._syncContextRadioLauncherState(...args),
|
||||||
_showToast: (...args) => this._showToast(...args),
|
_showToast: (...args) => this._showToast(...args),
|
||||||
@@ -1409,6 +1410,15 @@ export class StyleManager extends ShellFacade {
|
|||||||
this._initCockpitDisplayPortal();
|
this._initCockpitDisplayPortal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A portable panel finished a resize or snapped back to its rail. Nothing
|
||||||
|
* in the shell depends on it yet; a panel with a viewport of its own can
|
||||||
|
* refit here.
|
||||||
|
* @param {string} _panelId
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
_onPanelResized(_panelId) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recalculates the CCTV panel max-height based on its current top position
|
* Recalculates the CCTV panel max-height based on its current top position
|
||||||
* and the window height, enabling internal scroll without viewport overflow.
|
* and the window height, enabling internal scroll without viewport overflow.
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export class PanelChrome {
|
|||||||
layoutRightPanels: () => this._layoutRightPanels(),
|
layoutRightPanels: () => this._layoutRightPanels(),
|
||||||
syncCctvPanelViewport: () => this._syncCctvPanelViewport(),
|
syncCctvPanelViewport: () => this._syncCctvPanelViewport(),
|
||||||
showToast: (message) => this._showToast(message),
|
showToast: (message) => this._showToast(message),
|
||||||
|
onPanelResized: (panelId) => this._onPanelResized(panelId),
|
||||||
});
|
});
|
||||||
this._panelLayout = new PanelLayoutController({
|
this._panelLayout = new PanelLayoutController({
|
||||||
readHud: () => ({
|
readHud: () => ({
|
||||||
@@ -139,6 +140,7 @@ export class PanelChrome {
|
|||||||
this._initCommandDockPins();
|
this._initCommandDockPins();
|
||||||
this._initCommandDockTrayMetrics();
|
this._initCommandDockTrayMetrics();
|
||||||
this._maybeNotifyLayoutReset();
|
this._maybeNotifyLayoutReset();
|
||||||
|
this._panelPosition._initPanelDrag();
|
||||||
}
|
}
|
||||||
|
|
||||||
_collapsePanelOnEscape(event, panelId) {
|
_collapsePanelOnEscape(event, panelId) {
|
||||||
|
|||||||
+377
-13
@@ -1,4 +1,6 @@
|
|||||||
/** Own panel position preferences, viewport clamping and drag listeners. */
|
/** Own panel position preferences, viewport clamping and drag listeners. */
|
||||||
|
import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js';
|
||||||
|
|
||||||
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
|
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
|
||||||
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
|
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
|
||||||
/**
|
/**
|
||||||
@@ -10,40 +12,93 @@ const PANEL_POSITION_STORAGE_VERSION = 'v8';
|
|||||||
/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */
|
/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */
|
||||||
const PANEL_Z_BASE = 100;
|
const PANEL_Z_BASE = 100;
|
||||||
const PANEL_Z_MAX = 139;
|
const PANEL_Z_MAX = 139;
|
||||||
|
/** Pointer travel before a header press becomes a drag that lifts a portable panel out. */
|
||||||
|
const DRAG_THRESHOLD_PX = 4;
|
||||||
|
/** Viewport margin every positioned panel keeps clear. */
|
||||||
|
const VIEWPORT_MARGIN_PX = 6;
|
||||||
|
/** One-time hint shown the first time a portable panel leaves its rail. */
|
||||||
|
const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`;
|
||||||
|
const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back';
|
||||||
|
/** Header children whose own interaction wins over a drag or a snap-back. */
|
||||||
|
const INTERACTIVE_SELECTOR =
|
||||||
|
'input, select, option, textarea, button, a, [role="button"]';
|
||||||
|
/** Inline geometry a snap-back clears so the rail owns placement again. */
|
||||||
|
const FLOATING_STYLE_PROPERTIES = [
|
||||||
|
'left',
|
||||||
|
'top',
|
||||||
|
'right',
|
||||||
|
'bottom',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
];
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
export class PanelPositionControls {
|
export class PanelPositionControls {
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {Function} options.syncPanelCollapseButton
|
||||||
|
* @param {Function} options.layoutRightPanels
|
||||||
|
* @param {Function} options.syncCctvPanelViewport
|
||||||
|
* @param {Function} options.showToast
|
||||||
|
* @param {(panelId: string) => void} [options.onPanelResized] Fires after a
|
||||||
|
* portable panel's resize gesture ends or the panel snaps back to its rail.
|
||||||
|
*/
|
||||||
constructor({
|
constructor({
|
||||||
syncPanelCollapseButton,
|
syncPanelCollapseButton,
|
||||||
layoutRightPanels,
|
layoutRightPanels,
|
||||||
syncCctvPanelViewport,
|
syncCctvPanelViewport,
|
||||||
showToast,
|
showToast,
|
||||||
|
onPanelResized = null,
|
||||||
}) {
|
}) {
|
||||||
this._syncPanelCollapseButton = syncPanelCollapseButton;
|
this._syncPanelCollapseButton = syncPanelCollapseButton;
|
||||||
this._layoutRightPanels = layoutRightPanels;
|
this._layoutRightPanels = layoutRightPanels;
|
||||||
this._syncCctvPanelViewport = syncCctvPanelViewport;
|
this._syncCctvPanelViewport = syncCctvPanelViewport;
|
||||||
this._showToast = showToast;
|
this._showToast = showToast;
|
||||||
|
this._onPanelResized = onPanelResized;
|
||||||
this._ppToggles = document.getElementById('pp-toggles');
|
this._ppToggles = document.getElementById('pp-toggles');
|
||||||
this._panelZCounter = PANEL_Z_BASE + 10;
|
this._panelZCounter = PANEL_Z_BASE + 10;
|
||||||
this._draggableResizeObserver = null;
|
this._draggableResizeObserver = null;
|
||||||
this._cancelDrag = null;
|
this._cancelDrag = null;
|
||||||
|
/** Portable panels by id: `{ panel, min: { width, height } }`. */
|
||||||
|
this._portablePanels = new Map();
|
||||||
|
this._resizeHandles = [];
|
||||||
|
this._dragInitialized = false;
|
||||||
this.removers = [];
|
this.removers = [];
|
||||||
this.destroyed = false;
|
this.destroyed = false;
|
||||||
}
|
}
|
||||||
listen(target, type, callback) {
|
listen(target, type, callback, options) {
|
||||||
if (this.destroyed || !target) return;
|
if (this.destroyed || !target) return;
|
||||||
const listener = (event) => {
|
const listener = (event) => {
|
||||||
if (!this.destroyed) callback(event);
|
if (!this.destroyed) callback(event);
|
||||||
};
|
};
|
||||||
target.addEventListener(type, listener);
|
target.addEventListener(type, listener, options);
|
||||||
this.removers.push(() => target.removeEventListener(type, listener));
|
this.removers.push(() =>
|
||||||
|
target.removeEventListener(type, listener, options),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_reclampDraggablePanels() {
|
_reclampDraggablePanels() {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
const el = this._ppToggles;
|
const el = this._ppToggles;
|
||||||
if (!el || !el.style.top || el.style.top === 'auto') return;
|
if (el && el.style.top && el.style.top !== 'auto') {
|
||||||
const top = parseInt(el.style.top, 10);
|
const top = parseInt(el.style.top, 10);
|
||||||
if (!Number.isFinite(top)) return;
|
if (Number.isFinite(top)) {
|
||||||
el.style.top = `${this._clampToViewport(0, top, el).top}px`;
|
el.style.top = `${this._clampToViewport(0, top, el).top}px`;
|
||||||
this._pinPanelToRight(el);
|
this._pinPanelToRight(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Floating panels only move back on-screen; their size is the user's.
|
||||||
|
for (const { panel } of this._portablePanels.values()) {
|
||||||
|
if (!panel.classList.contains('panel-floating')) continue;
|
||||||
|
const left = parseInt(panel.style.left, 10);
|
||||||
|
const top = parseInt(panel.style.top, 10);
|
||||||
|
if (!Number.isFinite(left) || !Number.isFinite(top)) continue;
|
||||||
|
const next = this._clampToViewport(left, top, panel);
|
||||||
|
panel.style.left = `${next.left}px`;
|
||||||
|
panel.style.top = `${next.top}px`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_maybeNotifyLayoutReset() {
|
_maybeNotifyLayoutReset() {
|
||||||
@@ -65,19 +120,41 @@ export class PanelPositionControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_initPanelDrag() {
|
_initPanelDrag() {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed || this._dragInitialized) return;
|
||||||
|
this._dragInitialized = true;
|
||||||
|
const cctvPanel = document.getElementById('cctv-panel');
|
||||||
|
// A `portable` spec lifts out of its rail on a header drag, resizes from
|
||||||
|
// every edge and remembers its window; the others only reposition in
|
||||||
|
// place. Another panel opts in by adding a spec here.
|
||||||
const dragSpecs = [
|
const dragSpecs = [
|
||||||
{
|
{
|
||||||
id: 'pp-toggles',
|
id: 'pp-toggles',
|
||||||
panel: this._ppToggles,
|
panel: this._ppToggles,
|
||||||
handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'),
|
handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'cctv-panel',
|
||||||
|
panel: cctvPanel,
|
||||||
|
handle: cctvPanel?.querySelector('.panel-header'),
|
||||||
|
portable: true,
|
||||||
|
min: { width: 300, height: 160 },
|
||||||
|
},
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
for (const spec of dragSpecs) {
|
for (const spec of dragSpecs) {
|
||||||
if (!spec.panel || !spec.handle) continue;
|
if (!spec.panel || !spec.handle) continue;
|
||||||
|
if (spec.portable) {
|
||||||
|
this._portablePanels.set(spec.id, {
|
||||||
|
panel: spec.panel,
|
||||||
|
min: { width: 300, height: 160, ...spec.min },
|
||||||
|
});
|
||||||
|
spec.panel.classList.add('panel-portable');
|
||||||
|
}
|
||||||
this._restorePanelPosition(spec.id, spec.panel);
|
this._restorePanelPosition(spec.id, spec.panel);
|
||||||
this._makePanelDraggable(spec.id, spec.panel, spec.handle);
|
this._makePanelDraggable(spec.id, spec.panel, spec.handle);
|
||||||
|
if (spec.portable) {
|
||||||
|
this._makePanelResizable(spec.id, spec.panel, spec.min);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its
|
// Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its
|
||||||
// full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and
|
// full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and
|
||||||
@@ -88,6 +165,9 @@ export class PanelPositionControls {
|
|||||||
);
|
);
|
||||||
this._draggableResizeObserver.observe(this._ppToggles);
|
this._draggableResizeObserver.observe(this._ppToggles);
|
||||||
}
|
}
|
||||||
|
if (this._portablePanels.size) {
|
||||||
|
this.listen(window, 'resize', () => this._reclampDraggablePanels());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_panelStorageKey(panelId) {
|
_panelStorageKey(panelId) {
|
||||||
@@ -151,6 +231,27 @@ export class PanelPositionControls {
|
|||||||
const pos = JSON.parse(raw);
|
const pos = JSON.parse(raw);
|
||||||
if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number')
|
if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number')
|
||||||
return;
|
return;
|
||||||
|
// A floating record only means something to a panel that can float; a
|
||||||
|
// portable panel in turn only ever stores floating records.
|
||||||
|
const portable = this._portablePanels.get(panelId);
|
||||||
|
if (Boolean(pos.floating) !== Boolean(portable)) return;
|
||||||
|
if (portable) {
|
||||||
|
panelEl.classList.add('panel-floating', 'panel-draggable');
|
||||||
|
if (Number.isFinite(pos.width)) {
|
||||||
|
panelEl.style.width = `${clamp(
|
||||||
|
Math.round(pos.width),
|
||||||
|
portable.min.width,
|
||||||
|
Math.max(portable.min.width, window.innerWidth - 12),
|
||||||
|
)}px`;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(pos.height)) {
|
||||||
|
panelEl.style.height = `${clamp(
|
||||||
|
Math.round(pos.height),
|
||||||
|
portable.min.height,
|
||||||
|
Math.max(portable.min.height, window.innerHeight - 12),
|
||||||
|
)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Clamp to the viewport: a position saved at one window size would otherwise land off-screen at
|
// Clamp to the viewport: a position saved at one window size would otherwise land off-screen at
|
||||||
// another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too.
|
// another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too.
|
||||||
const { left, top } = this._clampToViewport(
|
const { left, top } = this._clampToViewport(
|
||||||
@@ -182,13 +283,21 @@ export class PanelPositionControls {
|
|||||||
|
|
||||||
_savePanelPosition(panelId, panelEl) {
|
_savePanelPosition(panelId, panelEl) {
|
||||||
const rect = panelEl.getBoundingClientRect();
|
const rect = panelEl.getBoundingClientRect();
|
||||||
|
const record = {
|
||||||
|
left: Math.round(rect.left),
|
||||||
|
top: Math.round(rect.top),
|
||||||
|
};
|
||||||
|
if (this._portablePanels.has(panelId)) {
|
||||||
|
// Only an explicitly set size is remembered: freezing a collapsed
|
||||||
|
// panel's measured height would stop it from ever expanding again.
|
||||||
|
if (panelEl.style.width) record.width = Math.round(rect.width);
|
||||||
|
if (panelEl.style.height) record.height = Math.round(rect.height);
|
||||||
|
record.floating = true;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
this._panelStorageKey(panelId),
|
this._panelStorageKey(panelId),
|
||||||
JSON.stringify({
|
JSON.stringify(record),
|
||||||
left: Math.round(rect.left),
|
|
||||||
top: Math.round(rect.top),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// storage unavailable
|
// storage unavailable
|
||||||
@@ -211,11 +320,78 @@ export class PanelPositionControls {
|
|||||||
panelEl.style.zIndex = String(this._panelZCounter);
|
panelEl.style.zIndex = String(this._panelZCounter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_maybeShowFloatHint() {
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return;
|
||||||
|
localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
// storage unavailable: still worth saying once this session
|
||||||
|
if (this._floatHintShown) return;
|
||||||
|
this._floatHintShown = true;
|
||||||
|
}
|
||||||
|
this._showToast(PANEL_FLOAT_HINT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch a portable panel from rail flow to a fixed window at the place it
|
||||||
|
* currently occupies. Width is frozen so a `width: 100%` rail panel keeps
|
||||||
|
* its size once fixed-positioned; height stays natural until a resize.
|
||||||
|
*/
|
||||||
|
_liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) {
|
||||||
|
if (panelEl.classList.contains('panel-floating')) return false;
|
||||||
|
panelEl.style.left = `${Math.round(rect.left)}px`;
|
||||||
|
panelEl.style.top = `${Math.round(rect.top)}px`;
|
||||||
|
panelEl.style.right = 'auto';
|
||||||
|
panelEl.style.bottom = 'auto';
|
||||||
|
if (!panelEl.classList.contains('collapsed')) {
|
||||||
|
panelEl.style.width = `${Math.round(rect.width)}px`;
|
||||||
|
}
|
||||||
|
// Rail bookkeeping no longer applies to a window the rail does not allocate.
|
||||||
|
panelEl.style.removeProperty('--right-panel-allocated-height');
|
||||||
|
panelEl.removeAttribute('aria-hidden');
|
||||||
|
panelEl.classList.add('panel-floating', 'panel-draggable');
|
||||||
|
this._promotePanelZ(panelEl);
|
||||||
|
this._layoutRightPanels();
|
||||||
|
this._maybeShowFloatHint();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snap a floating panel back into its rail and forget its window. */
|
||||||
|
_resetPanelPosition(panelId) {
|
||||||
|
const portable = this._portablePanels.get(panelId);
|
||||||
|
if (!portable) return;
|
||||||
|
const panelEl = portable.panel;
|
||||||
|
this._cancelDrag?.();
|
||||||
|
panelEl.classList.remove(
|
||||||
|
'panel-floating',
|
||||||
|
'panel-draggable',
|
||||||
|
'panel-dragging',
|
||||||
|
'panel-resizing',
|
||||||
|
);
|
||||||
|
for (const property of FLOATING_STYLE_PROPERTIES) {
|
||||||
|
panelEl.style.removeProperty(property);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(this._panelStorageKey(panelId));
|
||||||
|
} catch {
|
||||||
|
// storage unavailable
|
||||||
|
}
|
||||||
|
this._layoutRightPanels();
|
||||||
|
if (panelId === 'cctv-panel') {
|
||||||
|
this._syncCctvPanelViewport();
|
||||||
|
}
|
||||||
|
this._onPanelResized?.(panelId);
|
||||||
|
}
|
||||||
|
|
||||||
_makePanelDraggable(panelId, panelEl, handleEl) {
|
_makePanelDraggable(panelId, panelEl, handleEl) {
|
||||||
// Z-order promotion: bring clicked panel to front of the stacking context
|
// Z-order promotion: bring clicked panel to front of the stacking context
|
||||||
this.listen(panelEl, 'pointerdown', () => {
|
this.listen(panelEl, 'pointerdown', () => {
|
||||||
this._promotePanelZ(panelEl);
|
this._promotePanelZ(panelEl);
|
||||||
});
|
});
|
||||||
|
if (this._portablePanels.has(panelId)) {
|
||||||
|
this._makePortablePanelDraggable(panelId, panelEl, handleEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.listen(handleEl, 'pointerdown', (event) => {
|
this.listen(handleEl, 'pointerdown', (event) => {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
@@ -283,11 +459,199 @@ export class PanelPositionControls {
|
|||||||
window.addEventListener('pointercancel', onUp);
|
window.addEventListener('pointercancel', onUp);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portable drag: a press on the header only becomes a drag after a few
|
||||||
|
* pixels of travel, at which point the panel lifts out of its rail. The
|
||||||
|
* collapse button still toggles on a plain click, and the click that ends
|
||||||
|
* a real drag is swallowed so it cannot toggle by accident. Double-clicking
|
||||||
|
* the header snaps the panel back.
|
||||||
|
*/
|
||||||
|
_makePortablePanelDraggable(panelId, panelEl, handleEl) {
|
||||||
|
let swallowNextClick = false;
|
||||||
|
this.listen(
|
||||||
|
handleEl,
|
||||||
|
'click',
|
||||||
|
(event) => {
|
||||||
|
if (!swallowNextClick) return;
|
||||||
|
swallowNextClick = false;
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
);
|
||||||
|
this.listen(handleEl, 'dblclick', (event) => {
|
||||||
|
if (event.target.closest?.(INTERACTIVE_SELECTOR)) return;
|
||||||
|
if (!panelEl.classList.contains('panel-floating')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this._resetPanelPosition(panelId);
|
||||||
|
});
|
||||||
|
this.listen(handleEl, 'pointerdown', (event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
const interactive = event.target.closest?.(INTERACTIVE_SELECTOR);
|
||||||
|
if (interactive && !interactive.matches('.panel-collapse-btn')) return;
|
||||||
|
|
||||||
|
this._cancelDrag?.();
|
||||||
|
// A header press must not start a text selection across the page.
|
||||||
|
event.preventDefault();
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startY = event.clientY;
|
||||||
|
let dragging = false;
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetY = 0;
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
|
||||||
|
const onMove = (moveEvent) => {
|
||||||
|
if (!dragging) {
|
||||||
|
if (
|
||||||
|
Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) <
|
||||||
|
DRAG_THRESHOLD_PX
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
dragging = true;
|
||||||
|
const rect = panelEl.getBoundingClientRect();
|
||||||
|
offsetX = startX - rect.left;
|
||||||
|
offsetY = startY - rect.top;
|
||||||
|
width = rect.width;
|
||||||
|
height = rect.height;
|
||||||
|
this._liftPanelOut(panelId, panelEl, rect);
|
||||||
|
panelEl.classList.add('panel-dragging');
|
||||||
|
this._promotePanelZ(panelEl);
|
||||||
|
}
|
||||||
|
moveEvent.preventDefault();
|
||||||
|
const maxLeft = Math.max(
|
||||||
|
VIEWPORT_MARGIN_PX,
|
||||||
|
window.innerWidth - width - VIEWPORT_MARGIN_PX,
|
||||||
|
);
|
||||||
|
const maxTop = Math.max(
|
||||||
|
VIEWPORT_MARGIN_PX,
|
||||||
|
window.innerHeight - height - VIEWPORT_MARGIN_PX,
|
||||||
|
);
|
||||||
|
panelEl.style.left = `${clamp(moveEvent.clientX - offsetX, VIEWPORT_MARGIN_PX, maxLeft)}px`;
|
||||||
|
panelEl.style.top = `${clamp(moveEvent.clientY - offsetY, VIEWPORT_MARGIN_PX, maxTop)}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
panelEl.classList.remove('panel-dragging');
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
window.removeEventListener('pointercancel', onUp);
|
||||||
|
this._cancelDrag = null;
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
cancel();
|
||||||
|
if (!dragging) return;
|
||||||
|
swallowNextClick = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
swallowNextClick = false;
|
||||||
|
}, 0);
|
||||||
|
this._savePanelPosition(panelId, panelEl);
|
||||||
|
if (panelId === 'cctv-panel') {
|
||||||
|
this._syncCctvPanelViewport();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._cancelDrag = cancel;
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
window.addEventListener('pointercancel', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a resize handle for every edge and corner. Seven are invisible
|
||||||
|
* strips; the bottom-right corner shows a grip. Resizing a docked panel
|
||||||
|
* lifts it out first, so the handles work in both states.
|
||||||
|
*/
|
||||||
|
_makePanelResizable(panelId, panelEl, min = {}) {
|
||||||
|
const limits = { width: 300, height: 160, ...min };
|
||||||
|
for (const dir of RESIZE_DIRECTIONS) {
|
||||||
|
const handle = document.createElement('div');
|
||||||
|
handle.className =
|
||||||
|
dir === 'se' ? 'panel-resize-grip' : 'panel-resize-edge';
|
||||||
|
handle.dataset.dir = dir;
|
||||||
|
handle.setAttribute('aria-hidden', 'true');
|
||||||
|
if (dir === 'se') {
|
||||||
|
handle.title = 'Drag to resize · double-click the header to snap back';
|
||||||
|
}
|
||||||
|
panelEl.appendChild(handle);
|
||||||
|
this._resizeHandles.push(handle);
|
||||||
|
this.listen(handle, 'pointerdown', (event) =>
|
||||||
|
this._startPanelResize(event, panelId, panelEl, dir, limits),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startPanelResize(event, panelId, panelEl, dir, limits) {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this._cancelDrag?.();
|
||||||
|
const rect = panelEl.getBoundingClientRect();
|
||||||
|
this._liftPanelOut(panelId, panelEl, rect);
|
||||||
|
const startBox = {
|
||||||
|
left: Math.round(rect.left),
|
||||||
|
top: Math.round(rect.top),
|
||||||
|
width: Math.round(rect.width),
|
||||||
|
height: Math.round(rect.height),
|
||||||
|
};
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startY = event.clientY;
|
||||||
|
const apply = ({ left, top, width, height }) => {
|
||||||
|
panelEl.style.left = `${left}px`;
|
||||||
|
panelEl.style.top = `${top}px`;
|
||||||
|
panelEl.style.width = `${width}px`;
|
||||||
|
panelEl.style.height = `${height}px`;
|
||||||
|
};
|
||||||
|
panelEl.classList.add('panel-resizing');
|
||||||
|
this._promotePanelZ(panelEl);
|
||||||
|
apply(startBox);
|
||||||
|
|
||||||
|
const onMove = (moveEvent) => {
|
||||||
|
moveEvent.preventDefault();
|
||||||
|
apply(
|
||||||
|
resizeBox(
|
||||||
|
startBox,
|
||||||
|
dir,
|
||||||
|
moveEvent.clientX - startX,
|
||||||
|
moveEvent.clientY - startY,
|
||||||
|
{
|
||||||
|
minWidth: limits.width,
|
||||||
|
minHeight: limits.height,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
viewportHeight: window.innerHeight,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const cancel = () => {
|
||||||
|
panelEl.classList.remove('panel-resizing');
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
window.removeEventListener('pointercancel', onUp);
|
||||||
|
this._cancelDrag = null;
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
cancel();
|
||||||
|
this._savePanelPosition(panelId, panelEl);
|
||||||
|
if (panelId === 'cctv-panel') {
|
||||||
|
this._syncCctvPanelViewport();
|
||||||
|
}
|
||||||
|
this._onPanelResized?.(panelId);
|
||||||
|
};
|
||||||
|
|
||||||
|
this._cancelDrag = cancel;
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
window.addEventListener('pointercancel', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
this._cancelDrag?.();
|
this._cancelDrag?.();
|
||||||
for (const remove of this.removers.splice(0)) remove();
|
for (const remove of this.removers.splice(0)) remove();
|
||||||
|
for (const handle of this._resizeHandles.splice(0)) handle.remove();
|
||||||
this._draggableResizeObserver?.disconnect();
|
this._draggableResizeObserver?.disconnect();
|
||||||
this._draggableResizeObserver = null;
|
this._draggableResizeObserver = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ function element(
|
|||||||
getBoundingClientRect() {
|
getBoundingClientRect() {
|
||||||
return this.rect;
|
return this.rect;
|
||||||
},
|
},
|
||||||
matches: (selector) => selector === '[data-panel-id]',
|
matches: (selector) =>
|
||||||
|
selector === '[data-panel-id]' ||
|
||||||
|
(selector === '[data-panel-id]:not(.panel-floating)' &&
|
||||||
|
!classes.has('panel-floating')),
|
||||||
contains(target) {
|
contains(target) {
|
||||||
return (
|
return (
|
||||||
target === this || this.children.some((child) => child.contains(target))
|
target === this || this.children.some((child) => child.contains(target))
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ export function layoutRightPanelRail({
|
|||||||
}) {
|
}) {
|
||||||
if (!stack) return;
|
if (!stack) return;
|
||||||
|
|
||||||
|
// A panel lifted out of the rail (PanelPositionControls' portable mode)
|
||||||
|
// is a fixed window the rail neither measures nor allocates.
|
||||||
const panels = [...stack.children].filter((panel) =>
|
const panels = [...stack.children].filter((panel) =>
|
||||||
panel.matches('[data-panel-id]'),
|
panel.matches('[data-panel-id]:not(.panel-floating)'),
|
||||||
);
|
);
|
||||||
if (!hud.visible || hud.variant !== 'tactical') {
|
if (!hud.visible || hud.variant !== 'tactical') {
|
||||||
for (const panel of panels.filter((item) =>
|
for (const panel of panels.filter((item) =>
|
||||||
|
|||||||
@@ -103,6 +103,116 @@
|
|||||||
opacity: 0.96;
|
opacity: 0.96;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Portable rail panels ────────────────────
|
||||||
|
A header drag lifts a portable panel (PanelPositionControls) out of its
|
||||||
|
rail into a fixed window that remembers its place and size. The rail's
|
||||||
|
own placement rules in layers.css are guarded with :not(.panel-floating),
|
||||||
|
so these only have to beat a panel's base placement (one id). */
|
||||||
|
.panel-portable .panel-header {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.panel-portable.panel-dragging .panel-header {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
#right-context-rail > .panel-floating {
|
||||||
|
position: fixed;
|
||||||
|
inset: auto;
|
||||||
|
max-height: calc(100vh - 12px);
|
||||||
|
margin: 0;
|
||||||
|
flex: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
/* A collapsed window keeps its stored height for later but shows only its
|
||||||
|
header strip, so the resize handles have nothing to attach to. */
|
||||||
|
#right-context-rail > .panel-floating.collapsed {
|
||||||
|
max-height: max-content;
|
||||||
|
}
|
||||||
|
.panel-floating[style*='height'] > .cctv-panel-inner {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 100%;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
.panel-resize-edge,
|
||||||
|
.panel-resize-grip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.panel-resize-edge[data-dir='n'],
|
||||||
|
.panel-resize-edge[data-dir='s'] {
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
height: 7px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
.panel-resize-edge[data-dir='n'] {
|
||||||
|
top: -3px;
|
||||||
|
}
|
||||||
|
.panel-resize-edge[data-dir='s'] {
|
||||||
|
bottom: -3px;
|
||||||
|
}
|
||||||
|
.panel-resize-edge[data-dir='e'],
|
||||||
|
.panel-resize-edge[data-dir='w'] {
|
||||||
|
top: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
width: 7px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
.panel-resize-edge[data-dir='e'] {
|
||||||
|
right: -3px;
|
||||||
|
}
|
||||||
|
.panel-resize-edge[data-dir='w'] {
|
||||||
|
left: -3px;
|
||||||
|
}
|
||||||
|
.panel-resize-edge[data-dir='ne'],
|
||||||
|
.panel-resize-edge[data-dir='nw'],
|
||||||
|
.panel-resize-edge[data-dir='sw'] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
.panel-resize-edge[data-dir='ne'] {
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
.panel-resize-edge[data-dir='nw'] {
|
||||||
|
top: -3px;
|
||||||
|
left: -3px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
.panel-resize-edge[data-dir='sw'] {
|
||||||
|
bottom: -3px;
|
||||||
|
left: -3px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
.panel-resize-grip {
|
||||||
|
right: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
opacity: 0.55;
|
||||||
|
clip-path: polygon(100% 0, 100% 100%, 0 100%);
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
135deg,
|
||||||
|
transparent 0 3px,
|
||||||
|
rgba(0, 212, 255, 0.6) 3px 4px
|
||||||
|
);
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
.panel-portable:hover .panel-resize-grip,
|
||||||
|
.panel-resizing .panel-resize-grip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.panel-collapsible.collapsed > .panel-resize-edge,
|
||||||
|
.panel-collapsible.collapsed > .panel-resize-grip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.panel-resizing,
|
||||||
|
.panel-resizing * {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-title {
|
.panel-title {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
max-height var(--transition-fast);
|
max-height var(--transition-fast);
|
||||||
}
|
}
|
||||||
#right-context-rail > #pp-toggles,
|
#right-context-rail > #pp-toggles,
|
||||||
#right-context-rail > #cctv-panel,
|
#right-context-rail > #cctv-panel:not(.panel-floating),
|
||||||
#right-context-rail > #global-context-panel {
|
#right-context-rail > #global-context-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
inset: auto;
|
inset: auto;
|
||||||
@@ -173,12 +173,12 @@
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
#right-context-rail #cctv-panel {
|
#right-context-rail #cctv-panel:not(.panel-floating) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
#right-context-rail #cctv-panel.collapsed {
|
#right-context-rail #cctv-panel.collapsed:not(.panel-floating) {
|
||||||
width: var(--right-display-collapsed-width);
|
width: var(--right-display-collapsed-width);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@@ -234,18 +234,22 @@
|
|||||||
#right-context-rail.layout-focus {
|
#right-context-rail.layout-focus {
|
||||||
height: var(--right-stack-max-height);
|
height: var(--right-stack-max-height);
|
||||||
}
|
}
|
||||||
#right-context-rail.layout-exclusive > [data-panel-id].collapsed {
|
#right-context-rail.layout-exclusive
|
||||||
|
> [data-panel-id].collapsed:not(.panel-floating) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
#right-context-rail.layout-focus > [data-panel-id].collapsed {
|
#right-context-rail.layout-focus
|
||||||
|
> [data-panel-id].collapsed:not(.panel-floating) {
|
||||||
display: block;
|
display: block;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
#right-context-rail.layout-focus.layout-exclusive > [data-panel-id].collapsed {
|
#right-context-rail.layout-focus.layout-exclusive
|
||||||
|
> [data-panel-id].collapsed:not(.panel-floating) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
#right-context-rail.layout-focus > #pp-toggles:not(.collapsed),
|
#right-context-rail.layout-focus > #pp-toggles:not(.collapsed),
|
||||||
#right-context-rail.layout-focus > #cctv-panel:not(.collapsed),
|
#right-context-rail.layout-focus
|
||||||
|
> #cctv-panel:not(.collapsed):not(.panel-floating),
|
||||||
#right-context-rail.layout-focus > #global-context-panel:not(.collapsed) {
|
#right-context-rail.layout-focus > #global-context-panel:not(.collapsed) {
|
||||||
flex: 0 1 var(--right-panel-allocated-height);
|
flex: 0 1 var(--right-panel-allocated-height);
|
||||||
height: var(--right-panel-allocated-height);
|
height: var(--right-panel-allocated-height);
|
||||||
@@ -259,7 +263,7 @@
|
|||||||
scrollbar-color: rgba(0, 212, 255, 0.4) transparent;
|
scrollbar-color: rgba(0, 212, 255, 0.4) transparent;
|
||||||
}
|
}
|
||||||
#right-context-rail.layout-focus
|
#right-context-rail.layout-focus
|
||||||
> #cctv-panel:not(.collapsed)
|
> #cctv-panel:not(.collapsed):not(.panel-floating)
|
||||||
.cctv-panel-inner,
|
.cctv-panel-inner,
|
||||||
#right-context-rail.layout-focus
|
#right-context-rail.layout-focus
|
||||||
> #global-context-panel:not(.collapsed)
|
> #global-context-panel:not(.collapsed)
|
||||||
|
|||||||
@@ -202,8 +202,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#right-context-rail #pp-toggles.collapsed .pp-header-row,
|
#right-context-rail #pp-toggles.collapsed .pp-header-row,
|
||||||
#right-context-rail #cctv-panel,
|
#right-context-rail #cctv-panel:not(.panel-floating),
|
||||||
#right-context-rail #cctv-panel.collapsed,
|
#right-context-rail #cctv-panel.collapsed:not(.panel-floating),
|
||||||
#right-context-rail #radio-panel,
|
#right-context-rail #radio-panel,
|
||||||
#right-context-rail #radio-panel.collapsed,
|
#right-context-rail #radio-panel.collapsed,
|
||||||
#right-context-rail #global-context-panel,
|
#right-context-rail #global-context-panel,
|
||||||
|
|||||||
Reference in new issue
Block a user