Merge remote-tracking branch 'upstream/main' into core-rb2

# Conflicts:
#	CHANGELOG.md
#	build/vite.js
#	scripts/package-boundaries.json
#	server/standalone/vite.config.js
#	src/app/scene.js
#	src/layers/vessels/queries.js
#	src/standalone/application.js
#	src/tooling/viteBuild.test.mjs
#	src/ui/cockpitInstruments.js
#	src/ui/layerPanel.js
#	src/ui/panelPositionControls.js
This commit is contained in:
Meir Michanie committed 2026-10-10 23:15:54 +02:00
commit 71f7a58169
475 files changed
+67826 -4965

No files matched your search

+613 -74
View File
@@ -1,5 +1,6 @@
/** Own panel position preferences, viewport clamping and drag listeners. */
import { t } from '../i18n/index.js';
import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js';
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
/**
@@ -11,40 +12,203 @@ 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;
/**
* Lowest z for a promoted panel: above every docked rail panel (#pp-toggles is
* 110). Keep in step with `#right-context-rail > .panel-floating` in controls.css.
*/
const PANEL_Z_FLOATING_FLOOR = PANEL_Z_BASE + 11;
/** Pointer travel before a press becomes a drag or resize. */
const DRAG_THRESHOLD_PX = 4;
/**
* Double press that snaps a floating panel back. Detected from pointerdown
* because the drag's preventDefault() suppresses a native dblclick.
*/
const DOUBLE_PRESS_MS = 400;
const DOUBLE_PRESS_SLOP_PX = 6;
const VIEWPORT_MARGIN_PX = 6;
/**
* Fixed chrome that paints over the rail's stacking context; a floating
* window keeps its header clear of it so it can always be grabbed again.
*/
const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control'];
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"]';
const FLOATING_STYLE_PROPERTIES = [
'left',
'top',
'right',
'bottom',
'width',
'height',
'z-index',
];
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function fitWindowExtent(value, minimum, viewportExtent) {
return clamp(
Math.round(value),
minimum,
Math.max(minimum, viewportExtent - 2 * VIEWPORT_MARGIN_PX),
);
}
function placeAt(panelEl, left, top) {
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
panelEl.style.right = 'auto';
panelEl.style.bottom = 'auto';
}
/** On a phone the rail scrolls, so a finger on a header is a swipe, not a drag. */
function isPrecisePointer(event) {
const pointerType = event.pointerType || 'mouse';
return pointerType === 'mouse' || pointerType === 'pen';
}
function geometryOf(panelEl) {
const { left, top, width, height } = panelEl.style;
return { left, top, width, height };
}
export class PanelPositionControls {
/** `onPanelResized(panelId)` runs after a portable panel moves, resizes or docks. */
constructor({
syncPanelCollapseButton,
layoutRightPanels,
syncCctvPanelViewport,
showToast,
onPanelResized = null,
}) {
this._syncPanelCollapseButton = syncPanelCollapseButton;
this._layoutRightPanels = layoutRightPanels;
this._syncCctvPanelViewport = syncCctvPanelViewport;
this._showToast = showToast;
this._onPanelResized = onPanelResized;
this._ppToggles = document.getElementById('pp-toggles');
this._panelZCounter = PANEL_Z_BASE + 10;
this._draggableResizeObserver = null;
this._cancelDrag = null;
this._portablePanels = new Map();
this._resizeHandles = [];
this._dragInitialized = false;
this.removers = [];
this.destroyed = false;
}
listen(target, type, callback) {
listen(target, type, callback, options) {
if (this.destroyed || !target) return;
const listener = (event) => {
if (!this.destroyed) callback(event);
};
target.addEventListener(type, listener);
this.removers.push(() => target.removeEventListener(type, listener));
target.addEventListener(type, listener, options);
this.removers.push(() =>
target.removeEventListener(type, listener, options),
);
}
_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);
if (el && el.style.top && el.style.top !== 'auto') {
const top = parseInt(el.style.top, 10);
if (Number.isFinite(top)) {
el.style.top = `${this._clampToViewport(0, top, el).top}px`;
this._pinPanelToRight(el);
}
}
for (const entry of this._portablePanels.values()) {
const { classList } = entry.panel;
if (!classList.contains('panel-floating')) continue;
// A live gesture owns the box; it re-clamps when it ends.
if (classList.contains('panel-dragging')) continue;
if (classList.contains('panel-resizing')) continue;
this._reclampPortablePanel(entry);
}
}
/** Bring a floating window on-screen; shrink it only if it exceeds the viewport. */
_reclampPortablePanel(entry) {
const { panel, min } = entry;
const left = parseInt(panel.style.left, 10);
const top = parseInt(panel.style.top, 10);
if (!Number.isFinite(left) || !Number.isFinite(top)) return;
for (const [property, viewportExtent] of [
['width', window.innerWidth],
['height', window.innerHeight],
]) {
const extent = parseFloat(panel.style[property]);
if (!Number.isFinite(extent)) continue;
const fitted = fitWindowExtent(extent, min[property], viewportExtent);
if (fitted < extent) panel.style[property] = `${fitted}px`;
}
const next = this._clampPortableWindow(entry, left, top);
panel.style.left = `${next.left}px`;
panel.style.top = `${next.top}px`;
}
/**
* Clamp a floating window inside the viewport margin with its header above
* the command dock and voice pill, measured each time since the dock resizes.
*/
_clampPortableWindow(
entry,
left,
top,
geometry = this._windowGeometry(entry),
) {
const { width, height, header } = geometry;
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,
);
const x = clamp(left, VIEWPORT_MARGIN_PX, maxLeft);
let y = clamp(top, VIEWPORT_MARGIN_PX, maxTop);
const obstacles = this._panelObstacles();
for (let pass = 0; pass < obstacles.length; pass++) {
const hit = obstacles.find(
(rect) =>
rect.left < x + width &&
rect.right > x &&
rect.top < y + header &&
rect.bottom > y,
);
if (!hit) break;
const above = hit.top - header - VIEWPORT_MARGIN_PX;
y =
above >= VIEWPORT_MARGIN_PX
? above
: Math.min(maxTop, hit.bottom + VIEWPORT_MARGIN_PX);
}
return { left: x, top: y };
}
_windowGeometry({ panel, handle }, rect = panel.getBoundingClientRect()) {
const handleRect = handle?.getBoundingClientRect();
const header = handleRect ? handleRect.bottom - rect.top : 0;
return {
width: rect.width,
height: rect.height,
// Without a measurable header, keep the whole window clear.
header: header > 0 ? Math.min(header, rect.height) : rect.height,
};
}
_panelObstacles() {
const rects = [];
for (const id of PANEL_OBSTACLE_IDS) {
const rect = document.getElementById(id)?.getBoundingClientRect();
// A hidden dock, or a voice UI switched off, measures empty.
if (rect && rect.width > 0 && rect.height > 0) rects.push(rect);
}
return rects;
}
_maybeNotifyLayoutReset() {
@@ -64,28 +228,71 @@ export class PanelPositionControls {
}
_initPanelDrag() {
if (this.destroyed) return;
if (this.destroyed || this._dragInitialized) return;
this._dragInitialized = true;
const cctvPanel = document.getElementById('cctv-panel');
const streetLevelPanel = document.getElementById('street-level-panel');
// A `portable` panel lifts out of its rail on a header drag, resizes from
// every edge and remembers its window; the others only reposition.
const dragSpecs = [
{
id: 'pp-toggles',
panel: this._ppToggles,
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 },
},
{
id: 'street-level-panel',
panel: streetLevelPanel,
handle: streetLevelPanel?.querySelector('.panel-header'),
portable: true,
min: { width: 320, height: 280 },
// Shrinking the window to its strip docks it, so it never sits
// over the globe as a stray header.
dockOnCollapse: true,
},
].filter(Boolean);
for (const spec of dragSpecs) {
if (!spec.panel || !spec.handle) continue;
if (spec.portable) {
this._portablePanels.set(spec.id, {
panel: spec.panel,
handle: spec.handle,
min: { width: 300, height: 160, ...spec.min },
dockOnCollapse: spec.dockOnCollapse === true,
});
spec.panel.classList.add('panel-portable');
}
this._restorePanelPosition(spec.id, spec.panel);
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
// 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') {
// the panel could still hang off the bottom (audit U2). Re-clamp on every size change. Floating
// windows grow the same way when their content does.
const observed = [
this._ppToggles,
...[...this._portablePanels.values()].map(({ panel }) => panel),
].filter(Boolean);
if (observed.length && typeof ResizeObserver !== 'undefined') {
this._draggableResizeObserver = new ResizeObserver(() =>
this._reclampDraggablePanels(),
);
this._draggableResizeObserver.observe(this._ppToggles);
for (const panel of observed)
this._draggableResizeObserver.observe(panel);
}
if (this._portablePanels.size) {
this.listen(window, 'resize', () => this._reclampDraggablePanels());
}
}
@@ -158,17 +365,43 @@ export class PanelPositionControls {
const pos = JSON.parse(raw);
if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number')
return;
// Only portable panels store, and accept, floating records.
const portable = this._portablePanels.get(panelId);
if (Boolean(pos.floating) !== Boolean(portable)) return;
if (portable) {
panelEl.classList.add('panel-floating', 'panel-draggable');
// A restored window must stack above the docked rail panels too.
this._promotePanelZ(panelEl);
if (Number.isFinite(pos.width)) {
panelEl.style.width = `${fitWindowExtent(
pos.width,
portable.min.width,
window.innerWidth,
)}px`;
}
if (Number.isFinite(pos.height)) {
panelEl.style.height = `${fitWindowExtent(
pos.height,
portable.min.height,
window.innerHeight,
)}px`;
}
}
// 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';
// another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too,
// and a window saved under the command dock must not come back there.
const { left, top } = portable
? this._clampPortableWindow(
portable,
Math.round(pos.left),
Math.round(pos.top),
)
: this._clampToViewport(
Math.round(pos.left),
Math.round(pos.top),
panelEl,
);
placeAt(panelEl, left, top);
if (panelId === 'pp-toggles') {
this._pinPanelToRight(panelEl);
}
@@ -189,13 +422,28 @@ export class PanelPositionControls {
_savePanelPosition(panelId, panelEl) {
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, and the inline value
// rather than the measured box: a collapsed window measures only its
// header but keeps the size the user chose for when it expands.
const chosen = (inline, measured) => {
const px = parseFloat(inline);
return Math.round(Number.isFinite(px) ? px : measured);
};
if (panelEl.style.width)
record.width = chosen(panelEl.style.width, rect.width);
if (panelEl.style.height)
record.height = chosen(panelEl.style.height, rect.height);
record.floating = true;
}
try {
localStorage.setItem(
this._panelStorageKey(panelId),
JSON.stringify({
left: Math.round(rect.left),
top: Math.round(rect.top),
}),
JSON.stringify(record),
);
} catch {
// storage unavailable
@@ -208,7 +456,7 @@ export class PanelPositionControls {
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;
let z = PANEL_Z_FLOATING_FLOOR;
for (const el of promoted) {
el.style.zIndex = String(z);
z += 1;
@@ -218,11 +466,70 @@ export class PanelPositionControls {
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 show it once this session
if (this._floatHintShown) return;
this._floatHintShown = true;
}
this._showToast(PANEL_FLOAT_HINT);
}
/**
* Turn a rail panel into a fixed window in place. Width is frozen so a
* `width: 100%` panel keeps its size; height stays natural until a resize.
*/
_liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) {
if (panelEl.classList.contains('panel-floating')) return false;
placeAt(panelEl, Math.round(rect.left), Math.round(rect.top));
if (!panelEl.classList.contains('collapsed')) {
panelEl.style.width = `${Math.round(rect.width)}px`;
}
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();
this._onPanelResized?.(panelId);
}
_makePanelDraggable(panelId, panelEl, handleEl) {
// Z-order promotion: bring clicked panel to front of the stacking context
this.listen(panelEl, 'pointerdown', () => {
this._promotePanelZ(panelEl);
});
if (this._portablePanels.has(panelId)) {
this._makePortablePanelDraggable(panelId, panelEl, handleEl);
return;
}
this.listen(handleEl, 'pointerdown', (event) => {
if (event.button !== 0) return;
@@ -237,64 +544,296 @@ export class PanelPositionControls {
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;
const offsetX = event.clientX - rect.left;
const offsetY = event.clientY - rect.top;
panelEl.style.left = `${rect.left}px`;
panelEl.style.top = `${rect.top}px`;
panelEl.style.right = 'auto';
panelEl.style.bottom = 'auto';
placeAt(panelEl, rect.left, rect.top);
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);
this._trackPointerGesture(event, {
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();
}
},
onStop: () => panelEl.classList.remove('panel-dragging'),
// A cancelled press keeps the place too: there is no rail to revert to.
onEnd: () => {
if (panelId === 'pp-toggles') {
this._pinPanelToRight(panelEl);
}
this._savePanelPosition(panelId, panelEl);
if (panelId === 'cctv-panel') {
this._syncCctvPanelViewport();
}
},
});
});
}
/**
* Track one pointer gesture. With a `threshold`, `onStart` and `onMove` wait
* until the pointer has travelled that far; `onEnd`/`onCancel` get whether
* it started. Stopping through `_cancelDrag` calls neither.
*/
_trackPointerGesture(
event,
{ threshold = 0, onStart, onMove, onStop, onEnd, onCancel = onEnd },
) {
const startX = event.clientX;
const startY = event.clientY;
let started = threshold <= 0;
const move = (moveEvent) => {
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
if (!started) {
if (Math.hypot(dx, dy) < threshold) return;
started = true;
onStart?.();
}
onMove(moveEvent, dx, dy);
};
const stop = () => {
onStop?.();
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
window.removeEventListener('pointercancel', cancel);
if (this._cancelDrag === stop) this._cancelDrag = null;
};
const up = () => {
stop();
onEnd?.(started);
};
const cancel = () => {
stop();
onCancel?.(started);
};
this._cancelDrag = stop;
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
window.addEventListener('pointercancel', cancel);
}
/** @returns {boolean} Whether the panel was floating and is now docked. */
dockPanel(panelId) {
const portable = this._portablePanels.get(panelId);
if (!portable?.panel.classList.contains('panel-floating')) return false;
this._resetPanelPosition(panelId);
return true;
}
/** A `dockOnCollapse` panel returns to its rail when collapsed while floating. */
onPanelCollapsed(panelId, collapsed) {
if (!collapsed || !this._portablePanels.get(panelId)?.dockOnCollapse)
return;
this.dockPanel(panelId);
}
/**
* The collapse button still toggles on a plain click; the click that ends a
* real drag is swallowed so it cannot toggle by accident.
*/
_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);
});
let lastPress = null;
this.listen(handleEl, 'pointerdown', (event) => {
if (event.button !== 0 || !isPrecisePointer(event)) return;
const interactive = event.target.closest?.(INTERACTIVE_SELECTOR);
if (interactive && !interactive.matches('.panel-collapse-btn')) return;
const now = event.timeStamp || performance.now();
const doublePress =
!interactive &&
lastPress &&
now - lastPress.time <= DOUBLE_PRESS_MS &&
Math.hypot(event.clientX - lastPress.x, event.clientY - lastPress.y) <=
DOUBLE_PRESS_SLOP_PX;
lastPress = doublePress
? null
: { time: now, x: event.clientX, y: event.clientY };
if (doublePress && panelEl.classList.contains('panel-floating')) {
event.preventDefault();
this._resetPanelPosition(panelId);
return;
}
this._cancelDrag?.();
// A header press must not start a text selection across the page.
event.preventDefault();
const entry = this._portablePanels.get(panelId);
let lifted = false;
let before = null;
let offsetX = 0;
let offsetY = 0;
let geometry = null;
this._trackPointerGesture(event, {
threshold: DRAG_THRESHOLD_PX,
onStart: () => {
// A drag between two presses is not a double-click.
lastPress = null;
const rect = panelEl.getBoundingClientRect();
offsetX = event.clientX - rect.left;
offsetY = event.clientY - rect.top;
geometry = this._windowGeometry(entry, rect);
before = geometryOf(panelEl);
lifted = this._liftPanelOut(panelId, panelEl, rect);
panelEl.classList.add('panel-dragging');
this._promotePanelZ(panelEl);
},
onMove: (moveEvent) => {
moveEvent.preventDefault();
const next = this._clampPortableWindow(
entry,
moveEvent.clientX - offsetX,
moveEvent.clientY - offsetY,
geometry,
);
panelEl.style.left = `${next.left}px`;
panelEl.style.top = `${next.top}px`;
},
onStop: () => panelEl.classList.remove('panel-dragging'),
onEnd: (dragged) => {
if (!dragged) return;
swallowNextClick = true;
setTimeout(() => {
swallowNextClick = false;
}, 0);
this._savePanelPosition(panelId, panelEl);
this._onPanelResized?.(panelId);
},
onCancel: (dragged) => {
if (dragged)
this._revertPanelGesture(panelId, panelEl, lifted, before);
},
});
});
}
/** On pointercancel, restore the panel as the gesture found it; save nothing. */
_revertPanelGesture(panelId, panelEl, lifted, before) {
if (lifted) {
this._resetPanelPosition(panelId);
return;
}
Object.assign(panelEl.style, before);
this._onPanelResized?.(panelId);
}
/** Resizing a docked panel lifts it out first, so 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 || !isPrecisePointer(event)) return;
event.preventDefault();
this._cancelDrag?.();
// Set once the pointer has travelled: a plain click on an edge must not
// lift the panel out.
let startBox = null;
let lifted = false;
let before = null;
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`;
};
this._trackPointerGesture(event, {
threshold: DRAG_THRESHOLD_PX,
onStart: () => {
const rect = panelEl.getBoundingClientRect();
before = geometryOf(panelEl);
lifted = this._liftPanelOut(panelId, panelEl, rect);
startBox = {
left: Math.round(rect.left),
top: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
panelEl.classList.add('panel-resizing');
this._promotePanelZ(panelEl);
apply(startBox);
},
onMove: (moveEvent, dx, dy) => {
moveEvent.preventDefault();
apply(
resizeBox(startBox, dir, dx, dy, {
minWidth: limits.width,
minHeight: limits.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
}),
);
},
onStop: () => panelEl.classList.remove('panel-resizing'),
onEnd: (resized) => {
if (!resized) return;
// Re-clamping skipped the live gesture; a north edge may have moved
// the header under the command dock.
const entry = this._portablePanels.get(panelId);
if (entry) this._reclampPortablePanel(entry);
this._savePanelPosition(panelId, panelEl);
this._onPanelResized?.(panelId);
},
onCancel: (resized) => {
if (resized) this._revertPanelGesture(panelId, panelEl, lifted, before);
},
});
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this._cancelDrag?.();
for (const remove of this.removers.splice(0)) remove();
for (const handle of this._resizeHandles.splice(0)) handle.remove();
this._draggableResizeObserver?.disconnect();
this._draggableResizeObserver = null;
}