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;