Docking a modal to a window edge pushes the chat aside (body padding via right-dock-active + --right-dock-w). Three problems on close/reopen: 1. Chat stayed offset after closing a docked modal. The close-watcher only reacted to the `.hidden` class or DOM removal, but the draggable modals (calendar, plan, workspace, document, …) close via inline `display:none`. Watch the `style` attribute too and treat `display:none` as closed. 2. Reopening a previously-docked singleton modal floated it off to the side, overlapping the chat. The reused element kept its docked inline geometry. Clear the content's inline position/size on close so it reopens at its CSS default (centered). 3. Undock wasn't animated. The transition lived on `.right/left-dock-active`, so removing the class dropped the transition with it and padding snapped to 0. Move the transition to the base `body` so the push animates both ways. Files: static/js/modalSnap.js, static/style.css. Checks: node --check static/js/modalSnap.js; verified in-browser (dock → close → chat animates back; reopen → centered, no overlap).
765 lines
34 KiB
JavaScript
765 lines
34 KiB
JavaScript
// 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:<nav>).
|
||
content.style.left = '';
|
||
content.style.right = '';
|
||
}
|
||
// Snapshot the actual rendered rect + inline styles so un-dock can
|
||
// restore the exact same floating window the user had before. Without
|
||
// this, a window the user had carefully resized would snap back to
|
||
// some 720×85vh default — feels like the dock ate their layout.
|
||
if (!content._preDockSnapshot) {
|
||
const r = content.getBoundingClientRect();
|
||
content._preDockSnapshot = {
|
||
rect: { left: r.left, top: r.top, width: r.width, height: r.height },
|
||
style: {
|
||
position: content.style.position,
|
||
left: content.style.left,
|
||
top: content.style.top,
|
||
right: content.style.right,
|
||
bottom: content.style.bottom,
|
||
width: content.style.width,
|
||
maxWidth: content.style.maxWidth,
|
||
height: content.style.height,
|
||
maxHeight: content.style.maxHeight,
|
||
borderRadius: content.style.borderRadius,
|
||
transform: content.style.transform,
|
||
margin: content.style.margin,
|
||
},
|
||
// Track whether we collapsed the wide sidebar — only restore it
|
||
// on un-dock if the dock was responsible for the collapse.
|
||
collapsedSidebar: false,
|
||
};
|
||
}
|
||
modal.classList.add(dockClass);
|
||
content.style.position = 'fixed';
|
||
content.style.top = '0';
|
||
content.style.bottom = '0';
|
||
content.style.height = '100vh';
|
||
content.style.maxHeight = '100vh';
|
||
content.style.borderRadius = '0';
|
||
content.style.transform = 'none';
|
||
content.style.margin = '0';
|
||
let w;
|
||
if (side === 'left') {
|
||
// Email-style left dock: collapse the sidebar to the icon rail, then
|
||
// OVERLAY the window beside the rail, covering the chat area. We anchor
|
||
// at the rail's right edge (so it sits to the RIGHT of the rail — not
|
||
// left of the sidebar) and DON'T reserve body padding (so it covers the
|
||
// chat rather than pushing it), leaving the right side free for the doc.
|
||
_collapseSidebarToRail();
|
||
content._preDockSnapshot.collapsedSidebar = true;
|
||
content.style.right = 'auto';
|
||
content._dockSide = 'left';
|
||
_anchorLeftDock(content);
|
||
w = parseFloat(content.style.width) || 0;
|
||
document.body.classList.add('left-dock-active');
|
||
document.documentElement.style.setProperty('--left-dock-w', '0px'); // overlay, no push
|
||
// Re-anchor the email when the sidebar is toggled (expanded/collapsed) so
|
||
// the nav slides the window over instead of growing on top of it. Also
|
||
// re-anchor when the document editor pane appears/disappears (signaled by
|
||
// body.doc-view) AND when the user drags the doc divider to resize it
|
||
// (ResizeObserver) so the email shrinks/grows inversely to keep the two
|
||
// sharing the row cleanly.
|
||
if (!content._leftDockNavObs && typeof MutationObserver !== 'undefined') {
|
||
const sidebar = document.getElementById('sidebar');
|
||
const _doAnchor = () => {
|
||
if (modal.classList.contains(dockClass)) _anchorLeftDock(content);
|
||
};
|
||
const reanchor = () => {
|
||
if (!modal.classList.contains(dockClass)) return;
|
||
_doAnchor();
|
||
// Multi-stage settle: the dock-flip + sidebar collapse + doc mount
|
||
// each have their own transition timing (160ms / ~240ms / variable).
|
||
// Re-measure at each plausible settle point so the email lands flush
|
||
// against the doc's FINAL position, not a mid-transition snapshot.
|
||
requestAnimationFrame(_doAnchor);
|
||
setTimeout(_doAnchor, 80);
|
||
setTimeout(_doAnchor, 250);
|
||
setTimeout(_doAnchor, 500);
|
||
};
|
||
const navObs = new MutationObserver(reanchor);
|
||
if (sidebar) navObs.observe(sidebar, { attributes: true, attributeFilter: ['class', 'style'] });
|
||
// Only react to doc-view toggling — NOT to every body attribute mutation.
|
||
// Listening broadly caused thrashing last time and crashed the tab.
|
||
let _lastDocView = document.body.classList.contains('doc-view');
|
||
const bodyObs = new MutationObserver(() => {
|
||
const cur = document.body.classList.contains('doc-view');
|
||
if (cur !== _lastDocView) {
|
||
_lastDocView = cur;
|
||
reanchor();
|
||
// Rebind the resize observer — the doc pane gets created/destroyed
|
||
// when doc-view flips, so the previous target may be stale.
|
||
_bindDocResizeObs();
|
||
}
|
||
});
|
||
bodyObs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||
|
||
// ResizeObserver on the current .doc-editor-pane so dragging its
|
||
// divider live-reflows the email's right edge. Also observe
|
||
// #chat-container — its width changes when the sidebar collapses,
|
||
// when right-dock padding drains, or when doc content paint reflows
|
||
// the row, all of which shift the doc pane's left edge without
|
||
// necessarily resizing the doc pane itself.
|
||
let docResizeObs = null;
|
||
let chatResizeObs = null;
|
||
const _bindDocResizeObs = () => {
|
||
if (docResizeObs) { try { docResizeObs.disconnect(); } catch (_) {} docResizeObs = null; }
|
||
if (chatResizeObs) { try { chatResizeObs.disconnect(); } catch (_) {} chatResizeObs = null; }
|
||
if (typeof ResizeObserver === 'undefined') return;
|
||
const docPane = document.querySelector('.doc-editor-pane');
|
||
if (docPane) {
|
||
docResizeObs = new ResizeObserver(reanchor);
|
||
docResizeObs.observe(docPane);
|
||
}
|
||
const chatPane = document.getElementById('chat-container');
|
||
if (chatPane) {
|
||
chatResizeObs = new ResizeObserver(reanchor);
|
||
chatResizeObs.observe(chatPane);
|
||
}
|
||
};
|
||
_bindDocResizeObs();
|
||
|
||
window.addEventListener('resize', reanchor);
|
||
content._leftDockNavObs = {
|
||
navObs,
|
||
bodyObs,
|
||
reanchor,
|
||
disconnectDocObs: () => {
|
||
try { docResizeObs && docResizeObs.disconnect(); } catch (_) {}
|
||
try { chatResizeObs && chatResizeObs.disconnect(); } catch (_) {}
|
||
},
|
||
};
|
||
}
|
||
} else {
|
||
w = _defaultDockWidth();
|
||
content.style.left = 'auto';
|
||
content.style.right = '0';
|
||
content.style.width = w + 'px';
|
||
content.style.maxWidth = w + 'px';
|
||
document.body.classList.add('right-dock-active');
|
||
document.documentElement.style.setProperty('--right-dock-w', w + 'px');
|
||
if (_shouldAutoCollapseSidebar(w)) {
|
||
_collapseSidebarToRail();
|
||
content._preDockSnapshot.collapsedSidebar = true;
|
||
}
|
||
}
|
||
content._dockSide = side;
|
||
// Watch for the docked modal disappearing (removed from DOM or hidden
|
||
// via .hidden class) and clean up the body padding + sidebar in that
|
||
// case. Without this, closing a docked window leaves a phantom strip
|
||
// of empty space on the right because nothing tells the body to drop
|
||
// its padding-right.
|
||
if (!modal._dockCloseWatcher && typeof MutationObserver !== 'undefined') {
|
||
const onGone = () => _onDockedModalGone(modal, dockClass);
|
||
// Watch the modal for: the `.hidden` class flip, an inline
|
||
// `display:none` (how the draggable modals — calendar, plan, workspace,
|
||
// etc. — actually close), and parent removal. Without the `style` filter
|
||
// a display:none close left the body's dock padding on, so the chat
|
||
// stayed shifted after the docked modal was closed.
|
||
const _isGone = () => !modal.isConnected
|
||
|| modal.classList.contains('hidden')
|
||
|| modal.style.display === 'none';
|
||
const obs = new MutationObserver(() => { if (_isGone()) onGone(); });
|
||
obs.observe(modal, { attributes: true, attributeFilter: ['class', 'style'] });
|
||
// A second observer catches DOM removal — childList on the parent
|
||
// is the reliable signal for `.remove()` / `.removeChild()` calls.
|
||
if (modal.parentNode) {
|
||
const parentObs = new MutationObserver(() => {
|
||
if (!modal.isConnected) onGone();
|
||
});
|
||
parentObs.observe(modal.parentNode, { childList: true });
|
||
modal._dockCloseWatcher = { obs, parentObs };
|
||
} else {
|
||
modal._dockCloseWatcher = { obs };
|
||
}
|
||
}
|
||
return w;
|
||
}
|
||
|
||
// Internal: tear down dock state when a docked modal vanishes (close
|
||
// button, X, escape, or programmatic removal). Idempotent — bails out
|
||
// if the dock is already cleared so multiple observers can fire safely.
|
||
function _onDockedModalGone(modal, dockClass) {
|
||
if (!modal) return;
|
||
const watcher = modal._dockCloseWatcher;
|
||
if (watcher) {
|
||
try { watcher.obs && watcher.obs.disconnect(); } catch (_) {}
|
||
try { watcher.parentObs && watcher.parentObs.disconnect(); } catch (_) {}
|
||
delete modal._dockCloseWatcher;
|
||
}
|
||
const _c = modal.querySelector ? modal.querySelector('.modal-content') : null;
|
||
_disconnectLeftDockObservers(_c);
|
||
const hadRight = modal.classList.contains('modal-right-docked');
|
||
const hadLeft = modal.classList.contains('modal-left-docked');
|
||
// Clear body-level dock state only for the side this modal owned, and only
|
||
// when another docked window is not still using that side.
|
||
if (hadRight) clearDockSide('right', modal);
|
||
if (hadLeft) clearDockSide('left', modal);
|
||
// Tear down the email/doc split CSS vars we set in _anchorLeftDock so the
|
||
// doc-pane returns to its natural flex layout when the email is closed.
|
||
if (hadLeft && !_hasOtherDockedWindow('left', modal)) {
|
||
_clearEmailDocSplitGeometry();
|
||
}
|
||
if (_c?._preDockSnapshot?.collapsedSidebar && !_hasAnyOtherDockedWindow(modal)) {
|
||
_expandSidebarFromRail();
|
||
}
|
||
modal.classList.remove('modal-right-docked');
|
||
modal.classList.remove('modal-left-docked');
|
||
// Clear the content's docked inline geometry. Singleton modals (plan,
|
||
// workspace, calendar, …) reuse the same element across open/close, so if we
|
||
// only drop the body push the element stays positioned (position:fixed;
|
||
// right:0; fixed width) on the next open — floating over the chat with no
|
||
// push. We deliberately do NOT restore the pre-dock snapshot here: that
|
||
// snapshot is the drag position from when the user pulled the window to the
|
||
// edge (near the side), so restoring it would reopen the modal off to the
|
||
// side, still overlapping. Clearing the inline styles lets the modal reopen
|
||
// at its CSS default (centered). Drag-to-undock still uses clearRightDock,
|
||
// which DOES restore the snapshot for the peel-off feel.
|
||
if (_c) {
|
||
for (const prop of ['position', 'inset', 'left', 'top', 'right', 'bottom',
|
||
'width', 'maxWidth', 'height', 'maxHeight',
|
||
'borderRadius', 'transform', 'margin']) {
|
||
_c.style[prop] = '';
|
||
}
|
||
delete _c._preDockSnapshot;
|
||
delete _c._dockSide;
|
||
}
|
||
}
|
||
|
||
function _expandSidebarFromRail() {
|
||
const sidebar = document.getElementById('sidebar');
|
||
if (!sidebar) return;
|
||
sidebar.classList.remove('hidden');
|
||
try { window.syncRailSide && window.syncRailSide(); } catch (_) {}
|
||
}
|
||
|
||
// Un-dock a previously docked modal. Restores the exact rendered size +
|
||
// position the modal had before being docked. (cx, cy) re-anchors the
|
||
// drag near the cursor so the panel feels like it peeled off the edge.
|
||
export function clearRightDock(modal, cx, cy, dockClass) {
|
||
const nodes = _resolveDockNodes(modal);
|
||
if (!nodes) return;
|
||
const content = nodes.content;
|
||
if (!content) return;
|
||
// Figure out which side was docked — fall back to right for legacy callers.
|
||
const side = content._dockSide || (modal.classList.contains('modal-left-docked') ? 'left' : 'right');
|
||
if (!dockClass) dockClass = side === 'left' ? 'modal-left-docked' : 'modal-right-docked';
|
||
if (!modal.classList.contains(dockClass)) return;
|
||
modal.classList.remove(dockClass);
|
||
clearDockSide(side, modal);
|
||
delete content._dockSide;
|
||
_disconnectLeftDockObservers(content);
|
||
const snap = content._preDockSnapshot;
|
||
// Re-expand the wide sidebar if we collapsed it — but only if the
|
||
// user didn't manually toggle it during the dock (we don't want to
|
||
// override their explicit choice).
|
||
if (snap && snap.collapsedSidebar && !_hasAnyOtherDockedWindow(modal)) _expandSidebarFromRail();
|
||
// Restore the exact inline style values the modal had before docking
|
||
// (width: min(720px, 92vw), max-height: 85vh, etc. — whatever the
|
||
// mount path set). Setting an empty string here removes the property
|
||
// from the inline style attribute, letting CSS rules take back over.
|
||
const r = snap && snap.rect;
|
||
const sty = (snap && snap.style) || {};
|
||
content.style.position = sty.position || 'fixed';
|
||
content.style.right = sty.right || '';
|
||
content.style.bottom = sty.bottom || '';
|
||
// Inline width/height may have been empty on the original (CSS-driven)
|
||
// modal — but we're now forcing position:fixed, which kills the
|
||
// CSS-flex-centered layout that produced the original size. Without a
|
||
// fallback, position:fixed + width:auto collapses the window to its
|
||
// content's min-width and the user sees a tiny pane after undock.
|
||
// Use the captured rendered rect as a backup so the floating window
|
||
// returns at roughly the same dimensions it had before docking.
|
||
content.style.width = sty.width || (r && r.width ? r.width + 'px' : '');
|
||
content.style.maxWidth = sty.maxWidth || '';
|
||
content.style.height = sty.height || (r && r.height ? r.height + 'px' : '');
|
||
content.style.maxHeight = sty.maxHeight || '';
|
||
content.style.borderRadius = sty.borderRadius || '';
|
||
content.style.transform = sty.transform || '';
|
||
content.style.margin = sty.margin || '';
|
||
// Re-anchor near the cursor so the panel feels peeled-off the edge.
|
||
// Use the captured rect width as the centering reference (CSS may not
|
||
// have resolved the inline width yet on this microtask). Fall back to
|
||
// the original captured left/top when no cursor coords are passed.
|
||
const refW = (r && r.width) || content.offsetWidth || 720;
|
||
const refH = (r && r.height) || content.offsetHeight || (window.innerHeight * 0.7);
|
||
const targetLeft = (typeof cx === 'number')
|
||
? Math.max(8, cx - refW / 2)
|
||
: (sty.left || (r ? r.left + 'px' : Math.max(8, (window.innerWidth - refW) / 2) + 'px'));
|
||
const targetTop = (typeof cy === 'number')
|
||
? Math.max(8, cy - 20)
|
||
: (sty.top || (r ? r.top + 'px' : Math.max(8, (window.innerHeight - refH) / 3) + 'px'));
|
||
content.style.left = (typeof targetLeft === 'number') ? targetLeft + 'px' : targetLeft;
|
||
content.style.top = (typeof targetTop === 'number') ? targetTop + 'px' : targetTop;
|
||
delete content._preDockSnapshot;
|
||
delete content._dockSuspended;
|
||
}
|
||
|
||
// Temporarily release a docked modal's body push (chat returns to full
|
||
// width) WITHOUT un-docking the window — used when a docked modal is
|
||
// MINIMIZED. The modal keeps its docked geometry + class + snapshot so
|
||
// resumeDock() can snap it right back when the chip is reopened. Returns the
|
||
// docked side, or null if the modal wasn't docked.
|
||
export function suspendDock(modal) {
|
||
const nodes = _resolveDockNodes(modal);
|
||
if (!nodes || !nodes.content) return null;
|
||
const content = nodes.content;
|
||
const side = content._dockSide
|
||
|| (modal.classList.contains('modal-left-docked') ? 'left'
|
||
: modal.classList.contains('modal-right-docked') ? 'right' : null);
|
||
if (!side) return null;
|
||
// Stop the close-watcher from tearing the dock fully down when `.hidden`
|
||
// is added by minimize — we want to keep the dock, just release the push.
|
||
if (modal._dockCloseWatcher) {
|
||
try { modal._dockCloseWatcher.obs && modal._dockCloseWatcher.obs.disconnect(); } catch (_) {}
|
||
try { modal._dockCloseWatcher.parentObs && modal._dockCloseWatcher.parentObs.disconnect(); } catch (_) {}
|
||
delete modal._dockCloseWatcher;
|
||
}
|
||
// Release the body push + restore the sidebar so the chat fills the width.
|
||
clearDockSide(side, modal);
|
||
if (content._preDockSnapshot?.collapsedSidebar && !_hasAnyOtherDockedWindow(modal)) {
|
||
_expandSidebarFromRail();
|
||
}
|
||
content._dockSuspended = side;
|
||
return side;
|
||
}
|
||
|
||
// Re-apply the body push (+ sidebar collapse + width var + close-watcher)
|
||
// for a modal that was suspendDock()'d, so RESTORING a minimized docked
|
||
// window nudges the chat back in. Idempotent via applyEdgeDock's guarded
|
||
// snapshot. Returns true if a suspended dock was resumed.
|
||
export function resumeDock(modal) {
|
||
const nodes = _resolveDockNodes(modal);
|
||
if (!nodes || !nodes.content) return false;
|
||
const content = nodes.content;
|
||
const side = content._dockSuspended;
|
||
if (!side) return false;
|
||
delete content._dockSuspended;
|
||
try { applyEdgeDock(modal, side); } catch (_) {}
|
||
return true;
|
||
}
|
||
|
||
// Wire right-edge snap detection into a drag session. Call this once per
|
||
// modal that should support docking. Returns an object the caller's drag
|
||
// handler can poll: { hovering(): boolean, commit(): void, release(): void }.
|
||
// The drag handler is responsible for calling onMove(clientX, clientY)
|
||
// during mousemove and commit() at mouseup if hovering().
|
||
export function makeRightDockController(modal, dockClass = 'modal-right-docked') {
|
||
return makeEdgeDockController(modal, 'right', dockClass);
|
||
}
|
||
|
||
// Read live rail+sidebar width — used as the LEFT "edge" for snap
|
||
// detection, since the visible left boundary the user can drag to is
|
||
// the nav, not x=0 (the rail covers 0..48 and the wide sidebar covers
|
||
// 0..~290 when open).
|
||
function _leftNavWidth() {
|
||
const rs = getComputedStyle(document.documentElement);
|
||
const rail = parseInt(rs.getPropertyValue('--icon-rail-w') || '48', 10) || 0;
|
||
const sb = parseInt(rs.getPropertyValue('--sidebar-w') || '0', 10) || 0;
|
||
return rail + sb;
|
||
}
|
||
|
||
// Generic edge-snap controller. `side` is 'left' or 'right'. Same pattern
|
||
// as the original right-only controller: caller drives onMove during
|
||
// mousemove, then calls commit()/release() at mouseup based on hovering().
|
||
export function makeEdgeDockController(modal, side = 'right', dockClass) {
|
||
if (!dockClass) dockClass = side === 'left' ? 'modal-left-docked' : 'modal-right-docked';
|
||
let _hoveringSnap = false;
|
||
const _distFromEdge = (cx) => {
|
||
if (side === 'left') return cx - _leftNavWidth();
|
||
return window.innerWidth - cx;
|
||
};
|
||
return {
|
||
onMove(cx, cy) {
|
||
if (modal.classList.contains(dockClass)) {
|
||
if (_distFromEdge(cx) > UNSNAP_PX) {
|
||
clearRightDock(modal, cx, cy, dockClass);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
const nearEdge = _distFromEdge(cx) <= SNAP_PX;
|
||
if (nearEdge !== _hoveringSnap) {
|
||
_hoveringSnap = nearEdge;
|
||
_showSnapHint(nearEdge, side);
|
||
}
|
||
return false;
|
||
},
|
||
hovering() { return _hoveringSnap; },
|
||
side() { return side; },
|
||
commit() {
|
||
_showSnapHint(false, side);
|
||
_hoveringSnap = false;
|
||
_applyDockInternal(modal, side, dockClass);
|
||
},
|
||
release() {
|
||
_showSnapHint(false, side);
|
||
_hoveringSnap = false;
|
||
},
|
||
};
|
||
}
|
||
|
||
(function _initSplitSeamIndicator() {
|
||
if (typeof document === 'undefined') return;
|
||
const stripe = document.createElement('div');
|
||
stripe.id = 'email-doc-split-seam';
|
||
stripe.style.position = 'fixed';
|
||
stripe.style.top = '0';
|
||
stripe.style.bottom = '0';
|
||
stripe.style.width = '10px';
|
||
stripe.style.cursor = 'col-resize';
|
||
stripe.style.zIndex = '9999';
|
||
stripe.style.background = 'linear-gradient(to right, transparent 0 3px, color-mix(in srgb, var(--accent, var(--red)) 35%, transparent) 3px 7px, transparent 7px 10px)';
|
||
stripe.style.pointerEvents = 'auto';
|
||
stripe.style.touchAction = 'none';
|
||
stripe.style.display = 'none';
|
||
stripe.title = 'Drag to resize email and draft';
|
||
|
||
const _activeLeftDockContent = () => {
|
||
const modal = document.querySelector(
|
||
'#email-lib-modal.modal-left-docked:not(.hidden), ' +
|
||
'#email-lib-modal.email-snap-left:not(.hidden), ' +
|
||
'.modal[id^="email-reader-"].modal-left-docked:not(.hidden), ' +
|
||
'.modal[id^="email-reader-"].email-snap-left:not(.hidden)'
|
||
);
|
||
return modal?.querySelector?.('.modal-content') || null;
|
||
};
|
||
|
||
const _position = () => {
|
||
const splitActive = document.body.classList.contains('email-doc-split-active')
|
||
&& document.body.classList.contains('doc-view')
|
||
&& window.innerWidth > 768;
|
||
if (!splitActive) { stripe.style.display = 'none'; return; }
|
||
const x = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--email-doc-split-right-x')) || 0;
|
||
if (!x) { stripe.style.display = 'none'; return; }
|
||
stripe.style.display = 'block';
|
||
stripe.style.left = (x - 5) + 'px';
|
||
};
|
||
|
||
const _dragTo = (clientX) => {
|
||
const content = _activeLeftDockContent();
|
||
if (!content) return;
|
||
const left = _leftNavRight();
|
||
const w = _clampEmailDocSplitWidth(clientX - left, left);
|
||
content._emailDocSplitUserW = w;
|
||
content.style.left = left + 'px';
|
||
content.style.width = w + 'px';
|
||
content.style.maxWidth = w + 'px';
|
||
_applyEmailDocSplitGeometry(left, w);
|
||
_position();
|
||
};
|
||
|
||
stripe.addEventListener('pointerdown', (e) => {
|
||
if (stripe.style.display === 'none') return;
|
||
e.preventDefault();
|
||
stripe.setPointerCapture?.(e.pointerId);
|
||
const prevCursor = document.body.style.cursor;
|
||
const prevUserSelect = document.body.style.userSelect;
|
||
document.body.style.cursor = 'col-resize';
|
||
document.body.style.userSelect = 'none';
|
||
document.body.classList.add('email-doc-split-resizing');
|
||
_dragTo(e.clientX);
|
||
const onMove = (ev) => {
|
||
ev.preventDefault();
|
||
_dragTo(ev.clientX);
|
||
};
|
||
const onUp = (ev) => {
|
||
try { stripe.releasePointerCapture?.(e.pointerId); } catch (_) {}
|
||
document.removeEventListener('pointermove', onMove, true);
|
||
document.removeEventListener('pointerup', onUp, true);
|
||
document.removeEventListener('pointercancel', onUp, true);
|
||
document.body.classList.remove('email-doc-split-resizing');
|
||
document.body.style.cursor = prevCursor;
|
||
document.body.style.userSelect = prevUserSelect;
|
||
const rightX = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--email-doc-split-right-x')) || 0;
|
||
const left = _leftNavRight();
|
||
if (rightX > left) _saveEmailDocSplitWidth(rightX - left);
|
||
ev.preventDefault();
|
||
};
|
||
document.addEventListener('pointermove', onMove, true);
|
||
document.addEventListener('pointerup', onUp, true);
|
||
document.addEventListener('pointercancel', onUp, true);
|
||
});
|
||
|
||
document.body.appendChild(stripe);
|
||
new MutationObserver(_position).observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||
new MutationObserver(_position).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
|
||
window.addEventListener('resize', _position);
|
||
_position();
|
||
})();
|