diff --git a/CHANGELOG.md b/CHANGELOG.md index d2d462031..9dae7d8af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Rail panels can opt into a portable mode: dragging the CCTV panel by its + header lifts it out of the right rail into a floating window that resizes + from any edge or corner, remembers its position and size, and snaps back on + a header double-click. + - Street Level: a provider-neutral street-level imagery layer modelled on the iD editor's photo overlay. One panel in the right rail carries a chip per imagery provider, shared 360°/flat and captured-since filters, coverage drawn on the globe from orbit down to a single street, image cones per sequence, and one embedded viewer whose adapter follows the provider of the open image; each active provider credits its imagery on the globe, and share links carry the provider switches and the filter. Mapillary is the first provider (free client token; CC BY-SA 4.0 imagery); Google Street View, KartaView and Panoramax are next. - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1e1f5488b..cd3a18e4f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4094,8 +4094,8 @@ easier to meet (detection is now on more often), but does not create it. samples and constant elevation during E/N drag; one shared-floor resolution on release; late one-shot shared-cell work is permitted during viewshed idle. The A+B harness intentionally excludes citywide LOD assertions. -- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.` (`'0'` open, `'1'` closed, absent means the panel's own default). -- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.`. +- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`. The rails lay docked panels out adaptively and write nothing for them; a portable panel (currently CCTV) persists `{ left, top, width, height, floating: true }` under that key once a header drag lifts it out of the rail, and a header double-click removes the record again. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.` (`'0'` open, `'1'` closed, absent means the panel's own default). +- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.`. - Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker. - Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`. - Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring. diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index e41623cc6..e6f8c2b3f 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -57,9 +57,10 @@ Related keys (current versions): - Panel collapsed state: `godsEyeView.v6.panelCollapsed.` — `'0'` open, `'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.` — the versioned name for a - stored position. The current layout writes none, so deleting one changes - nothing. +- Panel positions: `godsEyeView.v8.panelPos.` — written only for a + portable panel (currently CCTV) 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/package.json b/package.json index a342e10bf..d742c777e 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "opensky:import": "./scripts/opensky-import-client.sh", "preview": "vite preview", "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", + "qa:panel-resize": "node scripts/qa-panel-resize.mjs", "qa:street-level": "node scripts/qa-street-level.mjs", "qa:transit": "node scripts/qa-transit.mjs", "test": "node scripts/run-unit-tests.mjs", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 773541a41..95239614a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -26,6 +26,8 @@ "src/main.js", "docs/APPLICATION.md", "scripts/qa-application.mjs", + "scripts/qa-panel-resize.mjs", + "src/tooling/panelResizeQa.test.mjs", "src/app/application.js", "src/app/application.test.mjs", "src/app/mapLifecycle.test.mjs", @@ -152,6 +154,7 @@ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", "src/ui/rightPanelRail.js", + "src/ui/panelResize.test.mjs", "src/ui/panelMeasurement.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3bc32f94a..ab53ccb43 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -598,6 +598,7 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", + "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/radio.js", @@ -1820,6 +1821,7 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", + "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/radio.js", @@ -2652,6 +2654,7 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", + "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/rightPanelRail.js", diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs new file mode 100644 index 000000000..fc97614e9 --- /dev/null +++ b/scripts/qa-panel-resize.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node +/** + * Prove the portable CCTV panel in a real browser: a header drag lifts it out + * of the right rail, every resize handle moves only its own edges, minimum + * sizes hold, the window survives a reload and a header double-click snaps + * it back and forgets the stored position. + */ +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; +import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js'; + +export const PANEL_ID = 'cctv-panel'; +export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`; +export const MIN_SIZE = { width: 300, height: 160 }; +const VIEWPORT = { width: 1400, height: 900 }; +const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' }; + +/** Box edges a resize in `dir` must leave exactly where they were. */ +export function pinnedEdges(dir) { + return Object.entries(EDGE_BY_SIDE) + .filter(([side]) => !dir.includes(side)) + .map(([, edge]) => edge); +} + +/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */ +export function growthDelta(dir, amount) { + return { + dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0, + dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0, + }; +} + +/** + * Viewport point inside the handle for `dir`, given the panel's client rect. + * Edge strips straddle the border by 3px; corners and the grip sit inside. + */ +export function handlePoint(rect, dir) { + const inset = dir.length === 2 ? 4 : 1; + const x = dir.includes('w') + ? rect.left + inset + : dir.includes('e') + ? rect.right - inset + : rect.left + rect.width / 2; + const y = dir.includes('n') + ? rect.top + inset + : dir.includes('s') + ? rect.bottom - inset + : rect.top + rect.height / 2; + return { x, y }; +} + +/** Edges of `after` that moved although a resize in `dir` should have pinned them. */ +export function driftedEdges(before, after, dir, tolerance = 1.5) { + const edges = (box) => ({ + left: box.left, + top: box.top, + right: box.left + box.width, + bottom: box.top + box.height, + }); + const a = edges(before); + const b = edges(after); + return pinnedEdges(dir).filter( + (edge) => Math.abs(a[edge] - b[edge]) > tolerance, + ); +} + +function near(actual, expected, tolerance, label) { + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${label}: expected ${expected} ± ${tolerance}, got ${actual}`, + ); +} + +async function main() { + const { default: puppeteer } = await import('puppeteer'); + const args = process.argv.slice(2); + const urlIndex = args.indexOf('--url'); + const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; + const browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || + (await puppeteer.executablePath()), + args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], + }); + try { + const page = await browser.newPage(); + await page.setViewport(VIEWPORT); + const errors = []; + page.on('pageerror', (error) => errors.push(error.stack || error.message)); + + const boot = async () => { + await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.viewer, { + timeout: 90_000, + }); + await page.waitForSelector(`#${PANEL_ID} .panel-header`); + // The first-run dialog sits over the globe until a choice is made. + await page.evaluate(() => { + document.querySelector('.first-run-explore')?.click(); + }); + await page.keyboard.press('Escape'); + await page.evaluate(() => { + document.getElementById('first-run-launcher')?.remove(); + for (const node of document.querySelectorAll('[class*=first-run]')) + node.remove(); + }); + await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2); + await new Promise((resolve) => setTimeout(resolve, 400)); + }; + const readPanel = () => + page.evaluate((id) => { + const panel = document.getElementById(id); + const rect = panel.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + floating: panel.classList.contains('panel-floating'), + collapsed: panel.classList.contains('collapsed'), + inRail: panel.parentElement?.id === 'right-context-rail', + allocated: panel.style.getPropertyValue( + '--right-panel-allocated-height', + ), + inlineWidth: panel.style.width, + inlineHeight: panel.style.height, + stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`), + }; + }, PANEL_ID); + const headerPoint = async () => { + const rect = await page.evaluate((id) => { + const title = document.querySelector( + `#${id} .panel-header .panel-title`, + ); + const r = title.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }, PANEL_ID); + return rect; + }; + const drag = async (from, dx, dy) => { + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps: 6 }); + await page.mouse.move(from.x + dx, from.y + dy, { steps: 6 }); + await page.mouse.up(); + await new Promise((resolve) => setTimeout(resolve, 120)); + }; + + await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate((key) => { + localStorage.removeItem(key); + localStorage.removeItem('godsEyeView.v8.panelFloatHintShown'); + }, STORAGE_KEY); + await boot(); + + // Expand CCTV so the panel has a body worth resizing. + await page.evaluate((id) => { + const panel = document.getElementById(id); + if (panel.classList.contains('collapsed')) + document + .querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`) + .click(); + }, PANEL_ID); + await page.waitForFunction( + (id) => !document.getElementById(id).classList.contains('collapsed'), + {}, + PANEL_ID, + ); + await new Promise((resolve) => setTimeout(resolve, 600)); + const docked = await readPanel(); + assert.equal(docked.floating, false); + assert.equal(docked.inRail, true); + + // Lift out: drag the header 200px left and 100px up. + await drag(await headerPoint(), -200, -100); + const lifted = await readPanel(); + assert.equal(lifted.floating, true, 'a header drag lifts the panel out'); + assert.equal( + lifted.collapsed, + false, + 'the drag-ending click must not collapse', + ); + near(lifted.left, Math.max(6, docked.left - 200), 2, 'lifted left'); + near(lifted.top, Math.max(6, docked.top - 100), 2, 'lifted top'); + near(lifted.width, docked.width, 2, 'lifted width'); + await new Promise((resolve) => setTimeout(resolve, 700)); + const afterLayout = await readPanel(); + assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it'); + const railExcludes = await page.evaluate((id) => { + const rail = document.getElementById('right-context-rail'); + const counted = [...rail.children].filter((panel) => + panel.matches('[data-panel-id]:not(.panel-floating):not(.collapsed)'), + ); + return { + expandedCount: rail.dataset.expandedCount, + counted: counted.length, + includesPanel: counted.some((panel) => panel.id === id), + }; + }, PANEL_ID); + assert.equal(railExcludes.includesPanel, false); + if (railExcludes.expandedCount !== undefined) + assert.equal(Number(railExcludes.expandedCount), railExcludes.counted); + const storedAfterLift = JSON.parse(afterLayout.stored); + assert.equal(storedAfterLift.floating, true); + assert.equal( + 'height' in storedAfterLift, + false, + 'a drag alone must not freeze the measured height', + ); + console.log('PASS: header drag lifts CCTV out of the rail'); + + // Every handle moves only its own edges. + const limits = { + minWidth: MIN_SIZE.width, + minHeight: MIN_SIZE.height, + viewportWidth: VIEWPORT.width, + viewportHeight: VIEWPORT.height, + }; + for (const dir of RESIZE_DIRECTIONS) { + const before = await readPanel(); + const { dx, dy } = growthDelta(dir, 40); + await drag(handlePoint(before, dir), 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}`); + assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`); + } + console.log('PASS: eight resize handles keep the opposite edge pinned'); + + // Minimum size stops the moving edge. + let before = await readPanel(); + await drag(handlePoint(before, 'se'), -2000, -2000); + let after = await readPanel(); + near(after.width, MIN_SIZE.width, 2, 'min width (se)'); + near(after.height, MIN_SIZE.height, 2, 'min height (se)'); + assert.deepEqual(driftedEdges(before, after, 'se', 2), []); + before = after; + await drag(handlePoint(before, 'nw'), 2000, 2000); + after = await readPanel(); + near(after.width, MIN_SIZE.width, 2, 'min width (nw)'); + near(after.height, MIN_SIZE.height, 2, 'min height (nw)'); + assert.deepEqual(driftedEdges(before, after, 'nw', 2), []); + console.log('PASS: minimum size holds from both corners'); + + // Reload restores the window. + const saved = await readPanel(); + const record = JSON.parse(saved.stored); + assert.equal(record.floating, true); + await boot(); + const restored = await readPanel(); + assert.equal(restored.floating, true, 'floating survives a reload'); + for (const key of ['left', 'top', 'width', 'height']) + near(restored[key], record[key], 2, `restored ${key}`); + console.log('PASS: reload restores position and size'); + + // Double-click the header to snap back. + const point = await headerPoint(); + 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.inRail, true); + assert.equal(snapped.inlineWidth, ''); + assert.equal(snapped.inlineHeight, ''); + assert.equal(snapped.stored, null, 'the storage key is gone'); + console.log('PASS: header double-click returns the panel to the rail'); + + assert.deepEqual(errors, []); + } finally { + await browser.close(); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); + }); +} diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index 2254ea24e..b727e6d12 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -45,7 +45,11 @@ test('desktop Display participates in Tactical exclusivity without changing mobi /panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/, ); assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); - assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); + // A panel floating out of the rail (portable mode) is exempt from exclusivity. + assert.match( + css, + /#right-context-rail\.layout-exclusive\s*> \[data-panel-id\]\.collapsed:not\(\.panel-floating\) \{/, + ); }); test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => { diff --git a/src/tooling/panelResizeQa.test.mjs b/src/tooling/panelResizeQa.test.mjs new file mode 100644 index 000000000..586f22c08 --- /dev/null +++ b/src/tooling/panelResizeQa.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + driftedEdges, + growthDelta, + handlePoint, + MIN_SIZE, + pinnedEdges, + STORAGE_KEY, +} from '../../scripts/qa-panel-resize.mjs'; +import { RESIZE_DIRECTIONS } from '../ui/panelResize.js'; + +test('the harness targets the key and minimums the controls use', () => { + assert.equal(STORAGE_KEY, 'godsEyeView.v8.panelPos.cctv-panel'); + assert.deepEqual(MIN_SIZE, { width: 300, height: 160 }); +}); + +test('pinned edges are exactly the sides a direction does not name', () => { + assert.deepEqual(pinnedEdges('n'), ['bottom', 'right', 'left']); + assert.deepEqual(pinnedEdges('se'), ['top', 'left']); + assert.deepEqual(pinnedEdges('nw'), ['bottom', 'right']); + for (const dir of RESIZE_DIRECTIONS) + assert.equal(pinnedEdges(dir).length, 4 - dir.length); +}); + +test('growth deltas push each named edge outward', () => { + assert.deepEqual(growthDelta('n', 40), { dx: 0, dy: -40 }); + assert.deepEqual(growthDelta('e', 40), { dx: 40, dy: 0 }); + assert.deepEqual(growthDelta('sw', 40), { dx: -40, dy: 40 }); + assert.deepEqual(growthDelta('se', 40), { dx: 40, dy: 40 }); +}); + +test('handle points land inside the strip or corner for every direction', () => { + const rect = { left: 100, top: 200, width: 400, height: 300 }; + rect.right = rect.left + rect.width; + rect.bottom = rect.top + rect.height; + assert.deepEqual(handlePoint(rect, 'n'), { x: 300, y: 201 }); + assert.deepEqual(handlePoint(rect, 'w'), { x: 101, y: 350 }); + assert.deepEqual(handlePoint(rect, 'se'), { x: 496, y: 496 }); + assert.deepEqual(handlePoint(rect, 'nw'), { x: 104, y: 204 }); + for (const dir of RESIZE_DIRECTIONS) { + const { x, y } = handlePoint(rect, dir); + assert.ok(x >= rect.left && x <= rect.right, `${dir} x inside`); + assert.ok(y >= rect.top && y <= rect.bottom, `${dir} y inside`); + } +}); + +test('drift detection reports only pinned edges that moved', () => { + const before = { left: 100, top: 100, width: 400, height: 300 }; + const grewEast = { left: 100, top: 100, width: 440, height: 300 }; + assert.deepEqual(driftedEdges(before, grewEast, 'e'), []); + const slidWest = { left: 60, top: 100, width: 440, height: 300 }; + assert.deepEqual(driftedEdges(before, slidWest, 'e'), ['left']); + assert.deepEqual(driftedEdges(before, slidWest, 'w'), []); + const wobble = { left: 101, top: 100, width: 439, height: 300 }; + assert.deepEqual(driftedEdges(before, wobble, 'e'), []); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 2337264da..62b2322b6 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -105,6 +105,7 @@ export class StyleManager extends ShellFacade { _setRadioDisclosure: (...args) => this._setRadioDisclosure(...args), _syncCctvPanelViewport: (...args) => this._syncCctvPanelViewport(...args), + _onPanelResized: (...args) => this._onPanelResized(...args), _syncContextRadioLauncherState: (...args) => this._syncContextRadioLauncherState(...args), _showToast: (...args) => this._showToast(...args), @@ -1495,6 +1496,15 @@ export class StyleManager extends ShellFacade { this._initCockpitDisplayPortal(); } + /** + * A portable panel finished a resize or snapped back to its rail. Nothing + * in the shell depends on it yet; a panel with a viewport of its own can + * refit here. + * @param {string} _panelId + * @returns {void} + */ + _onPanelResized(_panelId) {} + /** * Recalculates the CCTV panel max-height based on its current top position * and the window height, enabling internal scroll without viewport overflow. diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 5a671e453..49b65de3d 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -63,6 +63,7 @@ export class PanelChrome { layoutRightPanels: () => this._layoutRightPanels(), syncCctvPanelViewport: () => this._syncCctvPanelViewport(), showToast: (message) => this._showToast(message), + onPanelResized: (panelId) => this._onPanelResized(panelId), }); this._panelLayout = new PanelLayoutController({ readHud: () => ({ @@ -146,6 +147,7 @@ export class PanelChrome { this._initCommandDockPins(); this._initCommandDockTrayMetrics(); this._maybeNotifyLayoutReset(); + this._panelPosition._initPanelDrag(); } _collapsePanelOnEscape(event, panelId) { diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index 812122abe..f0473e50d 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -1,4 +1,6 @@ /** Own panel position preferences, viewport clamping and drag listeners. */ +import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js'; + /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; /** @@ -10,40 +12,93 @@ 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; +/** Pointer travel before a header press becomes a drag that lifts a portable panel out. */ +const DRAG_THRESHOLD_PX = 4; +/** 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. */ +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"]'; +/** Inline geometry a snap-back clears so the rail owns placement again. */ +const FLOATING_STYLE_PROPERTIES = [ + 'left', + 'top', + 'right', + 'bottom', + 'width', + 'height', +]; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + export class PanelPositionControls { + /** + * @param {object} options + * @param {Function} options.syncPanelCollapseButton + * @param {Function} options.layoutRightPanels + * @param {Function} options.syncCctvPanelViewport + * @param {Function} options.showToast + * @param {(panelId: string) => void} [options.onPanelResized] Fires after a + * portable panel's resize gesture ends or the panel snaps back to its rail. + */ 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; + /** Portable panels by id: `{ panel, min: { width, height } }`. */ + 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); + } + } + // 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`; + } } _maybeNotifyLayoutReset() { @@ -65,19 +120,41 @@ export class PanelPositionControls { } _initPanelDrag() { - if (this.destroyed) return; + if (this.destroyed || this._dragInitialized) return; + this._dragInitialized = true; + const cctvPanel = document.getElementById('cctv-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. 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 }, + }, ].filter(Boolean); for (const spec of dragSpecs) { if (!spec.panel || !spec.handle) continue; + if (spec.portable) { + this._portablePanels.set(spec.id, { + panel: spec.panel, + min: { width: 300, height: 160, ...spec.min }, + }); + 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 @@ -88,6 +165,9 @@ export class PanelPositionControls { ); this._draggableResizeObserver.observe(this._ppToggles); } + if (this._portablePanels.size) { + this.listen(window, 'resize', () => this._reclampDraggablePanels()); + } } _panelStorageKey(panelId) { @@ -159,6 +239,27 @@ export class PanelPositionControls { const pos = JSON.parse(raw); if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') return; + // A floating record only means something to a panel that can float; a + // portable panel in turn only ever stores floating records. + const portable = this._portablePanels.get(panelId); + if (Boolean(pos.floating) !== Boolean(portable)) return; + if (portable) { + panelEl.classList.add('panel-floating', 'panel-draggable'); + if (Number.isFinite(pos.width)) { + panelEl.style.width = `${clamp( + Math.round(pos.width), + portable.min.width, + Math.max(portable.min.width, window.innerWidth - 12), + )}px`; + } + if (Number.isFinite(pos.height)) { + panelEl.style.height = `${clamp( + Math.round(pos.height), + portable.min.height, + Math.max(portable.min.height, window.innerHeight - 12), + )}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( @@ -190,13 +291,21 @@ 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: freezing a collapsed + // panel's measured height would stop it from ever expanding again. + if (panelEl.style.width) record.width = Math.round(rect.width); + if (panelEl.style.height) record.height = Math.round(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 @@ -219,11 +328,78 @@ 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 worth saying once this session + if (this._floatHintShown) return; + this._floatHintShown = true; + } + this._showToast(PANEL_FLOAT_HINT); + } + + /** + * Switch a portable panel from rail flow to a fixed window at the place it + * currently occupies. Width is frozen so a `width: 100%` rail panel keeps + * its size once fixed-positioned; height stays natural until a resize. + */ + _liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) { + if (panelEl.classList.contains('panel-floating')) return false; + panelEl.style.left = `${Math.round(rect.left)}px`; + panelEl.style.top = `${Math.round(rect.top)}px`; + panelEl.style.right = 'auto'; + panelEl.style.bottom = 'auto'; + if (!panelEl.classList.contains('collapsed')) { + panelEl.style.width = `${Math.round(rect.width)}px`; + } + // Rail bookkeeping no longer applies to a window the rail does not allocate. + 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(); + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + 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; @@ -291,11 +467,199 @@ export class PanelPositionControls { window.addEventListener('pointercancel', onUp); }); } + + /** + * 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 + * collapse button still toggles on a plain click, and the click that ends + * a real drag is swallowed so it cannot toggle by accident. Double-clicking + * the header snaps the panel back. + */ + _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); + }); + 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; + + 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; + let offsetX = 0; + let offsetY = 0; + let width = 0; + let height = 0; + + const onMove = (moveEvent) => { + if (!dragging) { + if ( + Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < + DRAG_THRESHOLD_PX + ) + return; + dragging = true; + const rect = panelEl.getBoundingClientRect(); + offsetX = startX - rect.left; + offsetY = startY - rect.top; + width = rect.width; + height = rect.height; + 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', onUp); + this._cancelDrag = null; + }; + const onUp = () => { + cancel(); + if (!dragging) return; + swallowNextClick = true; + setTimeout(() => { + swallowNextClick = false; + }, 0); + 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); + }); + } + + /** + * Append a resize handle for every edge and corner. Seven are invisible + * strips; the bottom-right corner shows a grip. Resizing a docked panel + * lifts it out first, so the 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) return; + event.preventDefault(); + this._cancelDrag?.(); + const rect = panelEl.getBoundingClientRect(); + this._liftPanelOut(panelId, panelEl, rect); + const startBox = { + left: Math.round(rect.left), + top: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + const startX = event.clientX; + const startY = event.clientY; + 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`; + }; + panelEl.classList.add('panel-resizing'); + this._promotePanelZ(panelEl); + apply(startBox); + + const onMove = (moveEvent) => { + moveEvent.preventDefault(); + apply( + resizeBox( + startBox, + dir, + moveEvent.clientX - startX, + moveEvent.clientY - startY, + { + 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', onUp); + this._cancelDrag = null; + }; + const onUp = () => { + cancel(); + this._savePanelPosition(panelId, panelEl); + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + this._onPanelResized?.(panelId); + }; + + 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(); + for (const handle of this._resizeHandles.splice(0)) handle.remove(); this._draggableResizeObserver?.disconnect(); this._draggableResizeObserver = null; } diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 782807c17..842cd041c 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -113,7 +113,10 @@ function element( getBoundingClientRect() { return this.rect; }, - matches: (selector) => selector === '[data-panel-id]', + matches: (selector) => + selector === '[data-panel-id]' || + (selector === '[data-panel-id]:not(.panel-floating)' && + !classes.has('panel-floating')), contains(target) { return ( target === this || this.children.some((child) => child.contains(target)) @@ -322,6 +325,28 @@ test('right layout retains Display allocation during measurement and caps restor ); }); +test('right rail neither measures nor allocates a panel floating out of it', () => { + const f = fixture('right'); + f.expand(f.first, 900); + f.expand(f.second, 300); + f.first.classList.add('panel-floating'); + f.first.style.setProperty('--right-panel-allocated-height', '500px'); + f.first.writes.length = 0; + f.run(); + assert.equal(f.stack.dataset.expandedCount, '1'); + assert.deepEqual( + f.first.writes, + [], + 'a floating panel keeps whatever inline style it left the rail with', + ); + assert.equal(f.first.getAttribute('aria-hidden'), undefined); + assert.equal(f.first.classList.contains('layout-auto-collapsed'), false); + assert.ok( + f.second.style.getPropertyValue('--right-panel-allocated-height'), + 'the docked panel still receives its allocation', + ); +}); + test('right rail aligns to the current left rail and excludes hidden obstacles', () => { const f = fixture('right'); f.options.leftStack.rect.top = 200; diff --git a/src/ui/panelResize.js b/src/ui/panelResize.js new file mode 100644 index 000000000..4472e7a01 --- /dev/null +++ b/src/ui/panelResize.js @@ -0,0 +1,65 @@ +/** + * Pure geometry for resizing a floating panel from any edge or corner. The + * DOM wiring lives in PanelPositionControls; this module only decides boxes. + */ + +/** Viewport margin a resized panel keeps clear on every side. */ +const EDGE_MARGIN_PX = 6; +/** Resize directions: compass edges first, then corners. */ +export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se']; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +/** + * Work out the box a resize gesture produces. Edges named in `dir` move with + * the pointer; the opposite edge stays put, so growing from the left keeps + * the right side pinned. Minimum size and the viewport margin win over the + * pointer. + * @param {{left: number, top: number, width: number, height: number}} box Box at gesture start. + * @param {string} dir One of RESIZE_DIRECTIONS. + * @param {number} dx Pointer travel since gesture start. + * @param {number} dy Pointer travel since gesture start. + * @param {object} [limits] + * @param {number} limits.minWidth + * @param {number} limits.minHeight + * @param {number} [limits.viewportWidth=Infinity] + * @param {number} [limits.viewportHeight=Infinity] + * @param {number} [limits.margin=6] + * @returns {{left: number, top: number, width: number, height: number}} + */ +export function resizeBox( + box, + dir, + dx, + dy, + { + minWidth, + minHeight, + viewportWidth = Infinity, + viewportHeight = Infinity, + margin = EDGE_MARGIN_PX, + } = {}, +) { + let { left, top, width, height } = box; + const right = box.left + box.width; + const bottom = box.top + box.height; + if (dir.includes('e')) + width = clamp(box.width + dx, minWidth, viewportWidth - margin - box.left); + if (dir.includes('s')) + height = clamp( + box.height + dy, + minHeight, + viewportHeight - margin - box.top, + ); + if (dir.includes('w')) { + width = clamp(box.width - dx, minWidth, right - margin); + left = right - width; + } + if (dir.includes('n')) { + height = clamp(box.height - dy, minHeight, bottom - margin); + top = bottom - height; + } + return { left, top, width, height }; +} diff --git a/src/ui/panelResize.test.mjs b/src/ui/panelResize.test.mjs new file mode 100644 index 000000000..3e1577665 --- /dev/null +++ b/src/ui/panelResize.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resizeBox } from './panelResize.js'; + +const box = { left: 100, top: 100, width: 400, height: 300 }; +const limits = { + minWidth: 200, + minHeight: 150, + viewportWidth: 1000, + viewportHeight: 800, +}; + +test('east and south edges grow away from the anchored corner', () => { + assert.deepEqual(resizeBox(box, 'se', 50, 20, limits), { + left: 100, + top: 100, + width: 450, + height: 320, + }); + assert.deepEqual(resizeBox(box, 'e', -30, 999, limits), { + left: 100, + top: 100, + width: 370, + height: 300, + }); +}); + +test('west and north edges keep the opposite edge pinned', () => { + assert.deepEqual(resizeBox(box, 'w', -40, 0, limits), { + left: 60, + top: 100, + width: 440, + height: 300, + }); + assert.deepEqual(resizeBox(box, 'nw', 40, 30, limits), { + left: 140, + top: 130, + width: 360, + height: 270, + }); + assert.deepEqual( + resizeBox(box, 'n', -50, 0, limits).top, + 100, + 'horizontal motion does not move a north edge', + ); +}); + +test('minimum size stops the moving edge, not the anchored one', () => { + const small = resizeBox(box, 'w', 350, 0, limits); + assert.deepEqual(small, { left: 300, top: 100, width: 200, height: 300 }); + assert.equal(resizeBox(box, 'ne', 0, 400, limits).height, 150); + assert.equal(resizeBox(box, 'ne', 0, 400, limits).top, 250); +}); + +test('the viewport margin caps growth in every direction', () => { + assert.equal(resizeBox(box, 'e', 2000, 0, limits).width, 1000 - 6 - 100); + assert.equal(resizeBox(box, 's', 0, 2000, limits).height, 800 - 6 - 100); + const west = resizeBox(box, 'w', -2000, 0, limits); + assert.equal(west.left, 6); + assert.equal(west.left + west.width, 500); + const north = resizeBox(box, 'n', 0, -2000, limits); + assert.equal(north.top, 6); + assert.equal(north.top + north.height, 400); +}); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 00bcffcda..45e55a73e 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -47,8 +47,11 @@ export function layoutRightPanelRail({ // A collapse may schedule one follow-up, which only measures and allocates. const isCollapseRetry = pendingCollapseRetries.delete(stack); + // A panel lifted out of the rail (PanelPositionControls' portable mode) + // is a fixed window the rail neither measures nor allocates. const panels = [...stack.children].filter( - (panel) => panel.matches('[data-panel-id]') && !panel.hidden, + (panel) => + panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden, ); if (!hud.visible || hud.variant !== 'tactical') { for (const panel of panels.filter((item) => diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 78932fc3b..98a9f10d3 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -103,6 +103,116 @@ opacity: 0.96; } +/* ── Portable rail panels ──────────────────── + A header drag lifts a portable panel (PanelPositionControls) out of its + rail into a fixed window that remembers its place and size. The rail's + own placement rules in layers.css are guarded with :not(.panel-floating), + so these only have to beat a panel's base placement (one id). */ +.panel-portable .panel-header { + cursor: grab; +} +.panel-portable.panel-dragging .panel-header { + cursor: grabbing; +} +#right-context-rail > .panel-floating { + position: fixed; + inset: auto; + max-height: calc(100vh - 12px); + margin: 0; + flex: none; + pointer-events: auto; +} +/* A collapsed window keeps its stored height for later but shows only its + header strip, so the resize handles have nothing to attach to. */ +#right-context-rail > .panel-floating.collapsed { + max-height: max-content; +} +.panel-floating[style*='height'] > .cctv-panel-inner { + box-sizing: border-box; + height: 100%; + max-height: none; +} +.panel-resize-edge, +.panel-resize-grip { + position: absolute; + z-index: 3; + touch-action: none; +} +.panel-resize-edge[data-dir='n'], +.panel-resize-edge[data-dir='s'] { + left: 10px; + right: 10px; + height: 7px; + cursor: ns-resize; +} +.panel-resize-edge[data-dir='n'] { + top: -3px; +} +.panel-resize-edge[data-dir='s'] { + bottom: -3px; +} +.panel-resize-edge[data-dir='e'], +.panel-resize-edge[data-dir='w'] { + top: 10px; + bottom: 10px; + width: 7px; + cursor: ew-resize; +} +.panel-resize-edge[data-dir='e'] { + right: -3px; +} +.panel-resize-edge[data-dir='w'] { + left: -3px; +} +.panel-resize-edge[data-dir='ne'], +.panel-resize-edge[data-dir='nw'], +.panel-resize-edge[data-dir='sw'] { + width: 14px; + height: 14px; +} +.panel-resize-edge[data-dir='ne'] { + top: -3px; + right: -3px; + cursor: nesw-resize; +} +.panel-resize-edge[data-dir='nw'] { + top: -3px; + left: -3px; + cursor: nwse-resize; +} +.panel-resize-edge[data-dir='sw'] { + bottom: -3px; + left: -3px; + cursor: nesw-resize; +} +.panel-resize-grip { + right: 3px; + bottom: 3px; + width: 14px; + height: 14px; + cursor: nwse-resize; + opacity: 0.55; + clip-path: polygon(100% 0, 100% 100%, 0 100%); + background: repeating-linear-gradient( + 135deg, + transparent 0 3px, + rgba(0, 212, 255, 0.6) 3px 4px + ); + transition: opacity var(--transition-fast); +} +.panel-portable:hover .panel-resize-grip, +.panel-resizing .panel-resize-grip { + opacity: 1; +} +.panel-collapsible.collapsed > .panel-resize-edge, +.panel-collapsible.collapsed > .panel-resize-grip { + display: none; +} +.panel-resizing, +.panel-resizing * { + user-select: none; +} + .panel-title { font-family: var(--font-mono); font-size: 9px; diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index ba9bfbc68..9b2534054 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -161,8 +161,8 @@ max-height var(--transition-fast); } #right-context-rail > #pp-toggles, -#right-context-rail > #cctv-panel, -#right-context-rail > #street-level-panel, +#right-context-rail > #cctv-panel:not(.panel-floating), +#right-context-rail > #street-level-panel:not(.panel-floating), #right-context-rail > #weather-panel, #right-context-rail > #recent-imagery-panel, #right-context-rail > #global-context-panel { @@ -176,8 +176,8 @@ max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel, -#right-context-rail #street-level-panel, +#right-context-rail #cctv-panel:not(.panel-floating), +#right-context-rail #street-level-panel:not(.panel-floating), #right-context-rail #weather-panel, #right-context-rail #recent-imagery-panel { width: 100%; @@ -192,8 +192,8 @@ flex-shrink: 1; min-height: 0; } -#right-context-rail #cctv-panel.collapsed, -#right-context-rail #street-level-panel.collapsed, +#right-context-rail #cctv-panel.collapsed:not(.panel-floating), +#right-context-rail #street-level-panel.collapsed:not(.panel-floating), #right-context-rail #weather-panel.collapsed, #right-context-rail #recent-imagery-panel.collapsed { width: var(--right-display-collapsed-width); @@ -257,19 +257,24 @@ #right-context-rail.layout-focus { height: var(--right-stack-max-height); } -#right-context-rail.layout-exclusive > [data-panel-id].collapsed { +#right-context-rail.layout-exclusive + > [data-panel-id].collapsed:not(.panel-floating) { display: none; } -#right-context-rail.layout-focus > [data-panel-id].collapsed { +#right-context-rail.layout-focus + > [data-panel-id].collapsed:not(.panel-floating) { display: block; flex: 0 0 auto; } -#right-context-rail.layout-focus.layout-exclusive > [data-panel-id].collapsed { +#right-context-rail.layout-focus.layout-exclusive + > [data-panel-id].collapsed:not(.panel-floating) { display: none; } #right-context-rail.layout-focus > #pp-toggles:not(.collapsed), -#right-context-rail.layout-focus > #cctv-panel:not(.collapsed), -#right-context-rail.layout-focus > #street-level-panel:not(.collapsed), +#right-context-rail.layout-focus + > #cctv-panel:not(.collapsed):not(.panel-floating), +#right-context-rail.layout-focus + > #street-level-panel:not(.collapsed):not(.panel-floating), #right-context-rail.layout-focus > #weather-panel:not(.collapsed), #right-context-rail.layout-focus > #recent-imagery-panel:not(.collapsed), #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) { @@ -288,10 +293,10 @@ > #weather-panel:not(.collapsed) .weather-panel-inner, #right-context-rail.layout-focus - > #cctv-panel:not(.collapsed) + > #cctv-panel:not(.collapsed):not(.panel-floating) .cctv-panel-inner, #right-context-rail.layout-focus - > #street-level-panel:not(.collapsed) + > #street-level-panel:not(.collapsed):not(.panel-floating) .street-level-panel-inner, #right-context-rail.layout-focus > #recent-imagery-panel:not(.collapsed) diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index ab84bbff4..4faccd150 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -216,10 +216,10 @@ } #right-context-rail #pp-toggles.collapsed .pp-header-row, - #right-context-rail #cctv-panel, - #right-context-rail #street-level-panel, - #right-context-rail #cctv-panel.collapsed, - #right-context-rail #street-level-panel.collapsed, + #right-context-rail #cctv-panel:not(.panel-floating), + #right-context-rail #cctv-panel.collapsed:not(.panel-floating), + #right-context-rail #street-level-panel:not(.panel-floating), + #right-context-rail #street-level-panel.collapsed:not(.panel-floating), #right-context-rail #recent-imagery-panel, #right-context-rail #recent-imagery-panel.collapsed, #right-context-rail #radio-panel,