// Right-edge snap docking for draggable modals. // // Adds a "drag-to-right" gesture that docks a modal as a right-side panel // (mirrors the snap-to-top fullscreen pattern used by _makeDraggable in // emailLibrary.js / documentLibrary.js / galleryEditor.js). While docked: // - the modal-content lives at `right: 0; top: 0; bottom: 0` with a // viewport-fraction width // - body gets `right-dock-active` + `--right-dock-w` so the chat / // doc panel / notes pane underneath reserves room via padding-right // - if the remaining chat width would drop under 380px, the wide // sidebar auto-collapses to the icon rail (mirrors notes-view UX) // // Drag-away from the right edge un-docks back to a centered window — // the same restore values the snap-to-top exit path uses. // Wider snap zone than the top-snap fullscreen (6px) — the right edge // is harder to hit precisely since most users drag broadly toward the // side rather than aiming at a 1px line. 60px feels generous without // false-positive triggers from casual repositioning. const SNAP_PX = 60; const UNSNAP_PX = 80; const MIN_CHAT_WIDTH = 380; const EMAIL_DOC_SPLIT_WIDTH_KEY = 'odysseus-email-doc-split-width'; 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 _hasAnyOtherDockedWindow(owner) { return _hasOtherDockedWindow('left', owner) || _hasOtherDockedWindow('right', owner); } export 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 (_) {} } } // Default dock width: ~38% of viewport, clamped to a reasonable band. function _defaultDockWidth() { return Math.min(640, Math.max(420, Math.round(window.innerWidth * 0.38))); } function _showSnapHint(on, side = 'right') { const cls = side === 'left' ? 'modal-snap-hint-left' : 'modal-snap-hint-right'; let hint = document.querySelector('.' + cls); if (!on) { if (hint) hint.remove(); return; } if (hint) return; hint = document.createElement('div'); hint.className = 'modal-snap-hint ' + cls; const w = _defaultDockWidth(); const edge = side === 'left' ? 'left:0' : 'right:0'; const borderSide = side === 'left' ? 'border-right' : 'border-left'; hint.style.cssText = `position:fixed;${edge};top:0;bottom:0;width:${w}px;background:color-mix(in srgb, var(--accent-primary, #60a5fa) 12%, transparent);${borderSide}:2px dashed color-mix(in srgb, var(--accent-primary, #60a5fa) 60%, transparent);z-index:9998;pointer-events:none;transition:opacity 0.12s;`; document.body.appendChild(hint); } // Check if the body's current chat area would be narrower than the // MIN_CHAT_WIDTH floor after reserving dockW pixels on the right. Returns // true if the wide sidebar should be collapsed to the rail. function _shouldAutoCollapseSidebar(dockW) { const sidebar = document.getElementById('sidebar'); const rail = document.getElementById('icon-rail'); if (!sidebar) return false; const sidebarHidden = sidebar.classList.contains('hidden'); if (sidebarHidden) return false; const sb = sidebar.getBoundingClientRect().width || 0; const rl = (rail && window.getComputedStyle(rail).display !== 'none') ? rail.getBoundingClientRect().width : 0; const remaining = window.innerWidth - sb - rl - dockW; return remaining < MIN_CHAT_WIDTH; } // Right edge (px) of whatever left navigation is currently showing — the // expanded sidebar if visible, otherwise the icon rail. Used to anchor the // left dock so it always sits flush to the right of the nav. function _leftNavRight() { const sidebar = document.getElementById('sidebar'); const rail = document.getElementById('icon-rail'); let x = 0; if (sidebar && !sidebar.classList.contains('hidden')) { const r = sidebar.getBoundingClientRect(); if (r.width) x = Math.max(x, r.right); } if (rail && window.getComputedStyle(rail).display !== 'none') { const r = rail.getBoundingClientRect(); if (r.width) x = Math.max(x, r.right); } return x; } function _clampEmailDocSplitWidth(width, left = _leftNavRight()) { const available = Math.max(0, window.innerWidth - left); if (!available) return 0; const compact = available < 760; const minEmail = compact ? 260 : 340; const minDoc = compact ? 260 : 360; const maxEmail = Math.max(minEmail, available - minDoc); return Math.min(maxEmail, Math.max(minEmail, Math.round(width))); } function _storedEmailDocSplitWidth() { try { const raw = localStorage.getItem(EMAIL_DOC_SPLIT_WIDTH_KEY); const n = parseFloat(raw || ''); return Number.isFinite(n) && n > 0 ? n : null; } catch (_) { return null; } } function _saveEmailDocSplitWidth(width) { try { localStorage.setItem(EMAIL_DOC_SPLIT_WIDTH_KEY, String(Math.round(width))); } catch (_) {} } function _disconnectLeftDockObservers(content) { if (!content?._leftDockNavObs) return; const obs = content._leftDockNavObs; try { obs.navObs && obs.navObs.disconnect(); } catch (_) {} try { obs.bodyObs && obs.bodyObs.disconnect(); } catch (_) {} try { obs.disconnectDocObs && obs.disconnectDocObs(); } catch (_) {} try { window.removeEventListener('resize', obs.reanchor); } catch (_) {} delete content._leftDockNavObs; } function _applyEmailDocSplitGeometry(left, emailWidth) { const x = left + emailWidth; document.documentElement.style.setProperty('--email-doc-split-left-x', `${left}px`); document.documentElement.style.setProperty('--email-doc-split-email-w', `${emailWidth}px`); document.documentElement.style.setProperty('--email-doc-split-right-x', `${x}px`); // emailLibrary.js pins the document pane with inline !important styles // after opening a document beside a snapped email. Update that inline // geometry too, otherwise the email resizes but the document stays put. const docPane = document.getElementById('doc-editor-pane'); if (!docPane || window.innerWidth <= 768) return; docPane.style.setProperty('position', 'fixed', 'important'); docPane.style.setProperty('left', `${x}px`, 'important'); docPane.style.setProperty('right', '0px', 'important'); docPane.style.setProperty('top', '0px', 'important'); docPane.style.setProperty('bottom', '0px', 'important'); docPane.style.setProperty('width', 'auto', 'important'); docPane.style.setProperty('max-width', 'none', 'important'); docPane.style.setProperty('height', '100vh', 'important'); docPane.style.setProperty('z-index', '260', 'important'); docPane.style.setProperty('transform', 'none', 'important'); } function _clearEmailDocSplitGeometry() { document.body.classList.remove('email-doc-split-active'); document.documentElement.style.removeProperty('--email-doc-split-left-x'); document.documentElement.style.removeProperty('--email-doc-split-email-w'); document.documentElement.style.removeProperty('--email-doc-split-right-x'); const docPane = document.getElementById('doc-editor-pane'); if (!docPane) return; [ 'position', 'left', 'right', 'top', 'bottom', 'width', 'max-width', 'height', 'z-index', 'transform', ].forEach(prop => docPane.style.removeProperty(prop)); } function _resolveEmailDocSplitWidth(content, left) { const available = Math.max(0, window.innerWidth - left); const fallback = Math.max(440, available * 0.55); const requested = content?._emailDocSplitUserW || _storedEmailDocSplitWidth() || fallback; return _clampEmailDocSplitWidth(requested, left); } // Position a left-docked window flush against the current left nav, covering // the chat area. Re-run whenever the sidebar is toggled so the window slides // to follow the nav instead of being covered by it. // // Also: if the document editor pane is rendered to the right of the chat // area, cap the email's right edge to stop just before it so the two share // the row instead of overlapping. Pure geometry read — no CSS class changes // (the previous attempt that flipped body classes here caused layout thrash // and broke the whole tab). function _anchorLeftDock(content) { if (!content || content._dockSide !== 'left') return; const left = _leftNavRight(); const w = _resolveEmailDocSplitWidth(content, left); content.style.left = left + 'px'; content.style.width = w + 'px'; content.style.maxWidth = w + 'px'; // If a document is also open, drive the existing email/doc-split CSS rule // (style.css `body.email-doc-split-active.doc-view .doc-editor-pane`) so // the doc-pane becomes position:fixed starting at the email's right edge. // No flex/max-width fighting; the doc just owns the right side from the // email's right edge to the viewport edge — they touch flush, no gap. const docOpen = document.body.classList.contains('doc-view'); if (docOpen) { if (!document.body.classList.contains('email-doc-split-active')) { document.body.classList.add('email-doc-split-active'); } _applyEmailDocSplitGeometry(left, w); } else if (document.body.classList.contains('email-doc-split-active')) { _clearEmailDocSplitGeometry(); } } function _collapseSidebarToRail() { const sidebar = document.getElementById('sidebar'); const rail = document.getElementById('icon-rail'); if (!sidebar || !rail) return; // Mark the collapse as route/dock-driven so the paired restore in // app.js (window._restoreSidebarIfRouteCollapsed) knows it owns the // un-collapse. Same marker the /email and /notes openers use — they // can't both be active at once so no conflict. if (!sidebar.classList.contains('hidden')) { document.body.dataset.routeCollapsedSidebar = '1'; } sidebar.classList.add('hidden'); rail.classList.remove('rail-hidden'); try { window.syncRailSide && window.syncRailSide(); } catch (_) {} } // Resolve the dock target. For .modal containers, the inner .modal-content // is what we position; for standalone panes (research, compare, etc.) the // passed element itself is both the container and the content. Returns // {modal, content} or null when nothing usable was passed in. function _resolveDockNodes(target) { if (!target) return null; const content = target.querySelector ? (target.querySelector('.modal-content') || target) : target; return { modal: target, content }; } // Apply edge dock state to a modal/pane. `side` is 'right' (default) or 'left'. export function applyEdgeDock(modal, side = 'right', dockClass) { if (!dockClass) dockClass = side === 'left' ? 'modal-left-docked' : 'modal-right-docked'; return _applyDockInternal(modal, side, dockClass); } // Backwards-compat: existing callers use applyRightDock for right snaps. export function applyRightDock(modal, dockClass = 'modal-right-docked') { return _applyDockInternal(modal, 'right', dockClass); } function _applyDockInternal(modal, side, dockClass) { const nodes = _resolveDockNodes(modal); if (!nodes) return 0; const content = nodes.content; if (!content) return 0; // If the modal is currently docked on the OTHER side (e.g. the user // manually docked it right, then a reply re-docks it left), clear that // side's class + body push first. Otherwise both sides' state coexist — // the old dock keeps pushing/overlapping and the reply doc opens beneath // the still-docked window. We keep _preDockSnapshot (the guard below skips // re-capturing) so un-dock still restores the original floating geometry. // Guarded on the other-side class so a normal first dock still snapshots // the floating window's real left/right inline styles below. const otherSide = side === 'left' ? 'right' : 'left'; const otherClass = _dockClassForSide(otherSide); if (modal.classList.contains(otherClass)) { modal.classList.remove(otherClass); clearDockSide(otherSide, modal); // Reset the edge anchors so the new side positions from a clean slate // (the right dock pins right:0; the left dock pins left: