/** * tileManager.js — desktop window tiling for tool modals. * * Hooks into any modal whose `.modal-header` is dragged (each tool wires its * own drag; we just watch pointer moves). Shows a translucent ghost preview * when the cursor is near a snap zone. On release, snaps the modal-content * to fill that zone with a springy animation. * * Snap zones (9): * - top edge (10% strip) → maximize * - top-left corner → top-left quarter * - top-right corner → top-right quarter * - left edge → left half * - right edge → right half * - bottom-left corner → bottom-left quarter * - bottom-right corner → bottom-right quarter * - bottom edge → bottom half * - sidebar edge (if present) → snap next to the sidebar * * Mobile (≤768px) is excluded — the swipe-dismiss UX takes precedence. * * Each modal-content remembers its pre-snap geometry so dragging away restores * the original size. */ const EDGE_THRESHOLD_PX = 24; // how close to an edge counts as "near" const CORNER_THRESHOLD_PX = 64; // corner box size const TOP_FULL_STRIP_PX = 8; // top strip → maximize let _ghost = null; let _activeZone = null; let _tracking = null; // { content, startRect } function _isDesktop() { return window.innerWidth > 768; } function _dockClassForSide(side) { return side === 'left' ? 'modal-left-docked' : 'modal-right-docked'; } function _hasOtherDockedWindow(side, owner) { const cls = _dockClassForSide(side); return Array.from(document.querySelectorAll(`.${cls}`)).some((el) => { if (!el || el === owner) return false; if (owner && el.contains && el.contains(owner)) return false; if (owner && owner.contains && owner.contains(el)) return false; return true; }); } function _clearDockSide(side, owner = null) { if (side !== 'left' && side !== 'right') return; if (_hasOtherDockedWindow(side, owner)) return; document.body.classList.remove(side === 'left' ? 'left-dock-active' : 'right-dock-active'); document.documentElement.style.removeProperty(side === 'left' ? '--left-dock-w' : '--right-dock-w'); if (side === 'left') { try { window._restoreSidebarIfRouteCollapsed?.(); } catch (_) {} } } function _ensureGhost() { if (_ghost) return _ghost; _ghost = document.createElement('div'); _ghost.id = 'tile-ghost'; document.body.appendChild(_ghost); return _ghost; } function _hideGhost() { if (_ghost) _ghost.classList.remove('visible'); } function _showGhost(rect) { const g = _ensureGhost(); g.style.left = rect.left + 'px'; g.style.top = rect.top + 'px'; g.style.width = rect.width + 'px'; g.style.height = rect.height + 'px'; g.classList.add('visible'); } function _viewportSafeRect() { // Account for the icon rail / sidebar on the left side of the viewport. const sidebar = document.getElementById('sidebar'); const rail = document.querySelector('.icon-rail') || document.querySelector('#icon-rail'); let leftEdge = 0; const sb = sidebar?.getBoundingClientRect(); if (sb && sb.right > 0 && !sidebar.classList.contains('hidden')) leftEdge = Math.max(leftEdge, sb.right); const rr = rail?.getBoundingClientRect(); if (rr && rr.right > 0) leftEdge = Math.max(leftEdge, rr.right); return { left: leftEdge + 4, top: 4, right: window.innerWidth - 4, bottom: window.innerHeight - 4, }; } function _zoneForPointer(x, y) { const safe = _viewportSafeRect(); const W = safe.right - safe.left; const H = safe.bottom - safe.top; // Dragged OVER the top edge (cursor at/past the very top) → TRUE fullscreen // that covers everything, including the sidebar. if (y <= 0) { return { name: 'fullscreen', rect: { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight } }; } // Near the top edge (but not over it) → "maximize": fill the safe area, // which sits NEXT TO the sidebar/rail rather than covering it. if (y <= safe.top + TOP_FULL_STRIP_PX) { return { name: 'maximize', rect: { left: safe.left, top: safe.top, width: W, height: H } }; } // Corner quarter-snaps DISABLED (user request) — only the top strip // (maximize) and the right/bottom half-snaps remain. The LEFT-half snap // is also disabled (the sidebar lives there; docking over it is awkward). if (x >= safe.right - EDGE_THRESHOLD_PX) return { name: 'right-half', rect: { left: safe.left + W / 2, top: safe.top, width: W / 2, height: H } }; if (y >= safe.bottom - EDGE_THRESHOLD_PX) return { name: 'bottom-half', rect: { left: safe.left, top: safe.top + H / 2, width: W, height: H / 2 } }; return null; } function _zoneForContent(content, x, y) { const modal = content && content.closest && content.closest('.modal, .research-overlay'); const zone = _zoneForPointer(x, y); if (!zone) return null; // Settings has a dense two-column layout; the full-height sidebar-style dock // crushes it. Let it tile only into the normal right half, where the nav can // flip to top tabs via CSS when the window gets narrow. if (modal && modal.id === 'settings-modal' && zone.name !== 'right-half') return null; if (modal && (modal.id === 'cookbook-modal' || modal.id === 'theme-modal' || modal.id === 'memory-modal') && zone.name !== 'fullscreen') return null; return zone; } function _clearEdgeDockResidue(modal, content) { const hadDockState = !!( (modal && (modal.classList.contains('modal-left-docked') || modal.classList.contains('modal-right-docked'))) || (content && (content._preDockSnapshot || content._dockSide || content._dockSuspended)) ); if (modal) { const hadLeft = modal.classList.contains('modal-left-docked'); const hadRight = modal.classList.contains('modal-right-docked'); modal.classList.remove('modal-left-docked', 'modal-right-docked'); if (hadLeft) _clearDockSide('left', modal); if (hadRight) _clearDockSide('right', modal); if (modal._dockCloseWatcher) { try { modal._dockCloseWatcher.obs && modal._dockCloseWatcher.obs.disconnect(); } catch (_) {} try { modal._dockCloseWatcher.parentObs && modal._dockCloseWatcher.parentObs.disconnect(); } catch (_) {} delete modal._dockCloseWatcher; } } if (!content) return; if (content._leftDockNavObs) { try { content._leftDockNavObs.navObs.disconnect(); } catch (_) {} try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {} delete content._leftDockNavObs; } delete content._preDockSnapshot; delete content._dockSide; delete content._dockSuspended; if (hadDockState) { ['right', 'bottom', 'max-width', 'border-radius'] .forEach(p => content.style.removeProperty(p)); } } function _applySnap(content, rect, zoneName) { // A tile-snap supersedes any edge-dock on this same modal. The two // systems (windowDrag→modalSnap edge-dock, and this tile manager) both // fire on a left/right-edge drag-release. If we leave modalSnap's // `left-dock-active` body class + `--left-dock-w` padding in place, it // reserves a strip on the left AND this manager's safe-rect already // accounts for the sidebar's (now padding-shifted) position — the two // double-count and jam the window to the right behind a massive empty // zone, which gets worse each time the sidebar is toggled. Clear the // orphaned edge-dock state so only the tile-snap positions the window. const _modal = content.closest && content.closest('.modal, .research-overlay'); const _fromRect = content.getBoundingClientRect(); _clearEdgeDockResidue(_modal, content); // Stash pre-snap geometry once; if we re-snap, keep the original. Capture a // CONCRETE fixed position (from the rendered rect when the inline value is // empty) and the position itself — otherwise un-snap restored empty left/top // + no position, and the .modal flex parent re-centered the window. if (!content.dataset._tilePreSnap) { content.dataset._tilePreSnap = JSON.stringify({ position: 'fixed', left: content.style.left || (Math.round(_fromRect.left) + 'px'), top: content.style.top || (Math.round(_fromRect.top) + 'px'), width: content.style.width, height: content.style.height, maxHeight: content.style.maxHeight, transform: content.style.transform, }); } content.style.transition = 'left 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), top 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), width 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), height 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)'; // Use !important — some modals (e.g. cookbook) carry inline width/height // and CSS that otherwise re-center the .modal-content, which made the snap // "jump back to the middle" on release. content.style.setProperty('position', 'fixed', 'important'); content.style.setProperty('left', rect.left + 'px', 'important'); content.style.setProperty('top', rect.top + 'px', 'important'); content.style.setProperty('width', rect.width + 'px', 'important'); content.style.setProperty('height', rect.height + 'px', 'important'); content.style.setProperty('max-height', rect.height + 'px', 'important'); content.style.setProperty('margin', '0', 'important'); content.style.setProperty('transform', 'none', 'important'); content.dataset._tileZone = zoneName; setTimeout(() => { content.style.transition = ''; }, 250); } function _unsnap(content) { const pre = content.dataset._tilePreSnap; if (!pre) return; // Clear the !important snap props first — Object.assign can't override them. ['position', 'left', 'top', 'width', 'height', 'max-height', 'margin', 'transform'] .forEach(p => content.style.removeProperty(p)); try { const r = JSON.parse(pre); Object.assign(content.style, r); } catch {} // Keep it a fixed floating window so the restored left/top actually take // effect — without position:fixed the .modal flex parent re-centers it. if (!content.style.position) content.style.position = 'fixed'; delete content.dataset._tilePreSnap; delete content.dataset._tileZone; } function _findDragTarget(e) { const header = e.target.closest('.modal-header'); if (!header) return null; // Skip clicks on header buttons (close, minimize, etc.) if (e.target.closest('button')) return null; const modal = header.closest('.modal, .research-overlay'); if (!modal) return null; const content = modal.querySelector('.modal-content, .research-pane'); return content || null; } document.addEventListener('pointerdown', (e) => { if (!_isDesktop()) return; const content = _findDragTarget(e); if (!content) return; // If we're already snapped, dragging away should unsnap immediately so the // user can move freely. if (content.dataset._tileZone) { // Defer slightly so pointermove threshold is met before unsnap kicks in _tracking = { content, startX: e.clientX, startY: e.clientY, willUnsnap: true }; } else { _tracking = { content, startX: e.clientX, startY: e.clientY, willUnsnap: false }; } }); document.addEventListener('pointermove', (e) => { if (!_tracking) return; if (!_isDesktop()) return; const dx = e.clientX - _tracking.startX; const dy = e.clientY - _tracking.startY; if (Math.hypot(dx, dy) < 6) return; // Unsnap on first significant move if (_tracking.willUnsnap) { _unsnap(_tracking.content); _tracking.willUnsnap = false; } // Detect snap zone under cursor const zone = _zoneForContent(_tracking.content, e.clientX, e.clientY); if (zone) { _showGhost(zone.rect); _activeZone = zone; } else { _hideGhost(); _activeZone = null; } }); document.addEventListener('pointerup', () => { if (!_tracking) return; const t = _tracking; _tracking = null; _hideGhost(); if (_activeZone && _isDesktop()) { _applySnap(t.content, _activeZone.rect, _activeZone.name); } _activeZone = null; }); // Re-clamp every currently-snapped window so it keeps filling its zone after // the safe-rect changes (viewport resize, sidebar toggle, etc.). function _reclampAll(animate = false) { document.querySelectorAll('.modal-content[data-_tile-zone], .research-pane[data-_tile-zone]').forEach(c => { const name = c.dataset._tileZone; if (!name) return; const safe = _viewportSafeRect(); const W = safe.right - safe.left, H = safe.bottom - safe.top; let r; switch (name) { case 'fullscreen': r = { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; break; case 'maximize': r = { left: safe.left, top: safe.top, width: W, height: H }; break; case 'left-half': r = { left: safe.left, top: safe.top, width: W/2, height: H }; break; case 'right-half': r = { left: safe.left + W/2, top: safe.top, width: W/2, height: H }; break; case 'bottom-half': r = { left: safe.left, top: safe.top + H/2, width: W, height: H/2 }; break; case 'top-left': r = { left: safe.left, top: safe.top, width: W/2, height: H/2 }; break; case 'top-right': r = { left: safe.left + W/2, top: safe.top, width: W/2, height: H/2 }; break; case 'bottom-left': r = { left: safe.left, top: safe.top + H/2, width: W/2, height: H/2 }; break; case 'bottom-right': r = { left: safe.left + W/2, top: safe.top + H/2, width: W/2, height: H/2 }; break; default: return; } if (animate) { c.style.transition = 'left 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), top 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), width 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), height 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)'; setTimeout(() => { c.style.transition = ''; }, 250); } c.style.setProperty('left', r.left + 'px', 'important'); c.style.setProperty('top', r.top + 'px', 'important'); c.style.setProperty('width', r.width + 'px', 'important'); c.style.setProperty('height', r.height + 'px', 'important'); c.style.setProperty('max-height', r.height + 'px', 'important'); }); } let _reclampPending = false; function _reclampAllThrottled(animate) { if (_reclampPending) return; _reclampPending = true; requestAnimationFrame(() => { try { _reclampAll(animate); } finally { _reclampPending = false; } }); } window.addEventListener('resize', () => _reclampAllThrottled(false)); // Watch the sidebar's class attribute so toggling hidden/right-side re-tiles // any snapped modal that was anchored to the old safe-rect. function _watchSidebar() { const sidebar = document.getElementById('sidebar'); if (!sidebar) { // Sidebar may not be in the DOM yet during early init. requestAnimationFrame(_watchSidebar); return; } const mo = new MutationObserver(() => _reclampAllThrottled(true)); mo.observe(sidebar, { attributes: true, attributeFilter: ['class'] }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', _watchSidebar); } else { _watchSidebar(); } // ── Public API for other drag sources (e.g. dragging a minimized dock chip // to a screen edge) to reuse the same snap zones + ghost preview + apply. ── // Show the snap-zone ghost for a point and return the zone (or null). export function previewZoneAt(x, y, target = null) { if (!_isDesktop()) { _hideGhost(); _activeZone = null; return null; } const content = target && target.querySelector ? (target.querySelector('.modal-content, .research-pane') || target) : null; const zone = content ? _zoneForContent(content, x, y) : _zoneForPointer(x, y); if (zone) { _showGhost(zone.rect); _activeZone = zone; } else { _hideGhost(); _activeZone = null; } return zone; } export function clearPreview() { _hideGhost(); _activeZone = null; } // Snap a modal (its .modal-content) into a previously-detected zone. export function snapModalToZone(modal, zone) { if (!modal || !zone) return; const content = modal.querySelector ? (modal.querySelector('.modal-content, .research-pane') || modal) : modal; if (!content) return; if (modal.id === 'settings-modal' && zone.name !== 'right-half') return; _applySnap(content, zone.rect, zone.name); } export {};