diff --git a/src/ui/cyberPanelDisclosure.test.mjs b/src/ui/cyberPanelDisclosure.test.mjs index ae0256d4c..911e2cab1 100644 --- a/src/ui/cyberPanelDisclosure.test.mjs +++ b/src/ui/cyberPanelDisclosure.test.mjs @@ -85,3 +85,16 @@ test('non-Cyber explicit panels and restored preferences retain their existing p } finally { globalThis.document = prior; } } }); + +test('opening a floating Cyber window leaves the docked rail panels open', () => { + const f = fixture(), prior = globalThis.document; + globalThis.document = f.doc; + try { + f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true }); + f.cctv.classList.add('panel-floating'); + f.owner.setPanelCollapsed('cctv-panel', false, { explicit: true }); + assert.equal(f.cctv.classList.contains('collapsed'), false); + assert.equal(f.display.classList.contains('collapsed'), false, 'DISPLAY stays open'); + assert.ok(!f.saved.some(([id, collapsed]) => id === 'pp-toggles' && collapsed)); + } finally { globalThis.document = prior; } +}); diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 33ae6fa56..8dad85649 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -419,12 +419,15 @@ export class PanelChrome { panelEl.classList.contains('cyber-accordion-collapsed'); // A user opening a Cyber rail panel owns the whole accordion, including // peers that were only presentation-collapsed during a saved-state restore. + // A floating window is outside the accordion both ways: opening one + // leaves the rail's panels as they are. if ( explicit && !restore && !nextCollapsed && document.documentElement?.dataset.uiTheme === 'cyber' && - panelEl.parentElement === this._rightPanelStack + panelEl.parentElement === this._rightPanelStack && + !panelEl.classList.contains('panel-floating') ) { for (const peer of this._rightPanelStack.children) { if ( diff --git a/src/ui/panelDock.test.mjs b/src/ui/panelDock.test.mjs index 0b4291d55..f1212fe77 100644 --- a/src/ui/panelDock.test.mjs +++ b/src/ui/panelDock.test.mjs @@ -5,10 +5,9 @@ import { PanelPositionControls } from './panelPositionControls.js'; /** Just enough DOM for PanelPositionControls' portable-panel paths. */ function fixture() { const saved = Object.fromEntries( - ['document', 'window', 'localStorage', 'performance'].map((key) => [ - key, - globalThis[key], - ]), + ['document', 'window', 'localStorage', 'performance', 'ResizeObserver'].map( + (key) => [key, globalThis[key]], + ), ); const store = new Map(); const element = () => { @@ -359,3 +358,186 @@ test('a pointercancel while dragging a floating window returns it to where it wa f.restore(); } }); + +test('a header press that travels under the drag threshold is a click, not a lift', () => { + const f = fixture(); + try { + const { panel, handle } = dockedPanel(f, 'street-level-panel'); + pointer(handle, 'pointerdown', { x: 200, y: 90 }); + pointer(window, 'pointermove', { x: 202, y: 90 }); + pointer(window, 'pointerup', { x: 202, y: 90 }); + assert.equal(panel.classList.contains('panel-floating'), false); + assert.equal(panel.classList.contains('panel-dragging'), false); + assert.equal(f.store.has(POSITION_KEY), false); + assert.equal(f.calls.layout, 0, 'the rail was not re-laid out'); + assert.deepEqual(f.calls.resized, []); + } finally { + f.restore(); + } +}); + +/** + * Both portable panels wired through _initPanelDrag. Each box follows its + * inline left/top/width/height like a fixed window (`box` holds the natural + * size), its header is the top 36 px, and `obstacles` stand in for the + * command dock and voice pill. ResizeObserver callbacks run on `observe()`. + */ +function portableShell(f, { stored = {}, box = {}, obstacles = {} } = {}) { + const observers = []; + globalThis.ResizeObserver = class { + constructor(callback) { + this.callback = callback; + this.targets = []; + observers.push(this); + } + observe(target) { + this.targets.push(target); + } + disconnect() {} + }; + const nodes = new Map(); + const panels = {}; + for (const id of ['cctv-panel', 'street-level-panel']) { + const panel = f.element(); + const handle = f.element(); + const natural = { width: 360, height: 420, ...box[id] }; + panel.id = id; + panel.querySelector = (selector) => + selector === '.panel-header' ? handle : null; + panel.getBoundingClientRect = () => { + const left = parseFloat(panel.style.left) || 0; + const top = parseFloat(panel.style.top) || 0; + const width = parseFloat(panel.style.width) || natural.width; + const height = parseFloat(panel.style.height) || natural.height; + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }; + }; + handle.getBoundingClientRect = () => { + const rect = panel.getBoundingClientRect(); + return { ...rect, height: 36, bottom: rect.top + 36 }; + }; + if (stored[id]) + f.store.set( + `godsEyeView.v8.panelPos.${id}`, + JSON.stringify({ floating: true, ...stored[id] }), + ); + nodes.set(id, panel); + panels[id] = { panel, handle, natural }; + } + const obstacle = (id, rect) => + nodes.set(id, { + getBoundingClientRect: () => ({ + ...rect, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + }), + }); + for (const [id, rect] of Object.entries(obstacles)) obstacle(id, rect); + globalThis.document.getElementById = (id) => nodes.get(id) ?? null; + f.owner._initPanelDrag(); + return { + ...panels, + obstacle, + observed: (node) => observers.some(({ targets }) => targets.includes(node)), + contentResized: () => observers.forEach(({ callback }) => callback([])), + }; +} + +test('a floating window whose content grows is pulled back on-screen', () => { + const f = fixture(); + try { + const shell = portableShell(f, { + stored: { 'cctv-panel': { left: 900, top: 380 } }, + }); + const { panel, natural } = shell['cctv-panel']; + assert.equal(panel.style.top, '380px'); + assert.equal(shell.observed(panel), true, 'its size is watched'); + assert.equal(shell.observed(shell['street-level-panel'].panel), true); + // Expanding the collapsed strip, or an image opening in Street Level. + natural.height = 800; + shell.contentResized(); + assert.equal(panel.style.top, `${900 - 800 - 6}px`); + assert.equal(panel.style.left, '900px'); + } finally { + f.restore(); + } +}); + +test('a live window resize shrinks a floating window larger than the viewport', () => { + const f = fixture(); + try { + const shell = portableShell(f, { + stored: { + 'cctv-panel': { left: 100, top: 80, width: 1300, height: 700 }, + }, + }); + const { panel } = shell['cctv-panel']; + assert.equal(panel.style.width, '1300px'); + Object.assign(window, { innerWidth: 1000, innerHeight: 500 }); + window.dispatchEvent(new Event('resize')); + assert.equal(panel.style.width, '988px'); + assert.equal(panel.style.height, '488px'); + assert.equal(panel.style.left, '6px'); + assert.equal(panel.style.top, '6px'); + // A window that already fits keeps the size the user gave it. + Object.assign(window, { innerWidth: 1440, innerHeight: 900 }); + window.dispatchEvent(new Event('resize')); + assert.equal(panel.style.width, '988px'); + } finally { + f.restore(); + } +}); + +const DOCK = { left: 300, top: 820, right: 1140, bottom: 882 }; + +test('a floating header never lands under the command dock or voice pill', () => { + const f = fixture(); + try { + const shell = portableShell(f, { + stored: { 'cctv-panel': { left: 100, top: 80 } }, + // A collapsed CCTV window is just its header strip. + box: { 'cctv-panel': { height: 36 } }, + obstacles: { 'command-dock': DOCK }, + }); + const { panel, handle, natural } = shell['cctv-panel']; + const above = DOCK.top - 36 - 6; + + // Drag: the strip dropped at the bottom centre stops above the dock. + pointer(handle, 'pointerdown', { x: 200, y: 90 }); + pointer(window, 'pointermove', { x: 720, y: 880 }); + assert.equal(panel.style.top, `${above}px`); + pointer(window, 'pointerup', { x: 720, y: 880 }); + const key = 'godsEyeView.v8.panelPos.cctv-panel'; + assert.equal(JSON.parse(f.store.get(key)).top, above); + + // Restore: a place saved under the dock (older build) comes back clear. + f.store.set(key, JSON.stringify({ left: 620, top: 858, floating: true })); + f.owner._restorePanelPosition('cctv-panel', panel); + assert.equal(panel.style.top, `${above}px`); + + // Re-clamp: the voice pill measured when the window resizes. + Object.assign(panel.style, { left: '1000px', top: '700px' }); + shell.obstacle('gev-voice-control', { + left: 1190, + top: 690, + right: 1404, + bottom: 740, + }); + window.dispatchEvent(new Event('resize')); + assert.equal(panel.style.top, `${690 - 36 - 6}px`); + + // Only the header has to stay clear: a body over the dock is fine. + natural.height = 200; + Object.assign(panel.style, { left: '620px', top: '650px' }); + window.dispatchEvent(new Event('resize')); + assert.equal(panel.style.top, '650px'); + } finally { + f.restore(); + } +}); diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index 5ab57465a..de622341d 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -30,6 +30,13 @@ const DOUBLE_PRESS_MS = 400; const DOUBLE_PRESS_SLOP_PX = 6; /** Viewport margin every positioned panel keeps clear. */ const VIEWPORT_MARGIN_PX = 6; +/** + * Fixed chrome that paints over every panel: the rail is its own stacking + * context (z 110), below the command dock (145) and the voice pill (150). A + * floating window keeps its header clear of these so it can always be + * grabbed again. + */ +const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control']; /** One-time hint shown the first time a portable panel leaves its rail. */ const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`; const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back'; @@ -52,6 +59,15 @@ function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } +/** A window extent that fits the viewport margins but never drops under its minimum. */ +function fitWindowExtent(value, minimum, viewportExtent) { + return clamp( + Math.round(value), + minimum, + Math.max(minimum, viewportExtent - 2 * VIEWPORT_MARGIN_PX), + ); +} + /** * Only a mouse or pen lifts and resizes a portable panel. On a phone the * rail is a scroll container, so a finger on a header is a swipe. @@ -93,7 +109,7 @@ export class PanelPositionControls { this._panelZCounter = PANEL_Z_BASE + 10; this._draggableResizeObserver = null; this._cancelDrag = null; - /** Portable panels by id: `{ panel, min: { width, height } }`. */ + /** Portable panels by id: `{ panel, handle, min: { width, height }, dockOnCollapse }`. */ this._portablePanels = new Map(); this._resizeHandles = []; this._dragInitialized = false; @@ -120,18 +136,108 @@ export class PanelPositionControls { this._pinPanelToRight(el); } } - // Floating panels only move back on-screen; their size is the user's. - for (const { panel } of this._portablePanels.values()) { - if (!panel.classList.contains('panel-floating')) continue; - const left = parseInt(panel.style.left, 10); - const top = parseInt(panel.style.top, 10); - if (!Number.isFinite(left) || !Number.isFinite(top)) continue; - const next = this._clampToViewport(left, top, panel); - panel.style.left = `${next.left}px`; - panel.style.top = `${next.top}px`; + for (const entry of this._portablePanels.values()) { + const { classList } = entry.panel; + if (!classList.contains('panel-floating')) continue; + // A live gesture owns the box; it re-clamps when it ends. + if (classList.contains('panel-dragging')) continue; + if (classList.contains('panel-resizing')) continue; + this._reclampPortablePanel(entry); } } + /** + * Bring a floating window back on-screen. Its size is the user's, except + * that a window wider or taller than the viewport shrinks to fit it. + */ + _reclampPortablePanel(entry) { + const { panel, min } = entry; + const left = parseInt(panel.style.left, 10); + const top = parseInt(panel.style.top, 10); + if (!Number.isFinite(left) || !Number.isFinite(top)) return; + for (const [property, viewportExtent] of [ + ['width', window.innerWidth], + ['height', window.innerHeight], + ]) { + const extent = parseFloat(panel.style[property]); + if (!Number.isFinite(extent)) continue; + const fitted = fitWindowExtent(extent, min[property], viewportExtent); + if (fitted < extent) panel.style[property] = `${fitted}px`; + } + const next = this._clampPortableWindow(entry, left, top); + panel.style.left = `${next.left}px`; + panel.style.top = `${next.top}px`; + } + + /** + * Where a floating window may sit: inside the viewport margin, with its + * header out of the command dock and the voice pill, measured now because + * the dock grows and shrinks. Both sit at the foot of the screen, so the + * header goes above whichever it would land under. + * @param {object} entry A `_portablePanels` value. + * @param {number} left + * @param {number} top + * @param {{width: number, height: number, header: number}} [geometry] + * Window size and header depth; measured when omitted. + */ + _clampPortableWindow( + entry, + left, + top, + geometry = this._windowGeometry(entry), + ) { + const { width, height, header } = geometry; + const maxLeft = Math.max( + VIEWPORT_MARGIN_PX, + window.innerWidth - width - VIEWPORT_MARGIN_PX, + ); + const maxTop = Math.max( + VIEWPORT_MARGIN_PX, + window.innerHeight - height - VIEWPORT_MARGIN_PX, + ); + const x = clamp(left, VIEWPORT_MARGIN_PX, maxLeft); + let y = clamp(top, VIEWPORT_MARGIN_PX, maxTop); + const obstacles = this._panelObstacles(); + for (let pass = 0; pass < obstacles.length; pass++) { + const hit = obstacles.find( + (rect) => + rect.left < x + width && + rect.right > x && + rect.top < y + header && + rect.bottom > y, + ); + if (!hit) break; + const above = hit.top - header - VIEWPORT_MARGIN_PX; + y = + above >= VIEWPORT_MARGIN_PX + ? above + : Math.min(maxTop, hit.bottom + VIEWPORT_MARGIN_PX); + } + return { left: x, top: y }; + } + + /** A window's size and how far down its header reaches. */ + _windowGeometry({ panel, handle }, rect = panel.getBoundingClientRect()) { + const handleRect = handle?.getBoundingClientRect(); + const header = handleRect ? handleRect.bottom - rect.top : 0; + return { + width: rect.width, + height: rect.height, + // Without a measurable header, keep the whole window clear. + header: header > 0 ? Math.min(header, rect.height) : rect.height, + }; + } + + _panelObstacles() { + const rects = []; + for (const id of PANEL_OBSTACLE_IDS) { + const rect = document.getElementById(id)?.getBoundingClientRect(); + // A hidden dock, or a voice UI switched off, measures empty. + if (rect && rect.width > 0 && rect.height > 0) rects.push(rect); + } + return rects; + } + _maybeNotifyLayoutReset() { try { const marker = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.layoutResetNotified`; @@ -188,6 +294,7 @@ export class PanelPositionControls { if (spec.portable) { this._portablePanels.set(spec.id, { panel: spec.panel, + handle: spec.handle, min: { width: 300, height: 160, ...spec.min }, dockOnCollapse: spec.dockOnCollapse === true, }); @@ -201,12 +308,18 @@ export class PanelPositionControls { } // Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its // full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and - // the panel could still hang off the bottom (audit U2). Re-clamp on every size change. - if (this._ppToggles && typeof ResizeObserver !== 'undefined') { + // the panel could still hang off the bottom (audit U2). Re-clamp on every size change. A floating + // window grows the same way when its content does (an image opens, a collapsed strip expands). + const observed = [ + this._ppToggles, + ...[...this._portablePanels.values()].map(({ panel }) => panel), + ].filter(Boolean); + if (observed.length && typeof ResizeObserver !== 'undefined') { this._draggableResizeObserver = new ResizeObserver(() => this._reclampDraggablePanels(), ); - this._draggableResizeObserver.observe(this._ppToggles); + for (const panel of observed) + this._draggableResizeObserver.observe(panel); } if (this._portablePanels.size) { this.listen(window, 'resize', () => this._reclampDraggablePanels()); @@ -292,27 +405,34 @@ export class PanelPositionControls { // only one that was lifted or clicked this session. this._promotePanelZ(panelEl); if (Number.isFinite(pos.width)) { - panelEl.style.width = `${clamp( - Math.round(pos.width), + panelEl.style.width = `${fitWindowExtent( + pos.width, portable.min.width, - Math.max(portable.min.width, window.innerWidth - 12), + window.innerWidth, )}px`; } if (Number.isFinite(pos.height)) { - panelEl.style.height = `${clamp( - Math.round(pos.height), + panelEl.style.height = `${fitWindowExtent( + pos.height, portable.min.height, - Math.max(portable.min.height, window.innerHeight - 12), + window.innerHeight, )}px`; } } // Clamp to the viewport: a position saved at one window size would otherwise land off-screen at - // another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too. - const { left, top } = this._clampToViewport( - Math.round(pos.left), - Math.round(pos.top), - panelEl, - ); + // another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too, + // and a window saved under the command dock must not come back there. + const { left, top } = portable + ? this._clampPortableWindow( + portable, + Math.round(pos.left), + Math.round(pos.top), + ) + : this._clampToViewport( + Math.round(pos.left), + Math.round(pos.top), + panelEl, + ); panelEl.style.left = `${left}px`; panelEl.style.top = `${top}px`; panelEl.style.right = 'auto'; @@ -457,10 +577,8 @@ export class PanelPositionControls { this._cancelDrag?.(); event.preventDefault(); const rect = panelEl.getBoundingClientRect(); - const startX = event.clientX; - const startY = event.clientY; - const offsetX = startX - rect.left; - const offsetY = startY - rect.top; + const offsetX = event.clientX - rect.left; + const offsetY = event.clientY - rect.top; panelEl.style.left = `${rect.left}px`; panelEl.style.top = `${rect.top}px`; @@ -469,48 +587,85 @@ export class PanelPositionControls { panelEl.classList.add('panel-dragging'); this._promotePanelZ(panelEl); - const onMove = (moveEvent) => { - const nextLeftRaw = moveEvent.clientX - offsetX; - const nextTopRaw = moveEvent.clientY - offsetY; - const maxLeft = Math.max(6, window.innerWidth - rect.width - 6); - const maxTop = Math.max(6, window.innerHeight - rect.height - 6); - const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw)); - const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw)); - panelEl.style.left = `${nextLeft}px`; - panelEl.style.top = `${nextTop}px`; - if (panelId === 'pp-toggles') { - this._layoutRightPanels(); - } - if (panelId === 'cctv-panel') { - this._syncCctvPanelViewport(); - } - }; - - const cancel = () => { - panelEl.classList.remove('panel-dragging'); - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - window.removeEventListener('pointercancel', onUp); - this._cancelDrag = null; - }; - const onUp = () => { - cancel(); - if (panelId === 'pp-toggles') { - this._pinPanelToRight(panelEl); - } - this._savePanelPosition(panelId, panelEl); - if (panelId === 'cctv-panel') { - this._syncCctvPanelViewport(); - } - }; - - this._cancelDrag = cancel; - window.addEventListener('pointermove', onMove); - window.addEventListener('pointerup', onUp); - window.addEventListener('pointercancel', onUp); + this._trackPointerGesture(event, { + onMove: (moveEvent) => { + const nextLeftRaw = moveEvent.clientX - offsetX; + const nextTopRaw = moveEvent.clientY - offsetY; + const maxLeft = Math.max(6, window.innerWidth - rect.width - 6); + const maxTop = Math.max(6, window.innerHeight - rect.height - 6); + const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw)); + const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw)); + panelEl.style.left = `${nextLeft}px`; + panelEl.style.top = `${nextTop}px`; + if (panelId === 'pp-toggles') { + this._layoutRightPanels(); + } + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + }, + onStop: () => panelEl.classList.remove('panel-dragging'), + // A cancelled press keeps the place too: there is no rail to revert to. + onEnd: () => { + if (panelId === 'pp-toggles') { + this._pinPanelToRight(panelEl); + } + this._savePanelPosition(panelId, panelEl); + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + }, + }); }); } + /** + * Follow one pointer gesture on the window; every panel gesture runs on + * this. With a `threshold`, a press only becomes a gesture once the pointer + * has travelled that far: `onStart` runs then, and `onMove` (given the + * travel so far) only after it. `onEnd` (pointerup) and `onCancel` + * (pointercancel, `onEnd` unless given) learn whether it started. A new + * press, a snap-back or destroy() stops it through `_cancelDrag`, which + * calls neither. + */ + _trackPointerGesture( + event, + { threshold = 0, onStart, onMove, onStop, onEnd, onCancel = onEnd }, + ) { + const startX = event.clientX; + const startY = event.clientY; + let started = threshold <= 0; + const move = (moveEvent) => { + const dx = moveEvent.clientX - startX; + const dy = moveEvent.clientY - startY; + if (!started) { + if (Math.hypot(dx, dy) < threshold) return; + started = true; + onStart?.(); + } + onMove(moveEvent, dx, dy); + }; + const stop = () => { + onStop?.(); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + window.removeEventListener('pointercancel', cancel); + if (this._cancelDrag === stop) this._cancelDrag = null; + }; + const up = () => { + stop(); + onEnd?.(started); + }; + const cancel = () => { + stop(); + onCancel?.(started); + }; + this._cancelDrag = stop; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + window.addEventListener('pointercancel', cancel); + } + /** * Portable drag: a press on the header only becomes a drag after a few * pixels of travel, at which point the panel lifts out of its rail. The @@ -586,76 +741,53 @@ export class PanelPositionControls { this._cancelDrag?.(); // A header press must not start a text selection across the page. event.preventDefault(); - const startX = event.clientX; - const startY = event.clientY; - let dragging = false; + const entry = this._portablePanels.get(panelId); let lifted = false; let before = null; let offsetX = 0; let offsetY = 0; - let width = 0; - let height = 0; + let geometry = null; - const onMove = (moveEvent) => { - if (!dragging) { - if ( - Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < - DRAG_THRESHOLD_PX - ) - return; - dragging = true; + this._trackPointerGesture(event, { + threshold: DRAG_THRESHOLD_PX, + onStart: () => { // A drag between two presses is not a double-click. lastPress = null; const rect = panelEl.getBoundingClientRect(); - offsetX = startX - rect.left; - offsetY = startY - rect.top; - width = rect.width; - height = rect.height; + offsetX = event.clientX - rect.left; + offsetY = event.clientY - rect.top; + geometry = this._windowGeometry(entry, rect); before = geometryOf(panelEl); lifted = this._liftPanelOut(panelId, panelEl, rect); panelEl.classList.add('panel-dragging'); this._promotePanelZ(panelEl); - } - moveEvent.preventDefault(); - const maxLeft = Math.max( - VIEWPORT_MARGIN_PX, - window.innerWidth - width - VIEWPORT_MARGIN_PX, - ); - const maxTop = Math.max( - VIEWPORT_MARGIN_PX, - window.innerHeight - height - VIEWPORT_MARGIN_PX, - ); - panelEl.style.left = `${clamp(moveEvent.clientX - offsetX, VIEWPORT_MARGIN_PX, maxLeft)}px`; - panelEl.style.top = `${clamp(moveEvent.clientY - offsetY, VIEWPORT_MARGIN_PX, maxTop)}px`; - }; - - const cancel = () => { - panelEl.classList.remove('panel-dragging'); - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - window.removeEventListener('pointercancel', onCancel); - this._cancelDrag = null; - }; - const onUp = () => { - cancel(); - if (!dragging) return; - swallowNextClick = true; - setTimeout(() => { - swallowNextClick = false; - }, 0); - this._savePanelPosition(panelId, panelEl); - this._onPanelResized?.(panelId); - }; - const onCancel = () => { - cancel(); - if (dragging) - this._revertPanelGesture(panelId, panelEl, lifted, before); - }; - - this._cancelDrag = cancel; - window.addEventListener('pointermove', onMove); - window.addEventListener('pointerup', onUp); - window.addEventListener('pointercancel', onCancel); + }, + onMove: (moveEvent) => { + moveEvent.preventDefault(); + const next = this._clampPortableWindow( + entry, + moveEvent.clientX - offsetX, + moveEvent.clientY - offsetY, + geometry, + ); + panelEl.style.left = `${next.left}px`; + panelEl.style.top = `${next.top}px`; + }, + onStop: () => panelEl.classList.remove('panel-dragging'), + onEnd: (dragged) => { + if (!dragged) return; + swallowNextClick = true; + setTimeout(() => { + swallowNextClick = false; + }, 0); + this._savePanelPosition(panelId, panelEl); + this._onPanelResized?.(panelId); + }, + onCancel: (dragged) => { + if (dragged) + this._revertPanelGesture(panelId, panelEl, lifted, before); + }, + }); }); } @@ -701,8 +833,6 @@ export class PanelPositionControls { if (event.button !== 0 || !isPrecisePointer(event)) return; event.preventDefault(); this._cancelDrag?.(); - const startX = event.clientX; - const startY = event.clientY; // Set once the pointer has travelled: a plain click on an edge (or on // the globe just beside a docked panel) must not lift it out. let startBox = null; @@ -714,60 +844,48 @@ export class PanelPositionControls { panelEl.style.width = `${width}px`; panelEl.style.height = `${height}px`; }; - const begin = () => { - const rect = panelEl.getBoundingClientRect(); - before = geometryOf(panelEl); - lifted = this._liftPanelOut(panelId, panelEl, rect); - startBox = { - left: Math.round(rect.left), - top: Math.round(rect.top), - width: Math.round(rect.width), - height: Math.round(rect.height), - }; - panelEl.classList.add('panel-resizing'); - this._promotePanelZ(panelEl); - apply(startBox); - }; - const onMove = (moveEvent) => { - const dx = moveEvent.clientX - startX; - const dy = moveEvent.clientY - startY; - if (!startBox) { - if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; - begin(); - } - moveEvent.preventDefault(); - apply( - resizeBox(startBox, dir, dx, dy, { - minWidth: limits.width, - minHeight: limits.height, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - }), - ); - }; - const cancel = () => { - panelEl.classList.remove('panel-resizing'); - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - window.removeEventListener('pointercancel', onCancel); - this._cancelDrag = null; - }; - const onUp = () => { - cancel(); - if (!startBox) return; - this._savePanelPosition(panelId, panelEl); - this._onPanelResized?.(panelId); - }; - const onCancel = () => { - cancel(); - if (startBox) this._revertPanelGesture(panelId, panelEl, lifted, before); - }; - - this._cancelDrag = cancel; - window.addEventListener('pointermove', onMove); - window.addEventListener('pointerup', onUp); - window.addEventListener('pointercancel', onCancel); + this._trackPointerGesture(event, { + threshold: DRAG_THRESHOLD_PX, + onStart: () => { + const rect = panelEl.getBoundingClientRect(); + before = geometryOf(panelEl); + lifted = this._liftPanelOut(panelId, panelEl, rect); + startBox = { + left: Math.round(rect.left), + top: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + panelEl.classList.add('panel-resizing'); + this._promotePanelZ(panelEl); + apply(startBox); + }, + onMove: (moveEvent, dx, dy) => { + moveEvent.preventDefault(); + apply( + resizeBox(startBox, dir, dx, dy, { + minWidth: limits.width, + minHeight: limits.height, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }), + ); + }, + onStop: () => panelEl.classList.remove('panel-resizing'), + onEnd: (resized) => { + if (!resized) return; + // Re-clamping skipped the live gesture; a north edge may have carried + // the header under the command dock. + const entry = this._portablePanels.get(panelId); + if (entry) this._reclampPortablePanel(entry); + this._savePanelPosition(panelId, panelEl); + this._onPanelResized?.(panelId); + }, + onCancel: (resized) => { + if (resized) this._revertPanelGesture(panelId, panelEl, lifted, before); + }, + }); } destroy() {