fix(ui): floating panels stay on-screen and clear of the command dock

From gekh's review of #768, each with a test that fails before:

- P2: a floating panel whose content grows (CCTV expanded, a photo opened)
  is pulled back on-screen: every portable panel is observed and re-clamped,
  not only on window resize.
- P2: a floating header can never land under #command-dock or the voice
  pill (the rail's stacking context paints below them): clamping keeps the
  header clear of their rects on drag, restore and every re-clamp.
- A live window resize shrinks a floating window wider or taller than the
  viewport; Cyber's accordion no longer collapses docked panels when a
  floating one opens.
- The three pointer gestures share one helper (threshold, mouse/pen only,
  cancel reverts), and the header-drag threshold now has a test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-05 18:35:30 +01:00
1 parent bb88a8b034
commit bbe7aff242
4 files changed
+502 -186

No files matched your search

+13
View File
@@ -85,3 +85,16 @@ test('non-Cyber explicit panels and restored preferences retain their existing p
} finally { globalThis.document = prior; }
}
});
test('opening a floating Cyber window leaves the docked rail panels open', () => {
const f = fixture(), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
f.cctv.classList.add('panel-floating');
f.owner.setPanelCollapsed('cctv-panel', false, { explicit: true });
assert.equal(f.cctv.classList.contains('collapsed'), false);
assert.equal(f.display.classList.contains('collapsed'), false, 'DISPLAY stays open');
assert.ok(!f.saved.some(([id, collapsed]) => id === 'pp-toggles' && collapsed));
} finally { globalThis.document = prior; }
});
+4 -1
View File
@@ -419,12 +419,15 @@ export class PanelChrome {
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.
// A floating window is outside the accordion both ways: opening one
// leaves the rail's panels as they are.
if (
explicit &&
!restore &&
!nextCollapsed &&
document.documentElement?.dataset.uiTheme === 'cyber' &&
panelEl.parentElement === this._rightPanelStack
panelEl.parentElement === this._rightPanelStack &&
!panelEl.classList.contains('panel-floating')
) {
for (const peer of this._rightPanelStack.children) {
if (
+186 -4
View File
@@ -5,10 +5,9 @@ import { PanelPositionControls } from './panelPositionControls.js';
/** Just enough DOM for PanelPositionControls' portable-panel paths. */
function fixture() {
const saved = Object.fromEntries(
['document', 'window', 'localStorage', 'performance'].map((key) => [
key,
globalThis[key],
]),
['document', 'window', 'localStorage', 'performance', 'ResizeObserver'].map(
(key) => [key, globalThis[key]],
),
);
const store = new Map();
const element = () => {
@@ -359,3 +358,186 @@ test('a pointercancel while dragging a floating window returns it to where it wa
f.restore();
}
});
test('a header press that travels under the drag threshold is a click, not a lift', () => {
const f = fixture();
try {
const { panel, handle } = dockedPanel(f, 'street-level-panel');
pointer(handle, 'pointerdown', { x: 200, y: 90 });
pointer(window, 'pointermove', { x: 202, y: 90 });
pointer(window, 'pointerup', { x: 202, y: 90 });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(panel.classList.contains('panel-dragging'), false);
assert.equal(f.store.has(POSITION_KEY), false);
assert.equal(f.calls.layout, 0, 'the rail was not re-laid out');
assert.deepEqual(f.calls.resized, []);
} finally {
f.restore();
}
});
/**
* Both portable panels wired through _initPanelDrag. Each box follows its
* inline left/top/width/height like a fixed window (`box` holds the natural
* size), its header is the top 36 px, and `obstacles` stand in for the
* command dock and voice pill. ResizeObserver callbacks run on `observe()`.
*/
function portableShell(f, { stored = {}, box = {}, obstacles = {} } = {}) {
const observers = [];
globalThis.ResizeObserver = class {
constructor(callback) {
this.callback = callback;
this.targets = [];
observers.push(this);
}
observe(target) {
this.targets.push(target);
}
disconnect() {}
};
const nodes = new Map();
const panels = {};
for (const id of ['cctv-panel', 'street-level-panel']) {
const panel = f.element();
const handle = f.element();
const natural = { width: 360, height: 420, ...box[id] };
panel.id = id;
panel.querySelector = (selector) =>
selector === '.panel-header' ? handle : null;
panel.getBoundingClientRect = () => {
const left = parseFloat(panel.style.left) || 0;
const top = parseFloat(panel.style.top) || 0;
const width = parseFloat(panel.style.width) || natural.width;
const height = parseFloat(panel.style.height) || natural.height;
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height,
};
};
handle.getBoundingClientRect = () => {
const rect = panel.getBoundingClientRect();
return { ...rect, height: 36, bottom: rect.top + 36 };
};
if (stored[id])
f.store.set(
`godsEyeView.v8.panelPos.${id}`,
JSON.stringify({ floating: true, ...stored[id] }),
);
nodes.set(id, panel);
panels[id] = { panel, handle, natural };
}
const obstacle = (id, rect) =>
nodes.set(id, {
getBoundingClientRect: () => ({
...rect,
width: rect.right - rect.left,
height: rect.bottom - rect.top,
}),
});
for (const [id, rect] of Object.entries(obstacles)) obstacle(id, rect);
globalThis.document.getElementById = (id) => nodes.get(id) ?? null;
f.owner._initPanelDrag();
return {
...panels,
obstacle,
observed: (node) => observers.some(({ targets }) => targets.includes(node)),
contentResized: () => observers.forEach(({ callback }) => callback([])),
};
}
test('a floating window whose content grows is pulled back on-screen', () => {
const f = fixture();
try {
const shell = portableShell(f, {
stored: { 'cctv-panel': { left: 900, top: 380 } },
});
const { panel, natural } = shell['cctv-panel'];
assert.equal(panel.style.top, '380px');
assert.equal(shell.observed(panel), true, 'its size is watched');
assert.equal(shell.observed(shell['street-level-panel'].panel), true);
// Expanding the collapsed strip, or an image opening in Street Level.
natural.height = 800;
shell.contentResized();
assert.equal(panel.style.top, `${900 - 800 - 6}px`);
assert.equal(panel.style.left, '900px');
} finally {
f.restore();
}
});
test('a live window resize shrinks a floating window larger than the viewport', () => {
const f = fixture();
try {
const shell = portableShell(f, {
stored: {
'cctv-panel': { left: 100, top: 80, width: 1300, height: 700 },
},
});
const { panel } = shell['cctv-panel'];
assert.equal(panel.style.width, '1300px');
Object.assign(window, { innerWidth: 1000, innerHeight: 500 });
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.width, '988px');
assert.equal(panel.style.height, '488px');
assert.equal(panel.style.left, '6px');
assert.equal(panel.style.top, '6px');
// A window that already fits keeps the size the user gave it.
Object.assign(window, { innerWidth: 1440, innerHeight: 900 });
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.width, '988px');
} finally {
f.restore();
}
});
const DOCK = { left: 300, top: 820, right: 1140, bottom: 882 };
test('a floating header never lands under the command dock or voice pill', () => {
const f = fixture();
try {
const shell = portableShell(f, {
stored: { 'cctv-panel': { left: 100, top: 80 } },
// A collapsed CCTV window is just its header strip.
box: { 'cctv-panel': { height: 36 } },
obstacles: { 'command-dock': DOCK },
});
const { panel, handle, natural } = shell['cctv-panel'];
const above = DOCK.top - 36 - 6;
// Drag: the strip dropped at the bottom centre stops above the dock.
pointer(handle, 'pointerdown', { x: 200, y: 90 });
pointer(window, 'pointermove', { x: 720, y: 880 });
assert.equal(panel.style.top, `${above}px`);
pointer(window, 'pointerup', { x: 720, y: 880 });
const key = 'godsEyeView.v8.panelPos.cctv-panel';
assert.equal(JSON.parse(f.store.get(key)).top, above);
// Restore: a place saved under the dock (older build) comes back clear.
f.store.set(key, JSON.stringify({ left: 620, top: 858, floating: true }));
f.owner._restorePanelPosition('cctv-panel', panel);
assert.equal(panel.style.top, `${above}px`);
// Re-clamp: the voice pill measured when the window resizes.
Object.assign(panel.style, { left: '1000px', top: '700px' });
shell.obstacle('gev-voice-control', {
left: 1190,
top: 690,
right: 1404,
bottom: 740,
});
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.top, `${690 - 36 - 6}px`);
// Only the header has to stay clear: a body over the dock is fine.
natural.height = 200;
Object.assign(panel.style, { left: '620px', top: '650px' });
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.top, '650px');
} finally {
f.restore();
}
});
+299 -181
View File
@@ -30,6 +30,13 @@ const DOUBLE_PRESS_MS = 400;
const DOUBLE_PRESS_SLOP_PX = 6;
/** Viewport margin every positioned panel keeps clear. */
const VIEWPORT_MARGIN_PX = 6;
/**
* Fixed chrome that paints over every panel: the rail is its own stacking
* context (z 110), below the command dock (145) and the voice pill (150). A
* floating window keeps its header clear of these so it can always be
* grabbed again.
*/
const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control'];
/** 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';
@@ -52,6 +59,15 @@ function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
/** A window extent that fits the viewport margins but never drops under its minimum. */
function fitWindowExtent(value, minimum, viewportExtent) {
return clamp(
Math.round(value),
minimum,
Math.max(minimum, viewportExtent - 2 * VIEWPORT_MARGIN_PX),
);
}
/**
* Only a mouse or pen lifts and resizes a portable panel. On a phone the
* rail is a scroll container, so a finger on a header is a swipe.
@@ -93,7 +109,7 @@ export class PanelPositionControls {
this._panelZCounter = PANEL_Z_BASE + 10;
this._draggableResizeObserver = null;
this._cancelDrag = null;
/** Portable panels by id: `{ panel, min: { width, height } }`. */
/** Portable panels by id: `{ panel, handle, min: { width, height }, dockOnCollapse }`. */
this._portablePanels = new Map();
this._resizeHandles = [];
this._dragInitialized = false;
@@ -120,18 +136,108 @@ export class PanelPositionControls {
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`;
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 back on-screen. Its size is the user's, except
* that a window wider or taller than the viewport shrinks to fit it.
*/
_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`;
}
/**
* Where a floating window may sit: inside the viewport margin, with its
* header out of the command dock and the voice pill, measured now because
* the dock grows and shrinks. Both sit at the foot of the screen, so the
* header goes above whichever it would land under.
* @param {object} entry A `_portablePanels` value.
* @param {number} left
* @param {number} top
* @param {{width: number, height: number, header: number}} [geometry]
* Window size and header depth; measured when omitted.
*/
_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 };
}
/** A window's size and how far down its header reaches. */
_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() {
try {
const marker = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.layoutResetNotified`;
@@ -188,6 +294,7 @@ export class PanelPositionControls {
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,
});
@@ -201,12 +308,18 @@ export class PanelPositionControls {
}
// 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. A floating
// window grows the same way when its content does (an image opens, a collapsed strip expands).
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());
@@ -292,27 +405,34 @@ export class PanelPositionControls {
// only one that was lifted or clicked this session.
this._promotePanelZ(panelEl);
if (Number.isFinite(pos.width)) {
panelEl.style.width = `${clamp(
Math.round(pos.width),
panelEl.style.width = `${fitWindowExtent(
pos.width,
portable.min.width,
Math.max(portable.min.width, window.innerWidth - 12),
window.innerWidth,
)}px`;
}
if (Number.isFinite(pos.height)) {
panelEl.style.height = `${clamp(
Math.round(pos.height),
panelEl.style.height = `${fitWindowExtent(
pos.height,
portable.min.height,
Math.max(portable.min.height, window.innerHeight - 12),
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,
);
// 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,
);
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
panelEl.style.right = 'auto';
@@ -457,10 +577,8 @@ 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`;
@@ -469,48 +587,85 @@ export class PanelPositionControls {
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();
}
},
});
});
}
/**
* Follow one pointer gesture on the window; every panel gesture runs on
* this. With a `threshold`, a press only becomes a gesture once the pointer
* has travelled that far: `onStart` runs then, and `onMove` (given the
* travel so far) only after it. `onEnd` (pointerup) and `onCancel`
* (pointercancel, `onEnd` unless given) learn whether it started. A new
* press, a snap-back or destroy() stops it through `_cancelDrag`, which
* 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);
}
/**
* 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
@@ -586,76 +741,53 @@ export class PanelPositionControls {
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;
const entry = this._portablePanels.get(panelId);
let lifted = false;
let before = null;
let offsetX = 0;
let offsetY = 0;
let width = 0;
let height = 0;
let geometry = null;
const onMove = (moveEvent) => {
if (!dragging) {
if (
Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) <
DRAG_THRESHOLD_PX
)
return;
dragging = true;
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 = startX - rect.left;
offsetY = startY - rect.top;
width = rect.width;
height = rect.height;
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);
}
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', onCancel);
this._cancelDrag = null;
};
const onUp = () => {
cancel();
if (!dragging) return;
swallowNextClick = true;
setTimeout(() => {
swallowNextClick = false;
}, 0);
this._savePanelPosition(panelId, panelEl);
this._onPanelResized?.(panelId);
};
const onCancel = () => {
cancel();
if (dragging)
this._revertPanelGesture(panelId, panelEl, lifted, before);
};
this._cancelDrag = cancel;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onCancel);
},
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);
},
});
});
}
@@ -701,8 +833,6 @@ export class PanelPositionControls {
if (event.button !== 0 || !isPrecisePointer(event)) return;
event.preventDefault();
this._cancelDrag?.();
const startX = event.clientX;
const startY = event.clientY;
// Set once the pointer has travelled: a plain click on an edge (or on
// the globe just beside a docked panel) must not lift it out.
let startBox = null;
@@ -714,60 +844,48 @@ export class PanelPositionControls {
panelEl.style.width = `${width}px`;
panelEl.style.height = `${height}px`;
};
const begin = () => {
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);
};
const onMove = (moveEvent) => {
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
if (!startBox) {
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
begin();
}
moveEvent.preventDefault();
apply(
resizeBox(startBox, dir, dx, dy, {
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', onCancel);
this._cancelDrag = null;
};
const onUp = () => {
cancel();
if (!startBox) return;
this._savePanelPosition(panelId, panelEl);
this._onPanelResized?.(panelId);
};
const onCancel = () => {
cancel();
if (startBox) this._revertPanelGesture(panelId, panelEl, lifted, before);
};
this._cancelDrag = cancel;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onCancel);
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 carried
// 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() {