mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
PR #768 bundled a portable-panel windowing system with the Street Level
layer. Remove it so CCTV and Street Level are ordinary docked right-rail
panels again, as on main before the merge.
Removed:
- header drag to float, 8-way edge/corner resize, double-press snap-back,
dockOnCollapse, command-dock clearance, float hint toast, floating z floor,
persisted floating geometry and ResizeObserver re-clamping
(panelPositionControls.js restored to its pre-#768 version)
- src/ui/panelResize.js and its tests, panelDock.test.mjs
- onPanelResized/dockPanel wiring in PanelChrome, the application shell and
StreetLevelControls (EXPAND/SHRINK no longer docks)
- :not(.panel-floating) guards and .panel-floating/.panel-portable/resize CSS
- qa:panel-resize, qa-panel-resize.mjs, qa-panelDrag.mjs, their tests, the
CI "Portable panel gate" step, QA_FAIL_ON_RETRY, and the Street Level
gate's float/resize/dock steps
- floating-panel docs in README, CHANGELOG, CURRENT-STATE, KNOWN-ISSUES
Kept: the Street Level panel as a docked rail panel (share/cockpit/rail
lists, layout controller, rail and Cyber CSS, EXPAND viewer), and the
independent right-rail alignment fix (settledLeftRailTop, 9dde7b4).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
303 lines
10 KiB
JavaScript
303 lines
10 KiB
JavaScript
/** Own panel position preferences, viewport clamping and drag listeners. */
|
|
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
|
|
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
|
|
/**
|
|
* Position keys are versioned separately from collapsed-state keys so layout
|
|
* default changes (e.g. right-rail origin) can reset positions without also
|
|
* resetting every panel's open/closed preference.
|
|
*/
|
|
const PANEL_POSITION_STORAGE_VERSION = 'v8';
|
|
/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */
|
|
const PANEL_Z_BASE = 100;
|
|
const PANEL_Z_MAX = 139;
|
|
export class PanelPositionControls {
|
|
constructor({
|
|
syncPanelCollapseButton,
|
|
layoutRightPanels,
|
|
syncCctvPanelViewport,
|
|
showToast,
|
|
}) {
|
|
this._syncPanelCollapseButton = syncPanelCollapseButton;
|
|
this._layoutRightPanels = layoutRightPanels;
|
|
this._syncCctvPanelViewport = syncCctvPanelViewport;
|
|
this._showToast = showToast;
|
|
this._ppToggles = document.getElementById('pp-toggles');
|
|
this._panelZCounter = PANEL_Z_BASE + 10;
|
|
this._draggableResizeObserver = null;
|
|
this._cancelDrag = null;
|
|
this.removers = [];
|
|
this.destroyed = false;
|
|
}
|
|
listen(target, type, callback) {
|
|
if (this.destroyed || !target) return;
|
|
const listener = (event) => {
|
|
if (!this.destroyed) callback(event);
|
|
};
|
|
target.addEventListener(type, listener);
|
|
this.removers.push(() => target.removeEventListener(type, listener));
|
|
}
|
|
_reclampDraggablePanels() {
|
|
if (this.destroyed) return;
|
|
const el = this._ppToggles;
|
|
if (!el || !el.style.top || el.style.top === 'auto') return;
|
|
const top = parseInt(el.style.top, 10);
|
|
if (!Number.isFinite(top)) return;
|
|
el.style.top = `${this._clampToViewport(0, top, el).top}px`;
|
|
this._pinPanelToRight(el);
|
|
}
|
|
|
|
_maybeNotifyLayoutReset() {
|
|
try {
|
|
const marker = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.layoutResetNotified`;
|
|
if (localStorage.getItem(marker)) return;
|
|
localStorage.setItem(marker, '1');
|
|
const hadOldPositions = Object.keys(localStorage).some((key) =>
|
|
key.startsWith('godsEyeView.v6.panelPos.'),
|
|
);
|
|
if (hadOldPositions) {
|
|
this._showToast(
|
|
'Panel layout updated — positions reset to new defaults',
|
|
);
|
|
}
|
|
} catch {
|
|
// storage unavailable
|
|
}
|
|
}
|
|
|
|
_initPanelDrag() {
|
|
if (this.destroyed) return;
|
|
const dragSpecs = [
|
|
{
|
|
id: 'pp-toggles',
|
|
panel: this._ppToggles,
|
|
handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'),
|
|
},
|
|
].filter(Boolean);
|
|
|
|
for (const spec of dragSpecs) {
|
|
if (!spec.panel || !spec.handle) continue;
|
|
this._restorePanelPosition(spec.id, spec.panel);
|
|
this._makePanelDraggable(spec.id, spec.panel, spec.handle);
|
|
}
|
|
// 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
|
|
// the panel could still hang off the bottom (audit U2). Re-clamp on every size change.
|
|
if (this._ppToggles && typeof ResizeObserver !== 'undefined') {
|
|
this._draggableResizeObserver = new ResizeObserver(() =>
|
|
this._reclampDraggablePanels(),
|
|
);
|
|
this._draggableResizeObserver.observe(this._ppToggles);
|
|
}
|
|
}
|
|
|
|
_panelStorageKey(panelId) {
|
|
return `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.${panelId}`;
|
|
}
|
|
|
|
_panelCollapseStorageKey(panelId) {
|
|
return `godsEyeView.${PANEL_LAYOUT_STORAGE_VERSION}.panelCollapsed.${panelId}`;
|
|
}
|
|
|
|
_restorePanelCollapsedState(panelId, { allowStored = true } = {}) {
|
|
const panelEl = document.getElementById(panelId);
|
|
if (!panelEl) return;
|
|
let collapsed = panelEl.classList.contains('collapsed');
|
|
let stored = null;
|
|
if (allowStored) {
|
|
try {
|
|
stored = localStorage.getItem(this._panelCollapseStorageKey(panelId));
|
|
if (stored === '1') collapsed = true;
|
|
if (stored === '0') collapsed = false;
|
|
} catch {
|
|
// storage unavailable
|
|
}
|
|
}
|
|
// DISPLAY starts COLLAPSED for a first-time visitor, then respects the
|
|
// user's persisted choice like every other panel.
|
|
//
|
|
// It used to start expanded, to advertise the HUD / DETECT / 3D toggles.
|
|
// That reason expired when those became ON by default: the rail now opens
|
|
// to offer controls for things already happening, while competing with the
|
|
// first-run mission card for the one first impression there is. A stored
|
|
// choice still wins in both directions, so anyone who opens it keeps it.
|
|
if (panelId === 'pp-toggles' && stored === null) collapsed = true;
|
|
panelEl.classList.toggle('collapsed', collapsed);
|
|
// Bodies that expand themselves on first appearance must not override a
|
|
// choice the user (or a share link) already made.
|
|
if (panelEl.dataset)
|
|
panelEl.dataset.collapsedPreference = !allowStored
|
|
? 'share'
|
|
: stored === null
|
|
? 'default'
|
|
: 'stored';
|
|
this._syncPanelCollapseButton(panelEl);
|
|
}
|
|
|
|
_savePanelCollapsedState(panelId, collapsed) {
|
|
try {
|
|
localStorage.setItem(
|
|
this._panelCollapseStorageKey(panelId),
|
|
collapsed ? '1' : '0',
|
|
);
|
|
} catch {
|
|
// storage unavailable
|
|
}
|
|
}
|
|
|
|
_pinPanelToRight(panelEl) {
|
|
if (!panelEl) return;
|
|
const rect = panelEl.getBoundingClientRect();
|
|
const rightOffset = Math.max(6, Math.round(window.innerWidth - rect.right));
|
|
panelEl.style.right = `${rightOffset}px`;
|
|
panelEl.style.left = 'auto';
|
|
}
|
|
|
|
_restorePanelPosition(panelId, panelEl) {
|
|
try {
|
|
const raw = localStorage.getItem(this._panelStorageKey(panelId));
|
|
if (!raw) return;
|
|
const pos = JSON.parse(raw);
|
|
if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number')
|
|
return;
|
|
// 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.
|
|
const { left, top } = this._clampToViewport(
|
|
Math.round(pos.left),
|
|
Math.round(pos.top),
|
|
panelEl,
|
|
);
|
|
panelEl.style.left = `${left}px`;
|
|
panelEl.style.top = `${top}px`;
|
|
panelEl.style.right = 'auto';
|
|
panelEl.style.bottom = 'auto';
|
|
if (panelId === 'pp-toggles') {
|
|
this._pinPanelToRight(panelEl);
|
|
}
|
|
} catch {
|
|
// ignore malformed saved panel position
|
|
}
|
|
}
|
|
|
|
_clampToViewport(left, top, panelEl) {
|
|
const rect = panelEl.getBoundingClientRect();
|
|
const maxLeft = Math.max(6, window.innerWidth - rect.width - 6);
|
|
const maxTop = Math.max(6, window.innerHeight - rect.height - 6);
|
|
return {
|
|
left: Math.max(6, Math.min(maxLeft, left)),
|
|
top: Math.max(6, Math.min(maxTop, top)),
|
|
};
|
|
}
|
|
|
|
_savePanelPosition(panelId, panelEl) {
|
|
const rect = panelEl.getBoundingClientRect();
|
|
try {
|
|
localStorage.setItem(
|
|
this._panelStorageKey(panelId),
|
|
JSON.stringify({
|
|
left: Math.round(rect.left),
|
|
top: Math.round(rect.top),
|
|
}),
|
|
);
|
|
} catch {
|
|
// storage unavailable
|
|
}
|
|
}
|
|
|
|
_promotePanelZ(panelEl) {
|
|
this._panelZCounter += 1;
|
|
if (this._panelZCounter > PANEL_Z_MAX) {
|
|
const promoted = [...document.querySelectorAll('.panel-draggable')]
|
|
.filter((el) => el.style.zIndex)
|
|
.sort((a, b) => Number(a.style.zIndex) - Number(b.style.zIndex));
|
|
let z = PANEL_Z_BASE + 1;
|
|
for (const el of promoted) {
|
|
el.style.zIndex = String(z);
|
|
z += 1;
|
|
}
|
|
this._panelZCounter = z;
|
|
}
|
|
panelEl.style.zIndex = String(this._panelZCounter);
|
|
}
|
|
|
|
_makePanelDraggable(panelId, panelEl, handleEl) {
|
|
// Z-order promotion: bring clicked panel to front of the stacking context
|
|
this.listen(panelEl, 'pointerdown', () => {
|
|
this._promotePanelZ(panelEl);
|
|
});
|
|
|
|
this.listen(handleEl, 'pointerdown', (event) => {
|
|
if (event.button !== 0) return;
|
|
if (event.target.closest('.panel-collapse-btn')) return;
|
|
if (
|
|
event.target.closest(
|
|
'input, select, option, button:not(.panel-collapse-btn)',
|
|
)
|
|
)
|
|
return;
|
|
|
|
this._cancelDrag?.();
|
|
event.preventDefault();
|
|
const rect = panelEl.getBoundingClientRect();
|
|
const startX = event.clientX;
|
|
const startY = event.clientY;
|
|
const offsetX = startX - rect.left;
|
|
const offsetY = startY - rect.top;
|
|
|
|
panelEl.style.left = `${rect.left}px`;
|
|
panelEl.style.top = `${rect.top}px`;
|
|
panelEl.style.right = 'auto';
|
|
panelEl.style.bottom = 'auto';
|
|
panelEl.classList.add('panel-dragging');
|
|
this._promotePanelZ(panelEl);
|
|
|
|
const onMove = (moveEvent) => {
|
|
const nextLeftRaw = moveEvent.clientX - offsetX;
|
|
const nextTopRaw = moveEvent.clientY - offsetY;
|
|
const maxLeft = Math.max(6, window.innerWidth - rect.width - 6);
|
|
const maxTop = Math.max(6, window.innerHeight - rect.height - 6);
|
|
const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw));
|
|
const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw));
|
|
panelEl.style.left = `${nextLeft}px`;
|
|
panelEl.style.top = `${nextTop}px`;
|
|
if (panelId === 'pp-toggles') {
|
|
this._layoutRightPanels();
|
|
}
|
|
if (panelId === 'cctv-panel') {
|
|
this._syncCctvPanelViewport();
|
|
}
|
|
};
|
|
|
|
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 (panelId === 'pp-toggles') {
|
|
this._pinPanelToRight(panelEl);
|
|
}
|
|
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);
|
|
});
|
|
}
|
|
destroy() {
|
|
if (this.destroyed) return;
|
|
this.destroyed = true;
|
|
this._cancelDrag?.();
|
|
for (const remove of this.removers.splice(0)) remove();
|
|
this._draggableResizeObserver?.disconnect();
|
|
this._draggableResizeObserver = null;
|
|
}
|
|
}
|