From c69dd2e89c31250e89ad445bbca91bc9f3ca2b14 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 25 Sep 2026 20:45:17 +0100 Subject: [PATCH] fix(ui): portable panels survive reloads and answer a real double-click - The rail's startup move of panels into #right-context-rail cleared every panel's inline position, so a window restored as floating dropped back to its rail place after a reload. Floating panels now keep their geometry. - A header double-click never snapped a floating panel back in a real browser: the drag handler's pointerdown preventDefault() suppresses the mouse events a native dblclick is built from. Two presses within 400 ms and 6 px (no drag between) are now detected in pointerdown itself. - Portable specs can ask to dock when collapsed (dockOnCollapse), and PanelChrome exposes dockPanel(). Street Level opts in as a portable panel (min 320 x 280) with dockOnCollapse. - qa-panel-resize: the panel reader returns right/bottom (s/e handle points were NaN), counts only visible rail panels like the rail does, parks the window clear of the voice dock before the resize loop, and names the element under the pointer when an assertion fails. Co-Authored-By: Claude Opus 5.5 --- docs/KNOWN-ISSUES.md | 2 +- scripts/qa-panel-resize.mjs | 43 ++++++++++++++++++++--- src/ui/panelChrome.js | 10 ++++++ src/ui/panelLayoutController.js | 10 ++++-- src/ui/panelPositionControls.js | 62 +++++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 8 deletions(-) diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index e6f8c2b3f..45ff1e8cd 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -58,7 +58,7 @@ Related keys (current versions): `'1'` closed, absent means the panel's own default. A view opened from a share link ignores the stored value entirely. - Panel positions: `godsEyeView.v8.panelPos.` — written only for a - portable panel (currently CCTV) lifted out of its rail, as + portable panel (CCTV, Street Level) lifted out of its rail, as `{ left, top, width, height, floating: true }`. Deleting it, or double-clicking the panel header, returns that panel to the rail; docked panels store nothing. - CCTV calibration: `godsEyeView.cctv.calibration.v2` diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs index fc97614e9..572773aaa 100644 --- a/scripts/qa-panel-resize.mjs +++ b/scripts/qa-panel-resize.mjs @@ -117,6 +117,9 @@ async function main() { top: rect.top, width: rect.width, height: rect.height, + // handlePoint() reads the far edges for s/e handles. + right: rect.right, + bottom: rect.bottom, floating: panel.classList.contains('panel-floating'), collapsed: panel.classList.contains('collapsed'), inRail: panel.parentElement?.id === 'right-context-rail', @@ -189,8 +192,11 @@ async function main() { assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it'); const railExcludes = await page.evaluate((id) => { const rail = document.getElementById('right-context-rail'); + // Same rule as the rail: hidden panels (a layer that is off) take no room. const counted = [...rail.children].filter((panel) => - panel.matches('[data-panel-id]:not(.panel-floating):not(.collapsed)'), + panel.matches( + '[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])', + ), ); return { expandedCount: rail.dataset.expandedCount, @@ -210,6 +216,15 @@ async function main() { ); console.log('PASS: header drag lifts CCTV out of the rail'); + // Park the window near the top: the voice dock sits above every panel at + // the bottom of the screen and would otherwise cover the south corners + // once the loop below has grown the window. + { + const header = await headerPoint(); + await drag(header, 0, 120 - header.y); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + // Every handle moves only its own edges. const limits = { minWidth: MIN_SIZE.width, @@ -220,11 +235,21 @@ async function main() { for (const dir of RESIZE_DIRECTIONS) { const before = await readPanel(); const { dx, dy } = growthDelta(dir, 40); - await drag(handlePoint(before, dir), dx, dy); + const point = handlePoint(before, dir); + // Name what the pointer lands on, so a covered handle is obvious. + const hit = await page.evaluate(({ x, y }) => { + const node = document.elementFromPoint(x, y); + if (!node) return 'nothing'; + const dirAttr = node.dataset?.dir + ? `[data-dir=${node.dataset.dir}]` + : ''; + return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}${dirAttr}`; + }, point); + await drag(point, dx, dy); const after = await readPanel(); const expected = resizeBox(before, dir, dx, dy, limits); for (const key of ['left', 'top', 'width', 'height']) - near(after[key], expected[key], 2, `${dir} ${key}`); + near(after[key], expected[key], 2, `${dir} ${key} (pointer on ${hit})`); assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`); } console.log('PASS: eight resize handles keep the opposite edge pinned'); @@ -257,11 +282,21 @@ async function main() { // Double-click the header to snap back. const point = await headerPoint(); + const headerHit = await page.evaluate(({ x, y }) => { + const node = document.elementFromPoint(x, y); + return node + ? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}` + : 'nothing'; + }, point); await page.mouse.click(point.x, point.y, { clickCount: 1 }); await page.mouse.click(point.x, point.y, { clickCount: 2 }); await new Promise((resolve) => setTimeout(resolve, 300)); const snapped = await readPanel(); - assert.equal(snapped.floating, false, 'double-click snaps back'); + assert.equal( + snapped.floating, + false, + `double-click snaps back (pointer on ${headerHit})`, + ); assert.equal(snapped.inRail, true); assert.equal(snapped.inlineWidth, ''); assert.equal(snapped.inlineHeight, ''); diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 49b65de3d..82fcb9806 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -388,6 +388,15 @@ export class PanelChrome { this.shareLinkManager?.onPanelStateChange?.(); } + /** + * Dock a floating portable panel back into its rail at its default size. + * @param {string} panelId + * @returns {boolean} Whether the panel was floating. + */ + dockPanel(panelId) { + return this._panelPosition?.dockPanel?.(panelId) === true; + } + setPanelCollapsed( panelId, collapsed, @@ -548,6 +557,7 @@ export class PanelChrome { } } panelEl.classList.toggle('collapsed', nextCollapsed); + this._panelPosition?.onPanelCollapsed?.(panelId, nextCollapsed); if ( nextCollapsed && this.cockpitView?.active && diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index fc170646e..156268120 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -205,9 +205,13 @@ export class PanelLayoutController { this._streetLevelPanel, ]) { if (!panel) continue; - for (const property of ['top', 'right', 'bottom', 'left', 'z-index']) - panel.style.removeProperty(property); - panel.classList.remove('panel-draggable', 'panel-dragging'); + // A portable panel restored as a floating window keeps the place and + // size PanelPositionControls gave it; only docked panels are reset. + if (!panel.classList.contains('panel-floating')) { + for (const property of ['top', 'right', 'bottom', 'left', 'z-index']) + panel.style.removeProperty(property); + panel.classList.remove('panel-draggable', 'panel-dragging'); + } stack.insertBefore(panel, globalContextPanel); this._syncPanelCollapseButton(panel); } diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index f0473e50d..7ce0651cb 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -14,6 +14,13 @@ const PANEL_Z_BASE = 100; 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; +/** + * Two header presses this close in time and space snap a floating panel + * back. Detected from pointerdown because the drag handler's + * preventDefault() suppresses the mouse events a native dblclick needs. + */ +const DOUBLE_PRESS_MS = 400; +const DOUBLE_PRESS_SLOP_PX = 6; /** 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. */ @@ -123,6 +130,7 @@ export class PanelPositionControls { if (this.destroyed || this._dragInitialized) return; this._dragInitialized = true; const cctvPanel = document.getElementById('cctv-panel'); + const streetLevelPanel = document.getElementById('street-level-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. @@ -139,6 +147,16 @@ export class PanelPositionControls { 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) { @@ -147,6 +165,7 @@ export class PanelPositionControls { this._portablePanels.set(spec.id, { panel: spec.panel, min: { width: 300, height: 160, ...spec.min }, + dockOnCollapse: spec.dockOnCollapse === true, }); spec.panel.classList.add('panel-portable'); } @@ -475,6 +494,30 @@ export class PanelPositionControls { * a real drag is swallowed so it cannot toggle by accident. Double-clicking * the header snaps the panel back. */ + /** + * Put a floating portable panel back in its rail at its default size. + * @param {string} panelId + * @returns {boolean} Whether the panel was floating. + */ + dockPanel(panelId) { + const portable = this._portablePanels.get(panelId); + if (!portable?.panel.classList.contains('panel-floating')) return false; + this._resetPanelPosition(panelId); + return true; + } + + /** + * A panel was collapsed or expanded. A portable panel that opted into + * `dockOnCollapse` returns to its rail when collapsed while floating. + * @param {string} panelId + * @param {boolean} collapsed + */ + onPanelCollapsed(panelId, collapsed) { + if (!collapsed || !this._portablePanels.get(panelId)?.dockOnCollapse) + return; + this.dockPanel(panelId); + } + _makePortablePanelDraggable(panelId, panelEl, handleEl) { let swallowNextClick = false; this.listen( @@ -494,11 +537,28 @@ export class PanelPositionControls { event.preventDefault(); this._resetPanelPosition(panelId); }); + let lastPress = null; 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; + 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(); @@ -518,6 +578,8 @@ export class PanelPositionControls { ) return; dragging = true; + // A drag between two presses is not a double-click. + lastPress = null; const rect = panelEl.getBoundingClientRect(); offsetX = startX - rect.left; offsetY = startY - rect.top;