From 96a1e710eab7a7bfa6ba26589740cca8c6b6c592 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:06:29 +0100 Subject: [PATCH] feat(ui): portable, resizable rail panels for CCTV A drag spec can now be `portable`: dragging the panel by its header lifts it out of the right rail into a fixed window, resizable from all four edges and corners, that remembers its place and size under the existing godsEyeView.v8.panelPos. key ({ left, top, width?, height?, floating }) and snaps back on a header double-click. Only CCTV opts in here; the rail skips .panel-floating panels when measuring and allocating, and its placement rules in layers.css are guarded so no !important is needed. PanelPositionControls gains an optional onPanelResized hook, threaded through PanelChrome to a no-op on the application shell. Co-Authored-By: Claude Fable 5.1 --- src/rightRailPolicy.test.mjs | 6 +- src/ui/applicationShell.js | 10 + src/ui/panelChrome.js | 2 + src/ui/panelPositionControls.js | 390 ++++++++++++++++++++++++++++++-- src/ui/panelRails.test.mjs | 5 +- src/ui/rightPanelRail.js | 4 +- src/ui/styles/controls.css | 110 +++++++++ src/ui/styles/layers.css | 20 +- src/ui/styles/responsive.css | 4 +- 9 files changed, 525 insertions(+), 26 deletions(-) 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/ui/applicationShell.js b/src/ui/applicationShell.js index b22c21c3f..3a01f97fe 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -96,6 +96,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), @@ -1409,6 +1410,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 2562f137b..66e469a45 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -56,6 +56,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: () => ({ @@ -139,6 +140,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 40d1c319f..ca85593a5 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) { @@ -151,6 +231,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( @@ -182,13 +283,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 @@ -211,11 +320,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; @@ -283,11 +459,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 46fbebc5f..2b03c1406 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -59,7 +59,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)) diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 3dd513d9f..1c324a523 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -41,8 +41,10 @@ export function layoutRightPanelRail({ }) { if (!stack) return; + // 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.matches('[data-panel-id]:not(.panel-floating)'), ); 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 c86be98f1..d2df76850 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 72207fa52..79ad8c268 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -161,7 +161,7 @@ max-height var(--transition-fast); } #right-context-rail > #pp-toggles, -#right-context-rail > #cctv-panel, +#right-context-rail > #cctv-panel:not(.panel-floating), #right-context-rail > #global-context-panel { position: relative; inset: auto; @@ -173,12 +173,12 @@ max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel { +#right-context-rail #cctv-panel:not(.panel-floating) { width: 100%; max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel.collapsed { +#right-context-rail #cctv-panel.collapsed:not(.panel-floating) { width: var(--right-display-collapsed-width); margin-left: auto; } @@ -234,18 +234,22 @@ #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 + > #cctv-panel:not(.collapsed):not(.panel-floating), #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) { flex: 0 1 var(--right-panel-allocated-height); height: var(--right-panel-allocated-height); @@ -259,7 +263,7 @@ scrollbar-color: rgba(0, 212, 255, 0.4) transparent; } #right-context-rail.layout-focus - > #cctv-panel:not(.collapsed) + > #cctv-panel:not(.collapsed):not(.panel-floating) .cctv-panel-inner, #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 7ebdaadae..38fe8efb3 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -202,8 +202,8 @@ } #right-context-rail #pp-toggles.collapsed .pp-header-row, - #right-context-rail #cctv-panel, - #right-context-rail #cctv-panel.collapsed, + #right-context-rail #cctv-panel:not(.panel-floating), + #right-context-rail #cctv-panel.collapsed:not(.panel-floating), #right-context-rail #radio-panel, #right-context-rail #radio-panel.collapsed, #right-context-rail #global-context-panel,