1485 lines
66 KiB
JavaScript
1485 lines
66 KiB
JavaScript
/**
|
||
* ModalManager — unified open/minimize/close behavior for tool modals.
|
||
*
|
||
* Goals:
|
||
* - Tab-down (swipe) and the `_` button MINIMIZE: modal hidden, JS state preserved.
|
||
* - The ✕ button CLOSES: tears down via the registered closeFn.
|
||
* - Sidebar/rail click handler: closed → open, minimized → restore, open → minimize.
|
||
* - Rail icon shows a "minimized" badge when state is held.
|
||
*
|
||
* Usage from a tool module:
|
||
*
|
||
* import * as Modals from './modalManager.js';
|
||
*
|
||
* // After building the modal element and adding it to the body:
|
||
* Modals.register('gallery-modal', {
|
||
* railBtnId: 'tool-gallery-btn',
|
||
* restoreFn: () => { ...whatever the tool needs to do when un-hiding... },
|
||
* closeFn: () => { ...full teardown — remove modal element etc... },
|
||
* });
|
||
*
|
||
* // From the sidebar/rail button click handler:
|
||
* if (!Modals.toggle('gallery-modal')) {
|
||
* // No registered modal — build and open it fresh.
|
||
* openGallery();
|
||
* }
|
||
*/
|
||
|
||
import { previewZoneAt, clearPreview, snapModalToZone } from './tileManager.js';
|
||
import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js';
|
||
|
||
const _state = new Map(); // id -> { restoreFn, closeFn, railBtnId, isMinimized }
|
||
|
||
const _rememberedDockKey = (id) => `odysseus-modal-remembered-dock-${id}`;
|
||
function _rememberDock(id, side) {
|
||
if (!id || !side) return;
|
||
try { localStorage.setItem(_rememberedDockKey(id), side); } catch (_) {}
|
||
}
|
||
function _forgetDock(id) {
|
||
if (!id) return;
|
||
try { localStorage.removeItem(_rememberedDockKey(id)); } catch (_) {}
|
||
}
|
||
function _getRememberedDock(id) {
|
||
try {
|
||
const side = localStorage.getItem(_rememberedDockKey(id));
|
||
return (side === 'left' || side === 'right') ? side : null;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
function _applyRememberedDock(id) {
|
||
const side = _getRememberedDock(id);
|
||
if (!side) return;
|
||
const modal = document.getElementById(id);
|
||
if (!modal || modal.classList.contains('hidden') || modal.classList.contains('modal-minimized')) return;
|
||
try { applyEdgeDock(modal, side); } catch (e) { console.warn('apply remembered dock failed', e); }
|
||
}
|
||
|
||
// Monotonic stacking counter so the most-recently-surfaced tool window always
|
||
// sits on top. Tool modals otherwise carry fixed CSS z-indexes (base .modal
|
||
// = 250, cookbook/theme = 260, …), so restoring one from the dock could leave
|
||
// it BEHIND an already-open tool with a higher static z-index. Start above
|
||
// those statics and bump on every bring-to-front.
|
||
let _modalTopZ = 300;
|
||
function _bringToFront(modal) {
|
||
if (modal) modal.style.setProperty('z-index', String(++_modalTopZ), 'important');
|
||
}
|
||
|
||
function _emitModalOpened(id, modal) {
|
||
try {
|
||
window.dispatchEvent(new CustomEvent('odysseus:modal-opened', {
|
||
detail: { id, modal },
|
||
}));
|
||
} catch (_) {}
|
||
}
|
||
|
||
function _setBadge(btnIds, on) {
|
||
if (!btnIds) return;
|
||
const ids = Array.isArray(btnIds) ? btnIds : [btnIds];
|
||
for (const id of ids) {
|
||
const btn = document.getElementById(id);
|
||
if (btn) btn.classList.toggle('rail-minimized', on);
|
||
}
|
||
}
|
||
|
||
// ── Bottom dock — visible chip per minimized modal ──
|
||
|
||
const _LABELS = {
|
||
'cookbook-modal': { label: 'Cookbook', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>' },
|
||
'calendar-modal': { label: 'Calendar', icon: 'M3 4h18v18H3zM16 2v4M8 2v4M3 10h18' },
|
||
'gallery-modal': { label: 'Gallery', icon: 'M3 3h18v18H3zM8.5 8.5l3 3M21 15l-5-5L5 21' },
|
||
'tasks-modal': { label: 'Tasks', icon: 'M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' },
|
||
'doclib-modal': { label: 'Library', icon: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2zM9 7h6M9 11h4' },
|
||
// Full SVG markup (not a single path-d) — the rounded-lobe brain needs
|
||
// three sub-paths, which the dock renderer supports when the icon string
|
||
// contains '<'.
|
||
'memory-modal': { label: 'Brain', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/></svg>' },
|
||
'notes-panel': { label: 'Notes', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h10l4 4v14H5z"/><path d="M15 3v5h5"/><path d="M8 17.5 15.5 10l2.5 2.5L10.5 20H8z"/></svg>' },
|
||
'email-lib-modal': { label: 'Email', icon: 'M2 4h20v16H2zM22 7l-9.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7' },
|
||
// The Prompt window (characters / inject / group). Syringe = "prompt" icon,
|
||
// matching its title bar. Full SVG markup (multi-path) per the dock renderer.
|
||
'custom-preset-modal': { label: 'Prompt', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/></svg>' },
|
||
'research-overlay': { label: 'Research', icon: 'M3 11a8 8 0 1 0 16 0a8 8 0 1 0-16 0M21 21l-4.35-4.35M11 8L11 14M8 11L14 11' },
|
||
'theme-modal': { label: 'Theme', icon: 'M12 2a10 10 0 1 0 10 10c0-1-1-2-2-2h-2a2 2 0 0 1 0-4h1a2 2 0 0 0 0-4 10 10 0 0 0-7-2zM7.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM12 7.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM16.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z' },
|
||
'compare-model-overlay': { label: 'Compare', icon: 'M8 3v18M16 3v18M3 8h5M16 16h5' },
|
||
'settings-modal': { label: 'Settings', icon: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.4.4.62.94.6 1.51V11a2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z' },
|
||
'ge-shortcuts-modal':{ label: 'Shortcuts', icon: 'M2 6h20v12H2zM6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10' },
|
||
// Virtual id — the doc editor pane isn't a modal, but it minimizes to a
|
||
// chip via the same dock infrastructure.
|
||
'doc-panel': { label: 'Document', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8' },
|
||
};
|
||
|
||
function _ensureDock() {
|
||
let dock = document.getElementById('minimized-dock');
|
||
if (dock) return dock;
|
||
dock = document.createElement('div');
|
||
dock.id = 'minimized-dock';
|
||
document.body.appendChild(dock);
|
||
_loadDockState();
|
||
return dock;
|
||
}
|
||
|
||
// Manual order users can rearrange via drag.
|
||
let _dockOrder = [];
|
||
// Per-chip free-floating position (mobile only). When set, the chip renders
|
||
// at this absolute viewport position instead of inside the dock flex layout.
|
||
const _chipPositions = new Map(); // modalId -> { left, top }
|
||
// User-dragged position of the dock pad itself (both desktop and mobile).
|
||
// Remembered across minimize→restore→minimize cycles so the dock reappears
|
||
// where the user last parked it instead of snapping back to bottom-center.
|
||
// null means "use the CSS default position".
|
||
let _dockPos = null; // { left, top } | null
|
||
// Snapshot of which ids had a rendered chip after the last _renderDock pass.
|
||
// Lets us detect "a brand-new chip just arrived" so we can re-dock the
|
||
// existing free-positioned chain to absorb the newcomer.
|
||
const _renderedChipIds = new Set();
|
||
|
||
// ── Persistence (mobile dock + free-chip positions) ──
|
||
const _DOCK_STORAGE_KEY = 'odysseus.mobileDockState.v1';
|
||
let _dockStateLoaded = false;
|
||
|
||
function _saveDockState() {
|
||
// The dock-pad position is remembered on every platform. The per-chip
|
||
// free-float positions are still a mobile-only gesture, so we only have
|
||
// entries to persist there on touch layouts — but writing the (empty)
|
||
// map on desktop is harmless.
|
||
try {
|
||
const state = {
|
||
dockPos: _dockPos,
|
||
chips: Object.fromEntries(_chipPositions),
|
||
};
|
||
localStorage.setItem(_DOCK_STORAGE_KEY, JSON.stringify(state));
|
||
} catch {}
|
||
}
|
||
|
||
function _loadDockState() {
|
||
if (_dockStateLoaded) return;
|
||
_dockStateLoaded = true;
|
||
try {
|
||
const raw = localStorage.getItem(_DOCK_STORAGE_KEY);
|
||
if (!raw) return;
|
||
const state = JSON.parse(raw);
|
||
if (state.chips && typeof state.chips === 'object') {
|
||
for (const [id, pos] of Object.entries(state.chips)) {
|
||
if (pos && typeof pos.left === 'number' && typeof pos.top === 'number') {
|
||
// Clamp to current viewport in case orientation/size changed
|
||
const left = Math.max(4, Math.min(window.innerWidth - 44, pos.left));
|
||
const top = Math.max(4, Math.min(window.innerHeight - 44, pos.top));
|
||
_chipPositions.set(id, { left, top });
|
||
}
|
||
}
|
||
}
|
||
// Dock position — accept the new {left,top} shape, and fall back to the
|
||
// legacy dockLeft/dockTop strings written by older builds.
|
||
let dp = state.dockPos;
|
||
if (!dp && state.dockLeft && state.dockTop) {
|
||
dp = { left: parseFloat(state.dockLeft), top: parseFloat(state.dockTop) };
|
||
}
|
||
if (dp && Number.isFinite(dp.left) && Number.isFinite(dp.top)) {
|
||
// Clamp into the current viewport so a saved spot from a larger
|
||
// window doesn't strand the dock off-screen.
|
||
_dockPos = {
|
||
left: Math.max(8, Math.min(window.innerWidth - 60, dp.left)),
|
||
top: Math.max(8, Math.min(window.innerHeight - 40, dp.top)),
|
||
};
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// Push the remembered dock position onto the live element. Called on every
|
||
// render because the empty-dock branch wipes inline styles via cssText='',
|
||
// which would otherwise drop the position the moment the dock clears.
|
||
function _applyDockPos(dock) {
|
||
if (!_dockPos) return;
|
||
dock.style.left = `${_dockPos.left}px`;
|
||
dock.style.top = `${_dockPos.top}px`;
|
||
dock.style.right = 'auto';
|
||
dock.style.bottom = 'auto';
|
||
dock.style.transform = 'none';
|
||
}
|
||
|
||
// True when `chipRect` is close enough to the dock's current location that
|
||
// dropping a free-dragged chip there should re-attach it to the chain.
|
||
function _nearDock(chipRect, dock) {
|
||
const dr = dock.getBoundingClientRect();
|
||
// Dock may be empty (all chips detached) — fall back to its style position
|
||
// so the user can still aim at "where the chain lives".
|
||
let cx, cy;
|
||
if (dr.width > 0 && dr.height > 0) {
|
||
cx = dr.left + dr.width / 2;
|
||
cy = dr.top + dr.height / 2;
|
||
} else {
|
||
const fallbackLeft = parseFloat(dock.style.left) || (window.innerWidth / 2);
|
||
const fallbackTop = parseFloat(dock.style.top) || (window.innerHeight - 32);
|
||
cx = fallbackLeft;
|
||
cy = fallbackTop;
|
||
}
|
||
const chipCx = chipRect.left + chipRect.width / 2;
|
||
const chipCy = chipRect.top + chipRect.height / 2;
|
||
return Math.hypot(chipCx - cx, chipCy - cy) < REDOCK_RADIUS;
|
||
}
|
||
|
||
function _renderDock() {
|
||
const dock = document.getElementById('minimized-dock');
|
||
if (!dock) return;
|
||
const minimizedIds = [..._state.entries()].filter(([_, s]) => s.isMinimized).map(([id]) => id);
|
||
// On mobile we ALSO keep chips around for any modal that's been
|
||
// free-positioned on screen — even while it's open — so the chip acts as
|
||
// a persistent toggle (tap to minimize, tap again to restore).
|
||
const isMobile = window.innerWidth <= 768;
|
||
const persistentIds = isMobile
|
||
? [..._state.entries()].filter(([id, _]) => _chipPositions.has(id)).map(([id]) => id)
|
||
: [];
|
||
const allIds = Array.from(new Set([...minimizedIds, ...persistentIds]));
|
||
// Keep _dockOrder for every modal still alive in _state — even when it's
|
||
// currently restored (not in allIds). That way re-minimizing a chip lands
|
||
// back in its original slot instead of being pushed to the right edge.
|
||
// Ids only fall out of _dockOrder once the modal is fully closed
|
||
// (close() → _state.delete()).
|
||
_dockOrder = _dockOrder.filter(id => _state.has(id));
|
||
for (const id of allIds) {
|
||
if (!_dockOrder.includes(id)) _dockOrder.push(id);
|
||
}
|
||
|
||
// Capture any custom data-* attributes (e.g. data-tab-num) BEFORE we
|
||
// remove old chips, so they can be restored on the rebuilt chips.
|
||
// Without this, external systems that stamp attributes on chips
|
||
// (like emailLibrary's slot-number badge) see the attribute wiped on
|
||
// every re-render — most visibly after a chain drag, when chips are
|
||
// at body level and get swept by the next render.
|
||
const oldData = new Map();
|
||
document.querySelectorAll('.minimized-dock-chip').forEach(c => {
|
||
const id = c.dataset.modalId;
|
||
if (!id) return;
|
||
const data = {};
|
||
for (const a of c.attributes) {
|
||
if (a.name.startsWith('data-') && a.name !== 'data-modal-id') {
|
||
data[a.name] = a.value;
|
||
}
|
||
}
|
||
if (Object.keys(data).length) oldData.set(id, data);
|
||
});
|
||
|
||
// Sweep any free-positioned chips currently on <body> first — they'll be
|
||
// recreated below if still alive, but if _dockOrder ended up empty (e.g.
|
||
// the chain close-all just finished) we need to clear them here too.
|
||
// Previously this sweep only ran in the non-empty branch, leaving the
|
||
// last-rendered chip orphaned on body after the final close.
|
||
document.querySelectorAll('body > .minimized-dock-chip').forEach(c => c.remove());
|
||
|
||
// _dockOrder keeps every alive modal's slot (so order is stable across
|
||
// restore→minimize cycles), but we only render chips for ids currently
|
||
// in allIds (minimized or persistent).
|
||
const renderIds = _dockOrder.filter(id => allIds.includes(id));
|
||
|
||
// If a brand-new chip is joining and the existing chips are already
|
||
// free-positioned at body level (e.g. previously chain-dropped), the
|
||
// new chip would land in the dock by itself — visually unlinking the
|
||
// group. Collapse everyone back into the dock so the chain stays
|
||
// together as a single group at the new size.
|
||
const newIds = renderIds.filter(id => !_renderedChipIds.has(id));
|
||
if (newIds.length && _chipPositions.size) {
|
||
_chipPositions.clear();
|
||
_saveDockState();
|
||
}
|
||
if (!renderIds.length) {
|
||
dock.innerHTML = '';
|
||
// Scrub ALL drag/animation inline styles, then re-apply display:none so
|
||
// the empty dock stays hidden until new chips arrive.
|
||
dock.style.cssText = '';
|
||
dock.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
// FLIP: capture old positions
|
||
const oldRects = new Map();
|
||
dock.querySelectorAll('.minimized-dock-chip').forEach(c => {
|
||
oldRects.set(c.dataset.modalId, c.getBoundingClientRect());
|
||
});
|
||
|
||
dock.style.display = '';
|
||
// Re-assert the remembered position — the empty-dock branch clears inline
|
||
// styles, so without this the dock would snap back to its CSS default the
|
||
// first time it re-populates after every chip was restored.
|
||
_applyDockPos(dock);
|
||
dock.innerHTML = '';
|
||
for (const id of renderIds) {
|
||
const meta = _LABELS[id] || { label: id, icon: '' };
|
||
const chip = document.createElement('button');
|
||
chip.type = 'button';
|
||
chip.className = 'minimized-dock-chip';
|
||
chip.dataset.modalId = id;
|
||
chip.title = `Restore ${meta.label}`;
|
||
// Restore any external data-* attributes the previous chip carried
|
||
// (e.g. emailLibrary's data-tab-num slot-number badge).
|
||
const prevAttrs = oldData.get(id);
|
||
if (prevAttrs) {
|
||
for (const [name, val] of Object.entries(prevAttrs)) {
|
||
chip.setAttribute(name, val);
|
||
}
|
||
}
|
||
// icon can be either a path-d string (built-in modals) or a complete
|
||
// <svg>...</svg> markup (custom registrants like FX popups).
|
||
const iconHtml = (typeof meta.icon === 'string' && meta.icon.includes('<'))
|
||
? meta.icon
|
||
: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${meta.icon}"/></svg>`;
|
||
chip.innerHTML = `
|
||
${iconHtml}
|
||
<span class="minimized-dock-label">${meta.label}</span>
|
||
<span class="minimized-dock-x" title="Close">×</span>
|
||
`;
|
||
chip.addEventListener('click', (e) => {
|
||
if (chip._wasDragging) { chip._wasDragging = false; return; }
|
||
if (e.target.classList.contains('minimized-dock-x')) {
|
||
e.stopPropagation();
|
||
close(id);
|
||
return;
|
||
}
|
||
// Tap toggles: if the modal is currently minimized, restore it. If
|
||
// it's already open (chip is being kept around because it was free-
|
||
// positioned on mobile), minimize it.
|
||
const s = _state.get(id);
|
||
if (s && !s.isMinimized) {
|
||
minimize(id);
|
||
} else {
|
||
restore(id);
|
||
}
|
||
});
|
||
_wireChipDrag(chip, dock);
|
||
// Visually mark whether the modal is currently open (chip-active) so
|
||
// the user can see at a glance which floating chip belongs to the
|
||
// visible modal.
|
||
const st = _state.get(id);
|
||
if (st && !st.isMinimized) chip.classList.add('chip-active');
|
||
// Free-positioned chips on mobile live OUTSIDE the dock so the dock's
|
||
// transform: translateX(-50%) doesn't shift their `position: fixed`
|
||
// coords. Dock-resident chips render as normal flex children.
|
||
const pos = _chipPositions.get(id);
|
||
if (pos && window.innerWidth <= 768) {
|
||
chip.style.setProperty('position', 'fixed', 'important');
|
||
chip.style.setProperty('left', `${pos.left}px`, 'important');
|
||
chip.style.setProperty('top', `${pos.top}px`, 'important');
|
||
chip.style.setProperty('z-index', '999', 'important');
|
||
document.body.appendChild(chip);
|
||
} else {
|
||
dock.appendChild(chip);
|
||
}
|
||
}
|
||
|
||
// FLIP: animate from old → new positions
|
||
dock.querySelectorAll('.minimized-dock-chip').forEach(c => {
|
||
const oldRect = oldRects.get(c.dataset.modalId);
|
||
if (!oldRect) return;
|
||
const newRect = c.getBoundingClientRect();
|
||
const dx = oldRect.left - newRect.left;
|
||
const dy = oldRect.top - newRect.top;
|
||
if (dx || dy) {
|
||
c.style.transform = `translate(${dx}px, ${dy}px)`;
|
||
c.style.transition = 'none';
|
||
requestAnimationFrame(() => {
|
||
c.style.transition = 'transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||
c.style.transform = '';
|
||
});
|
||
}
|
||
});
|
||
|
||
// Snapshot which ids are rendered now so the next render can tell when
|
||
// a brand-new chip is joining.
|
||
_renderedChipIds.clear();
|
||
for (const id of renderIds) _renderedChipIds.add(id);
|
||
}
|
||
|
||
// Lazy-build the magnetic close target. Horizontally centered; its vertical
|
||
// edge is set per-drag in _positionTrashZoneOpposite so the X always lands
|
||
// on the side opposite the chip the user is dragging.
|
||
function _ensureTrashZone() {
|
||
let z = document.getElementById('dock-trash-zone');
|
||
if (z) return z;
|
||
z = document.createElement('div');
|
||
z.id = 'dock-trash-zone';
|
||
z.innerHTML =
|
||
'<span class="whirlpool"></span>' +
|
||
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||
document.body.appendChild(z);
|
||
return z;
|
||
}
|
||
|
||
// Trigger the burst animation on the trash zone, then clean up the class so
|
||
// the next drag-in shows the normal spinning ring rather than a stuck burst.
|
||
function _trashBurst() {
|
||
const z = document.getElementById('dock-trash-zone');
|
||
if (!z) return;
|
||
z.classList.add('dropping');
|
||
setTimeout(() => { z.classList.remove('dropping'); }, 360);
|
||
}
|
||
|
||
// Place the X on the opposite vertical half from the chip so the user always
|
||
// has somewhere to drag toward. Locked at drag-start so it doesn't flip while
|
||
// the user is mid-gesture.
|
||
function _positionTrashZoneOpposite(z, chipTop, chipHeight) {
|
||
const chipMid = chipTop + chipHeight / 2;
|
||
if (chipMid > window.innerHeight / 2) {
|
||
z.style.top = 'max(24px, env(safe-area-inset-top))';
|
||
z.style.bottom = 'auto';
|
||
z.dataset.side = 'top';
|
||
} else {
|
||
z.style.top = 'auto';
|
||
z.style.bottom = 'max(24px, env(safe-area-inset-bottom))';
|
||
z.dataset.side = 'bottom';
|
||
}
|
||
}
|
||
|
||
// ── Drag behavior ──
|
||
// • Mobile dock chips → drag the entire dock as a unit; long-press peels one chip out.
|
||
// • Mobile free-floating chips → free-drag puck (drag UP to the trash zone to close).
|
||
// • Desktop middle chips → reorder within the dock (FLIP magnetic slide)
|
||
// • Desktop edge chips (or single chip) → drag the entire dock as a unit
|
||
const LONG_PRESS_MS = 380;
|
||
const REDOCK_RADIUS = 90;
|
||
|
||
function _detachToFreeDrag(chip, dock, chipStartLeft, chipStartTop) {
|
||
if (chip.parentElement === dock) {
|
||
chip.style.setProperty('position', 'fixed', 'important');
|
||
chip.style.setProperty('left', `${chipStartLeft}px`, 'important');
|
||
chip.style.setProperty('top', `${chipStartTop}px`, 'important');
|
||
document.body.appendChild(chip);
|
||
}
|
||
chip.classList.add('chip-free-drag');
|
||
}
|
||
|
||
// ── Chain physics ──
|
||
// When the user drags one chip from a multi-chip dock, the other chips
|
||
// spring-follow it like a snake. Each follower targets its predecessor's
|
||
// position plus a fixed offset (their original spacing), so the chain
|
||
// stretches and bunches naturally as the head moves.
|
||
function _initChainPhysics(grabbedChip, dock, startX, startY) {
|
||
// Use the canonical _dockOrder so the chain works whether chips are
|
||
// currently parented to the dock or living free at body level.
|
||
const chipEls = _dockOrder
|
||
.map(id => document.querySelector(`.minimized-dock-chip[data-modal-id="${id}"]`))
|
||
.filter(Boolean);
|
||
// Fallback to dock children if _dockOrder is somehow empty.
|
||
const dockChips = chipEls.length >= 2 ? chipEls : [...dock.querySelectorAll('.minimized-dock-chip')];
|
||
const grabbedIdx = dockChips.indexOf(grabbedChip);
|
||
if (grabbedIdx < 0 || dockChips.length < 2) return null;
|
||
|
||
const links = dockChips.map((c, i) => {
|
||
const r = c.getBoundingClientRect();
|
||
return {
|
||
chip: c, dockIdx: i,
|
||
width: r.width, height: r.height,
|
||
origX: r.left, origY: r.top,
|
||
x: r.left, y: r.top,
|
||
vx: 0, vy: 0,
|
||
pred: null,
|
||
};
|
||
});
|
||
// Build a single-line chain ordered outward from the head, alternating
|
||
// sides so chips closer to the grabbed one come first. Each link's
|
||
// predecessor is the chip immediately before it in chain order — that
|
||
// makes the whole chain a single tail rather than a Y where two strands
|
||
// dangle from the head.
|
||
const order = [grabbedIdx];
|
||
let lo = grabbedIdx - 1, hi = grabbedIdx + 1;
|
||
while (lo >= 0 || hi < links.length) {
|
||
if (lo >= 0) { order.push(lo); lo--; }
|
||
if (hi < links.length) { order.push(hi); hi++; }
|
||
}
|
||
for (let pos = 1; pos < order.length; pos++) {
|
||
links[order[pos]].pred = links[order[pos - 1]];
|
||
}
|
||
|
||
// Detach every chip to body so the dock element can't fight our positioning.
|
||
for (const l of links) {
|
||
l.chip.style.position = 'fixed';
|
||
l.chip.style.left = `${l.x}px`;
|
||
l.chip.style.top = `${l.y}px`;
|
||
l.chip.style.margin = '0';
|
||
l.chip.style.transition = 'none';
|
||
l.chip.style.animation = 'none';
|
||
l.chip.style.zIndex = (l.dockIdx === grabbedIdx) ? '10001' : '10000';
|
||
document.body.appendChild(l.chip);
|
||
}
|
||
// Hide the now-empty dock element itself so it doesn't show a blank pad.
|
||
dock.style.opacity = '0';
|
||
|
||
const head = links[grabbedIdx];
|
||
// Use a uniform link spacing — slightly tighter than a chip-width so the
|
||
// chain looks like a connected strand rather than a sparse row.
|
||
const linkSpacing = head.width + 2;
|
||
return {
|
||
links, grabbedIdx, order,
|
||
linkSpacing,
|
||
fingerOffsetX: startX - head.origX,
|
||
fingerOffsetY: startY - head.origY,
|
||
targetX: head.origX, targetY: head.origY,
|
||
// Seed the trail direction with the original layout direction (chips
|
||
// were arranged left-to-right; chain at rest points right).
|
||
trailDirX: 1, trailDirY: 0,
|
||
raf: 0, overTrash: false,
|
||
};
|
||
}
|
||
|
||
function _stepChain(state, trashZone, captureRadius) {
|
||
const HEAD_EASE = 0.85;
|
||
// Followers use critically-damped easing (direct lerp, no velocity) rather
|
||
// than spring + damp — springs oscillate when chasing a moving target, and
|
||
// with a chain of 3+ each link amplifies the wobble.
|
||
const FOLLOWER_EASE = 0.32;
|
||
// Trail direction tracks the head's RECENT movement (smoothed). Reversing
|
||
// direction mid-drag flips the trail; pausing keeps the last orientation.
|
||
// Seeded with the original horizontal layout in _initChainPhysics so the
|
||
// chain has a sensible direction even before the first frame.
|
||
const head = state.links[state.grabbedIdx];
|
||
const hVx = (state.targetX - head.x) * HEAD_EASE;
|
||
const hVy = (state.targetY - head.y) * HEAD_EASE;
|
||
const vMag = Math.hypot(hVx, hVy);
|
||
// Dead zone — micro-velocities from finger drift don't reorient the trail.
|
||
// Only intentional, sustained motion updates direction.
|
||
if (vMag > 2.0) {
|
||
const nx = -hVx / vMag;
|
||
const ny = -hVy / vMag;
|
||
const EASE = 0.12;
|
||
state.trailDirX += (nx - state.trailDirX) * EASE;
|
||
state.trailDirY += (ny - state.trailDirY) * EASE;
|
||
}
|
||
// Renormalize so spacing stays consistent even mid-rotation.
|
||
const tMag = Math.hypot(state.trailDirX, state.trailDirY) || 1;
|
||
const dirX = state.trailDirX / tMag;
|
||
const dirY = state.trailDirY / tMag;
|
||
const spacing = state.linkSpacing;
|
||
for (const i of state.order) {
|
||
const l = state.links[i];
|
||
if (i === state.grabbedIdx) {
|
||
l.x += (state.targetX - l.x) * HEAD_EASE;
|
||
l.y += (state.targetY - l.y) * HEAD_EASE;
|
||
} else {
|
||
const tx = l.pred.x + dirX * spacing;
|
||
const ty = l.pred.y + dirY * spacing;
|
||
l.x += (tx - l.x) * FOLLOWER_EASE;
|
||
l.y += (ty - l.y) * FOLLOWER_EASE;
|
||
}
|
||
}
|
||
for (const l of state.links) {
|
||
l.chip.style.left = `${l.x}px`;
|
||
l.chip.style.top = `${l.y}px`;
|
||
}
|
||
if (trashZone) {
|
||
const head = state.links[state.grabbedIdx];
|
||
const tz = trashZone.getBoundingClientRect();
|
||
const tzcx = tz.left + tz.width / 2;
|
||
const tzcy = tz.top + tz.height / 2;
|
||
const hcx = head.x + head.width / 2;
|
||
const hcy = head.y + head.height / 2;
|
||
const dist = Math.hypot(hcx - tzcx, hcy - tzcy);
|
||
// Trash zone is shown for the whole drag; only .engaged tracks proximity.
|
||
const inZone = dist < captureRadius;
|
||
if (inZone !== state.overTrash) {
|
||
state.overTrash = inZone;
|
||
trashZone.classList.toggle('engaged', inZone);
|
||
}
|
||
}
|
||
}
|
||
|
||
function _wireChipDrag(chip, dock) {
|
||
let startX = 0, startY = 0, dragging = false;
|
||
let dragMode = null; // 'reorder' | 'move-dock' | 'free' | 'chain'
|
||
let dockStartLeft = 0, dockStartTop = 0;
|
||
let chipStartLeft = 0, chipStartTop = 0;
|
||
let trashZone = null;
|
||
let overTrash = false;
|
||
let activePointerId = null;
|
||
let longPressTimer = null;
|
||
let longPressVisual = null;
|
||
let chainState = null;
|
||
let chipSnapZone = null; // desktop: snap zone under the cursor while dragging a chip
|
||
const CAPTURE_RADIUS = 70;
|
||
|
||
const cancelLongPress = () => {
|
||
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
||
if (longPressVisual) { clearTimeout(longPressVisual); longPressVisual = null; }
|
||
chip.classList.remove('chip-long-press');
|
||
};
|
||
|
||
const onPointerDown = (e) => {
|
||
if (e.target.classList.contains('minimized-dock-x')) return;
|
||
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
||
if (activePointerId !== null) return;
|
||
startX = e.clientX; startY = e.clientY; dragging = false;
|
||
activePointerId = e.pointerId;
|
||
// Flag global "a chip is being touched" so other touch handlers (e.g.
|
||
// the chat container's edge-swipe-to-open-sidebar) know to stand down.
|
||
// Set on pointerdown rather than waiting for the drag threshold so the
|
||
// chat container's touchstart, which fires roughly simultaneously, sees
|
||
// it during its own decision pass.
|
||
window._chipDragging = true;
|
||
|
||
const cr = chip.getBoundingClientRect();
|
||
chipStartLeft = cr.left;
|
||
chipStartTop = cr.top;
|
||
|
||
const onTouch = (e.pointerType === 'touch' || window.innerWidth <= 768);
|
||
if (onTouch) {
|
||
const isFree = _chipPositions.has(chip.dataset.modalId);
|
||
trashZone = _ensureTrashZone();
|
||
overTrash = false;
|
||
// Decide drag mode purely by chip count, not by whether this chip is
|
||
// currently dock-resident or free. As long as there are 2+ chips, the
|
||
// chain owns the gesture so the group stays grouped.
|
||
// Count every currently-rendered chip, whether it lives in the dock
|
||
// or free-positioned at body level after a chain drop. Counting only
|
||
// dock children meant a chained-and-released group could no longer
|
||
// re-activate chain mode on the next drag.
|
||
const totalChips =
|
||
dock.querySelectorAll('.minimized-dock-chip').length +
|
||
document.querySelectorAll('body > .minimized-dock-chip').length;
|
||
if (totalChips >= 2) {
|
||
dragMode = 'chain';
|
||
_positionTrashZoneOpposite(trashZone, chipStartTop, chip.offsetHeight);
|
||
// Long-press a chained chip to peel it off as a single free puck —
|
||
// movement before the timer fires cancels and starts chain physics.
|
||
// Defer the visual pulse so quick taps don't see a scale-up bounce.
|
||
longPressVisual = setTimeout(() => {
|
||
longPressVisual = null;
|
||
chip.classList.add('chip-long-press');
|
||
}, 180);
|
||
longPressTimer = setTimeout(() => {
|
||
longPressTimer = null;
|
||
if (dragging) return; // chain already engaged
|
||
dragMode = 'free';
|
||
chip.classList.remove('chip-long-press');
|
||
_detachToFreeDrag(chip, dock, chipStartLeft, chipStartTop);
|
||
_chipPositions.set(chip.dataset.modalId, { left: chipStartLeft, top: chipStartTop });
|
||
_saveDockState();
|
||
chip._wasDragging = true;
|
||
setTimeout(() => { chip._wasDragging = false; }, 350);
|
||
if (navigator.vibrate) { try { navigator.vibrate(15); } catch {} }
|
||
}, LONG_PRESS_MS);
|
||
} else if (isFree) {
|
||
// Lone free chip — single-puck drag.
|
||
dragMode = 'free';
|
||
_positionTrashZoneOpposite(trashZone, chipStartTop, chip.offsetHeight);
|
||
} else {
|
||
// Lone dock chip — keep move-dock so the user can reposition the
|
||
// dock pad (long-press still promotes to free-drag).
|
||
dragMode = 'move-dock';
|
||
const dr = dock.getBoundingClientRect();
|
||
dockStartLeft = dr.left;
|
||
dockStartTop = dr.top;
|
||
_positionTrashZoneOpposite(trashZone, dr.top, dr.height);
|
||
longPressVisual = setTimeout(() => {
|
||
longPressVisual = null;
|
||
chip.classList.add('chip-long-press');
|
||
}, 180);
|
||
longPressTimer = setTimeout(() => {
|
||
longPressTimer = null;
|
||
if (dragging) return; // committed to move-dock already
|
||
dragMode = 'free';
|
||
chip.classList.remove('chip-long-press');
|
||
_detachToFreeDrag(chip, dock, chipStartLeft, chipStartTop);
|
||
_positionTrashZoneOpposite(trashZone, chipStartTop, chip.offsetHeight);
|
||
// Stick the chip where it was so it stays detached even if the user
|
||
// lifts their finger without dragging.
|
||
_chipPositions.set(chip.dataset.modalId, { left: chipStartLeft, top: chipStartTop });
|
||
_saveDockState();
|
||
// Suppress the trailing click so this hold doesn't immediately
|
||
// restore/minimize the modal under the finger.
|
||
chip._wasDragging = true;
|
||
setTimeout(() => { chip._wasDragging = false; }, 350);
|
||
if (navigator.vibrate) { try { navigator.vibrate(15); } catch {} }
|
||
}, LONG_PRESS_MS);
|
||
}
|
||
document.addEventListener('pointermove', onPointerMove);
|
||
document.addEventListener('pointerup', onPointerUp, { once: true });
|
||
document.addEventListener('pointercancel', onPointerUp, { once: true });
|
||
return;
|
||
}
|
||
|
||
// Desktop — reorder vs move-dock
|
||
const chips = [...dock.querySelectorAll('.minimized-dock-chip')];
|
||
const idx = chips.indexOf(chip);
|
||
const isEdge = idx === 0 || idx === chips.length - 1;
|
||
dragMode = (isEdge && chips.length >= 2) ? 'move-dock' : (chips.length >= 2 ? 'reorder' : 'move-dock');
|
||
if (chips.length === 1) dragMode = 'move-dock';
|
||
if (dragMode === 'move-dock') {
|
||
const dr = dock.getBoundingClientRect();
|
||
dockStartLeft = dr.left;
|
||
dockStartTop = dr.top;
|
||
}
|
||
chip.setPointerCapture(e.pointerId);
|
||
chip.addEventListener('pointermove', onPointerMove);
|
||
chip.addEventListener('pointerup', onPointerUp, { once: true });
|
||
chip.addEventListener('pointercancel', onPointerUp, { once: true });
|
||
};
|
||
|
||
const onPointerMove = (e) => {
|
||
const dx = e.clientX - startX;
|
||
const dy = e.clientY - startY;
|
||
// Touch fingers drift a few pixels even on a "still" tap, so the touch
|
||
// threshold is generous — otherwise a tap-to-restore reads as a drag
|
||
// and the click gets eaten when the chain settles.
|
||
const DRAG_THRESHOLD = (e.pointerType === 'touch' || window.innerWidth <= 768) ? 14 : 5;
|
||
if (!dragging && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||
if (!dragging) {
|
||
dragging = true;
|
||
cancelLongPress();
|
||
// Reveal the trash X as soon as a drag begins so the user always
|
||
// sees the close target, regardless of distance. The .engaged
|
||
// state still tracks proximity to the zone center.
|
||
if (trashZone) trashZone.classList.add('visible');
|
||
if (dragMode === 'reorder') {
|
||
chip.classList.add('dragging');
|
||
} else if (dragMode === 'free') {
|
||
chip.classList.add('chip-free-drag');
|
||
} else if (dragMode === 'chain') {
|
||
chainState = _initChainPhysics(chip, dock, startX, startY);
|
||
if (chainState) {
|
||
const stepLoop = () => {
|
||
if (!chainState) return;
|
||
_stepChain(chainState, trashZone, CAPTURE_RADIUS);
|
||
overTrash = chainState.overTrash;
|
||
chainState.raf = requestAnimationFrame(stepLoop);
|
||
};
|
||
chainState.raf = requestAnimationFrame(stepLoop);
|
||
} else {
|
||
// Init failed for some reason — fall back to move-dock so the
|
||
// user's gesture isn't dropped on the floor.
|
||
dragMode = 'move-dock';
|
||
dock.classList.add('dock-dragging');
|
||
}
|
||
} else {
|
||
dock.classList.add('dock-dragging');
|
||
}
|
||
}
|
||
|
||
// Desktop: dragging a chip into a screen snap zone previews restoring the
|
||
// window + snapping it there (top → maximize/fullscreen, right → right
|
||
// dock). Releasing in the zone commits it (see onPointerUp).
|
||
if (e.pointerType !== 'touch' && window.innerWidth > 768) {
|
||
const z = previewZoneAt(e.clientX, e.clientY, modal);
|
||
// Ignore the bottom zone — the dock lives at the bottom, so horizontal
|
||
// chip reordering must not get hijacked into a bottom-half snap.
|
||
chipSnapZone = (z && z.name !== 'bottom-half') ? z : null;
|
||
if (z && !chipSnapZone) clearPreview();
|
||
if (chipSnapZone) {
|
||
chip.style.opacity = '0.35';
|
||
return; // aiming at a snap zone — suppress reorder/move-dock
|
||
}
|
||
chip.style.opacity = '';
|
||
}
|
||
|
||
if (dragMode === 'chain' && chainState) {
|
||
// Pointermove just updates the head's target — the RAF loop drives the
|
||
// actual position update for both head and followers.
|
||
chainState.targetX = e.clientX - chainState.fingerOffsetX;
|
||
chainState.targetY = e.clientY - chainState.fingerOffsetY;
|
||
e.preventDefault && e.preventDefault();
|
||
return;
|
||
}
|
||
|
||
if (dragMode === 'free') {
|
||
const tz = trashZone.getBoundingClientRect();
|
||
const tzcx = tz.left + tz.width / 2;
|
||
const tzcy = tz.top + tz.height / 2;
|
||
const dist = Math.hypot(e.clientX - tzcx, e.clientY - tzcy);
|
||
const inZone = dist < CAPTURE_RADIUS;
|
||
// Trash X stays visible for the entire drag; only .engaged tracks
|
||
// when the chip is close enough to capture.
|
||
let tx = e.clientX - (chipStartLeft + chip.offsetWidth / 2);
|
||
let ty = e.clientY - (chipStartTop + chip.offsetHeight / 2);
|
||
if (inZone) {
|
||
const pull = 1 - (dist / CAPTURE_RADIUS);
|
||
const sx = tzcx - (chipStartLeft + chip.offsetWidth / 2);
|
||
const sy = tzcy - (chipStartTop + chip.offsetHeight / 2);
|
||
tx = tx * (1 - pull) + sx * pull;
|
||
ty = ty * (1 - pull) + sy * pull;
|
||
}
|
||
// !important needed because the chip's class-level transform/transition
|
||
// (the FLIP reorder animation + spring transition) outranks plain
|
||
// inline styles set via .style on some Safari versions.
|
||
chip.style.setProperty('transition', 'none', 'important');
|
||
chip.style.setProperty('transform', `translate(${tx}px, ${ty}px) scale(${inZone ? 1.12 : 1.05})`, 'important');
|
||
chip.style.setProperty('z-index', '10000', 'important');
|
||
chip.style.setProperty('position', 'fixed', 'important');
|
||
chip.style.setProperty('left', `${chipStartLeft}px`, 'important');
|
||
chip.style.setProperty('top', `${chipStartTop}px`, 'important');
|
||
chip.style.setProperty('pointer-events', 'none', 'important');
|
||
if (inZone !== overTrash) {
|
||
overTrash = inZone;
|
||
trashZone.classList.toggle('engaged', overTrash);
|
||
}
|
||
e.preventDefault && e.preventDefault();
|
||
return;
|
||
}
|
||
|
||
if (dragMode === 'reorder') {
|
||
chip.style.transition = 'none';
|
||
chip.style.transform = `translate(${dx}px, ${dy}px) scale(1.05)`;
|
||
chip.style.zIndex = '1000';
|
||
|
||
// Find sibling under cursor and swap
|
||
const siblings = [...dock.querySelectorAll('.minimized-dock-chip:not(.dragging)')];
|
||
for (const sib of siblings) {
|
||
const r = sib.getBoundingClientRect();
|
||
if (e.clientX >= r.left && e.clientX <= r.right) {
|
||
const myIdx = _dockOrder.indexOf(chip.dataset.modalId);
|
||
const sibIdx = _dockOrder.indexOf(sib.dataset.modalId);
|
||
if (myIdx !== sibIdx) {
|
||
_dockOrder.splice(myIdx, 1);
|
||
_dockOrder.splice(sibIdx, 0, chip.dataset.modalId);
|
||
chip.classList.remove('dragging');
|
||
_renderDock();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Move-dock: reposition the entire dock element. On touch, the whole
|
||
// chain also interacts with the trash zone — drop on X to close every
|
||
// chip in the dock.
|
||
let newLeft = Math.max(8, Math.min(window.innerWidth - dock.offsetWidth - 8, dockStartLeft + dx));
|
||
let newTop = Math.max(8, Math.min(window.innerHeight - dock.offsetHeight - 8, dockStartTop + dy));
|
||
|
||
if (trashZone) {
|
||
const tz = trashZone.getBoundingClientRect();
|
||
const tzcx = tz.left + tz.width / 2;
|
||
const tzcy = tz.top + tz.height / 2;
|
||
const dockCx = newLeft + dock.offsetWidth / 2;
|
||
const dockCy = newTop + dock.offsetHeight / 2;
|
||
const dist = Math.hypot(dockCx - tzcx, dockCy - tzcy);
|
||
const inZone = dist < CAPTURE_RADIUS;
|
||
// Trash X stays visible for the entire drag — only .engaged
|
||
// tracks proximity to the capture point.
|
||
if (inZone) {
|
||
const pull = 1 - (dist / CAPTURE_RADIUS);
|
||
newLeft = newLeft + (tzcx - dockCx) * pull;
|
||
newTop = newTop + (tzcy - dockCy) * pull;
|
||
}
|
||
if (inZone !== overTrash) {
|
||
overTrash = inZone;
|
||
trashZone.classList.toggle('engaged', overTrash);
|
||
}
|
||
}
|
||
|
||
dock.style.left = `${newLeft}px`;
|
||
dock.style.top = `${newTop}px`;
|
||
dock.style.right = 'auto';
|
||
dock.style.bottom = 'auto';
|
||
dock.style.transform = 'none';
|
||
_dockPos = { left: newLeft, top: newTop };
|
||
_saveDockState();
|
||
}
|
||
};
|
||
|
||
const onPointerUp = () => {
|
||
document.removeEventListener('pointermove', onPointerMove);
|
||
chip.removeEventListener('pointermove', onPointerMove);
|
||
activePointerId = null;
|
||
cancelLongPress();
|
||
// Clear the global drag flag so the chat container's edge-swipe handler
|
||
// can resume opening the sidebar on plain swipes.
|
||
setTimeout(() => { window._chipDragging = false; }, 0);
|
||
// Desktop snap-on-drop: released over a snap zone → restore the window and
|
||
// snap it there (instead of the normal chip reorder/dock drop).
|
||
if (chipSnapZone) {
|
||
const zone = chipSnapZone; chipSnapZone = null;
|
||
clearPreview();
|
||
chip.style.opacity = '';
|
||
dock.classList.remove('dock-dragging');
|
||
const id = chip.dataset.modalId;
|
||
restore(id);
|
||
const modal = document.getElementById(id);
|
||
if (modal) requestAnimationFrame(() => requestAnimationFrame(() => snapModalToZone(modal, zone)));
|
||
return;
|
||
}
|
||
if (dragMode === 'chain' && chainState) {
|
||
cancelAnimationFrame(chainState.raf);
|
||
const state = chainState;
|
||
chainState = null;
|
||
if (state.overTrash && dragging) {
|
||
// Drop on X: animate every link toward the trash zone, then close.
|
||
const ids = state.links.map(l => l.chip.dataset.modalId);
|
||
const tz = trashZone ? trashZone.getBoundingClientRect() : null;
|
||
for (const l of state.links) {
|
||
if (tz) {
|
||
const dx = (tz.left + tz.width / 2) - (l.x + l.width / 2);
|
||
const dy = (tz.top + tz.height / 2) - (l.y + l.height / 2);
|
||
l.chip.style.transition = 'transform 0.32s cubic-bezier(0.45, 0, 0.25, 1), opacity 0.3s ease-in, left 0.32s cubic-bezier(0.45, 0, 0.25, 1), top 0.32s cubic-bezier(0.45, 0, 0.25, 1)';
|
||
// Whirlpool: spin + shrink so the chip swirls into the X.
|
||
l.chip.style.transform = 'scale(0.15) rotate(720deg)';
|
||
l.chip.style.left = `${l.x + dx}px`;
|
||
l.chip.style.top = `${l.y + dy}px`;
|
||
}
|
||
l.chip.style.opacity = '0';
|
||
}
|
||
_trashBurst();
|
||
setTimeout(() => {
|
||
for (const id of ids) close(id);
|
||
dock.style.cssText = '';
|
||
_saveDockState();
|
||
}, 320);
|
||
} else if (dragging) {
|
||
// Released away from X: settle the chain into a tight line in the
|
||
// last trail direction, then persist each chip's position so they
|
||
// stay together at drop instead of scattering at their in-motion
|
||
// physics positions.
|
||
const tMag = Math.hypot(state.trailDirX, state.trailDirY) || 1;
|
||
const dirX = state.trailDirX / tMag;
|
||
const dirY = state.trailDirY / tMag;
|
||
const spacing = state.linkSpacing;
|
||
for (const i of state.order) {
|
||
const l = state.links[i];
|
||
if (i !== state.grabbedIdx) {
|
||
l.x = l.pred.x + dirX * spacing;
|
||
l.y = l.pred.y + dirY * spacing;
|
||
}
|
||
const clampedLeft = Math.max(4, Math.min(window.innerWidth - l.width - 4, l.x));
|
||
const clampedTop = Math.max(4, Math.min(window.innerHeight - l.height - 4, l.y));
|
||
_chipPositions.set(l.chip.dataset.modalId, { left: clampedLeft, top: clampedTop });
|
||
}
|
||
dock.style.opacity = '';
|
||
_saveDockState();
|
||
_renderDock();
|
||
} else {
|
||
// Touch without movement: nothing to do (RAF never started, dock
|
||
// never modified). Let the click handler restore as normal.
|
||
}
|
||
if (trashZone) trashZone.classList.remove('visible', 'engaged');
|
||
overTrash = false;
|
||
dock.classList.remove('dock-dragging');
|
||
dragging = false;
|
||
dragMode = null;
|
||
return;
|
||
}
|
||
if (dragMode === 'free') {
|
||
// If the user never actually dragged (just tapped), do nothing here —
|
||
// let the click handler restore the modal as normal. Skipping past
|
||
// the drop logic also avoids saving the chip's position and re-
|
||
// rendering, which was destroying the click target before it fired.
|
||
if (!dragging) {
|
||
dragging = false;
|
||
dragMode = null;
|
||
if (trashZone) trashZone.classList.remove('visible', 'engaged');
|
||
return;
|
||
}
|
||
if (overTrash) {
|
||
// Animate into the X — the inline transforms set during drag have
|
||
// `!important`, so the close animation needs setProperty(...important)
|
||
// too or the styles don't apply and the chip just snaps.
|
||
const cur = chip.style.transform || 'translate(0,0)';
|
||
chip.style.setProperty('transition', 'transform 0.32s cubic-bezier(0.45, 0, 0.25, 1), opacity 0.3s ease-in', 'important');
|
||
// Whirlpool: spin + shrink as the chip swirls into the X.
|
||
chip.style.setProperty('transform', `${cur} scale(0.15) rotate(720deg)`, 'important');
|
||
chip.style.setProperty('opacity', '0', 'important');
|
||
_trashBurst();
|
||
const id = chip.dataset.modalId;
|
||
setTimeout(() => close(id), 320);
|
||
} else {
|
||
// Drop wherever the finger let go — capture the current viewport
|
||
// position. If we land within snap-distance of another floating chip
|
||
// OR within REDOCK_RADIUS of the chain, magnetically align so chips
|
||
// collect into a tidy cluster instead of scattering.
|
||
const r = chip.getBoundingClientRect();
|
||
let dropLeft = r.left;
|
||
let dropTop = r.top;
|
||
const SNAP = 50; // px — distance under which we snap together
|
||
const myW = r.width, myH = r.height;
|
||
const myId = chip.dataset.modalId;
|
||
|
||
// Find the closest other floating chip (if any).
|
||
let nearest = null, nearestDist = Infinity;
|
||
document.querySelectorAll('body > .minimized-dock-chip').forEach(other => {
|
||
if (other === chip) return;
|
||
const or = other.getBoundingClientRect();
|
||
const dx2 = (or.left + or.width / 2) - (r.left + r.width / 2);
|
||
const dy2 = (or.top + or.height / 2) - (r.top + r.height / 2);
|
||
const d = Math.hypot(dx2, dy2);
|
||
if (d < nearestDist) { nearestDist = d; nearest = or; }
|
||
});
|
||
|
||
if (nearest && nearestDist < myW + SNAP) {
|
||
// Snap adjacent to the nearest chip — pick the side closest to
|
||
// where the finger let go so it feels like a natural collision.
|
||
const dx2 = (r.left + myW / 2) - (nearest.left + nearest.width / 2);
|
||
const dy2 = (r.top + myH / 2) - (nearest.top + nearest.height / 2);
|
||
const gap = 4;
|
||
if (Math.abs(dx2) >= Math.abs(dy2)) {
|
||
// Horizontal snap
|
||
dropLeft = dx2 >= 0 ? nearest.right + gap : nearest.left - myW - gap;
|
||
dropTop = nearest.top;
|
||
} else {
|
||
// Vertical snap
|
||
dropTop = dy2 >= 0 ? nearest.bottom + gap : nearest.top - myH - gap;
|
||
dropLeft = nearest.left;
|
||
}
|
||
} else if (_nearDock(r, dock)) {
|
||
// Dropped near the dock chain — re-dock.
|
||
_chipPositions.delete(myId);
|
||
chip.style.removeProperty('transform');
|
||
chip.style.removeProperty('z-index');
|
||
chip.style.removeProperty('position');
|
||
chip.style.removeProperty('left');
|
||
chip.style.removeProperty('top');
|
||
chip.style.removeProperty('pointer-events');
|
||
chip.style.removeProperty('transition');
|
||
chip.classList.remove('chip-free-drag');
|
||
_saveDockState();
|
||
_renderDock();
|
||
return;
|
||
}
|
||
|
||
const clampedLeft = Math.max(4, Math.min(window.innerWidth - myW - 4, dropLeft));
|
||
const clampedTop = Math.max(4, Math.min(window.innerHeight - myH - 4, dropTop));
|
||
_chipPositions.set(myId, { left: clampedLeft, top: clampedTop });
|
||
chip.style.removeProperty('transform');
|
||
chip.style.removeProperty('z-index');
|
||
chip.style.removeProperty('position');
|
||
chip.style.removeProperty('left');
|
||
chip.style.removeProperty('top');
|
||
chip.style.removeProperty('pointer-events');
|
||
chip.style.removeProperty('transition');
|
||
chip.classList.remove('chip-free-drag');
|
||
_saveDockState();
|
||
_renderDock();
|
||
}
|
||
if (trashZone) trashZone.classList.remove('visible', 'engaged');
|
||
overTrash = false;
|
||
} else if (dragMode === 'move-dock' && dragging && overTrash) {
|
||
// Dropped the whole chain onto the X — close every chipped modal.
|
||
// Only the ids that actually have a rendered chip (i.e. currently
|
||
// minimized or free-positioned); restored open modals are skipped.
|
||
const ids = [...dock.querySelectorAll('.minimized-dock-chip')]
|
||
.map(c => c.dataset.modalId)
|
||
.filter(Boolean);
|
||
_trashBurst();
|
||
// Whirlpool: spin + shrink the whole dock as it spirals into the X.
|
||
dock.style.transition = 'transform 0.32s cubic-bezier(0.45, 0, 0.25, 1), opacity 0.3s ease-in';
|
||
dock.style.opacity = '0';
|
||
dock.style.transform = 'scale(0.2) rotate(720deg)';
|
||
setTimeout(() => {
|
||
for (const id of ids) close(id);
|
||
// The animation left the dock at opacity:0 / scale(0.2) rotated and
|
||
// the drag left it pinned wherever the user landed. Scrub all of
|
||
// that so the NEXT minimized modal renders into a visible, default-
|
||
// positioned dock instead of an invisible one stuck near the trash.
|
||
dock.style.removeProperty('opacity');
|
||
dock.style.removeProperty('transform');
|
||
dock.style.removeProperty('transition');
|
||
dock.style.removeProperty('left');
|
||
dock.style.removeProperty('top');
|
||
dock.style.removeProperty('right');
|
||
dock.style.removeProperty('bottom');
|
||
_saveDockState();
|
||
}, 320);
|
||
if (trashZone) trashZone.classList.remove('visible', 'engaged');
|
||
overTrash = false;
|
||
dock.classList.remove('dock-dragging');
|
||
} else if (dragMode === 'reorder') {
|
||
chip.classList.remove('dragging');
|
||
chip.style.transition = 'transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||
chip.style.transform = '';
|
||
chip.style.zIndex = '';
|
||
} else {
|
||
dock.classList.remove('dock-dragging');
|
||
if (trashZone) trashZone.classList.remove('visible', 'engaged');
|
||
overTrash = false;
|
||
}
|
||
if (dragging) {
|
||
chip._wasDragging = true;
|
||
setTimeout(() => { chip._wasDragging = false; }, 50);
|
||
}
|
||
dragging = false;
|
||
dragMode = null;
|
||
};
|
||
chip.addEventListener('pointerdown', onPointerDown);
|
||
}
|
||
|
||
// Tracks which _LABELS entries were created by `register(..., {label, icon})`
|
||
// (vs. the built-in static ones). Only these should be removed in
|
||
// `unregister` — built-in labels stay for the lifetime of the page.
|
||
const _customLabelIds = new Set();
|
||
|
||
export function register(id, { restoreFn, closeFn, railBtnId, sidebarBtnId, label, icon } = {}) {
|
||
// railBtnId can be a single id or an array; we accept both rail and sidebar separately too.
|
||
const btnIds = [];
|
||
if (railBtnId) btnIds.push(...(Array.isArray(railBtnId) ? railBtnId : [railBtnId]));
|
||
if (sidebarBtnId) btnIds.push(...(Array.isArray(sidebarBtnId) ? sidebarBtnId : [sidebarBtnId]));
|
||
_state.set(id, {
|
||
restoreFn: restoreFn || (() => {}),
|
||
closeFn: closeFn || (() => {}),
|
||
btnIds,
|
||
isMinimized: false,
|
||
});
|
||
// Auto-stack: whichever modal becomes visible last sits on top of any
|
||
// already-open modals. The various tool open() functions (gallery,
|
||
// memory/brain, tasks, etc.) all just toggle `.hidden` or `display` —
|
||
// observe both and bump the z-index on the visible→hidden→visible
|
||
// transition. Idempotent on re-register.
|
||
const _modalEl = document.getElementById(id);
|
||
if (_modalEl && !_modalEl._mmAutoStackObs) {
|
||
const _isVisible = () => !_modalEl.classList.contains('hidden')
|
||
&& getComputedStyle(_modalEl).display !== 'none';
|
||
_modalEl._mmAutoStackLast = _isVisible();
|
||
const obs = new MutationObserver(() => {
|
||
const vis = _isVisible();
|
||
if (vis && !_modalEl._mmAutoStackLast) {
|
||
_bringToFront(_modalEl);
|
||
_applyRememberedDock(id);
|
||
_emitModalOpened(id, _modalEl);
|
||
}
|
||
_modalEl._mmAutoStackLast = vis;
|
||
});
|
||
obs.observe(_modalEl, { attributes: true, attributeFilter: ['class', 'style'] });
|
||
_modalEl._mmAutoStackObs = obs;
|
||
// If it's already visible at register time (e.g. modal opened before
|
||
// register completes), bump it once now too.
|
||
if (_modalEl._mmAutoStackLast) {
|
||
_bringToFront(_modalEl);
|
||
_applyRememberedDock(id);
|
||
_emitModalOpened(id, _modalEl);
|
||
}
|
||
}
|
||
// Allow callers to supply their own chip label/icon (path d="..." or
|
||
// full <svg>...</svg>) so ephemeral things like FX popups can dock
|
||
// into the same chain without needing an entry in the built-in
|
||
// _LABELS table. Track the id so `unregister` can drop the entry
|
||
// and avoid an unbounded-growth leak (v2 review HIGH-3).
|
||
if (label || icon) {
|
||
_LABELS[id] = { label: label || id, icon: icon || '' };
|
||
_customLabelIds.add(id);
|
||
}
|
||
// If a docked window was minimized and its chip was closed, reopen the
|
||
// window in the same side dock next time. Defer until the caller finishes
|
||
// removing `.hidden` / applying initial display styles.
|
||
if (_getRememberedDock(id)) {
|
||
requestAnimationFrame(() => requestAnimationFrame(() => _applyRememberedDock(id)));
|
||
}
|
||
}
|
||
|
||
export function unregister(id) {
|
||
const s = _state.get(id);
|
||
if (s) _setBadge(s.btnIds, false);
|
||
_state.delete(id);
|
||
_chipPositions.delete(id);
|
||
// Drop any per-popup _LABELS entry created at register-time.
|
||
if (_customLabelIds.has(id)) {
|
||
delete _LABELS[id];
|
||
_customLabelIds.delete(id);
|
||
}
|
||
// Also prune the dock-order list so a re-rendered dock doesn't try
|
||
// to draw a chip for a now-dead id.
|
||
const idx = _dockOrder.indexOf(id);
|
||
if (idx >= 0) _dockOrder.splice(idx, 1);
|
||
_saveDockState();
|
||
_renderDock();
|
||
}
|
||
|
||
export function isRegistered(id) { return _state.has(id); }
|
||
export function isMinimized(id) { return _state.get(id)?.isMinimized === true; }
|
||
|
||
export function minimize(id) {
|
||
// Lazy-register if a known modal isn't yet registered (e.g. user clicked `_`
|
||
// on a tool that doesn't pre-register itself).
|
||
if (!_state.has(id) && _AUTO_WIRE[id]) _autoRegister(id);
|
||
const s = _state.get(id);
|
||
if (!s) return false;
|
||
// The id may refer to a virtual tool (e.g. the document panel) that has no
|
||
// actual modal element — in that case we just track the minimized state
|
||
// and let the chip drive restore/close via the registered functions.
|
||
const modal = document.getElementById(id);
|
||
if (modal) {
|
||
// If this window is edge-docked (right/left), SUSPEND the dock: release
|
||
// the body push so the chat returns to full width while the window is
|
||
// minimized, but keep the dock so restoring the chip snaps it back in.
|
||
if (modal.classList.contains('modal-right-docked') || modal.classList.contains('modal-left-docked')) {
|
||
try { suspendDock(modal); } catch (e) { console.warn('suspendDock on minimize failed', e); }
|
||
}
|
||
modal.classList.add('hidden');
|
||
modal.classList.add('modal-minimized');
|
||
const content = modal.querySelector('.modal-content');
|
||
if (content) {
|
||
content.classList.remove('sheet-ready', 'modal-closing');
|
||
content.style.transform = '';
|
||
content.style.transition = '';
|
||
content.style.animation = '';
|
||
}
|
||
}
|
||
s.isMinimized = true;
|
||
_setBadge(s.btnIds, true);
|
||
_ensureDock();
|
||
_renderDock();
|
||
return true;
|
||
}
|
||
|
||
export function restore(id) {
|
||
const s = _state.get(id);
|
||
if (!s) return false;
|
||
const modal = document.getElementById(id);
|
||
if (modal) {
|
||
modal.classList.remove('hidden', 'modal-minimized');
|
||
modal.style.display = '';
|
||
// Surface above any already-open tool window — restoring from the dock
|
||
// should bring this tool to the front, not leave it stuck behind one with
|
||
// a higher static z-index.
|
||
_bringToFront(modal);
|
||
// If the window was edge-docked when minimized, re-apply the dock so the
|
||
// chat nudges back in and the window returns exactly where it was.
|
||
try { resumeDock(modal); } catch (e) { console.warn('resumeDock on restore failed', e); }
|
||
_emitModalOpened(id, modal);
|
||
}
|
||
s.isMinimized = false;
|
||
_setBadge(s.btnIds, false);
|
||
// Intentionally don't clear _chipPositions here: on mobile a free-
|
||
// positioned chip is meant to act as a persistent toggle that stays
|
||
// visible alongside the open modal, so the user can re-collapse it with
|
||
// one tap. The chip only goes away when the modal is fully closed (see
|
||
// close() above, which does delete the position).
|
||
_renderDock();
|
||
try { s.restoreFn(); } catch (e) { console.error('restoreFn:', e); }
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* If the modal is currently MINIMIZED, restore it and return true.
|
||
* Otherwise return false so the caller falls through to its own
|
||
* open/close handling. We deliberately do NOT minimize on toggle —
|
||
* that's the `_` button's job, not the rail/sidebar button's job.
|
||
*/
|
||
export function toggle(id) {
|
||
const s = _state.get(id);
|
||
if (!s) return false;
|
||
const modal = document.getElementById(id);
|
||
if (!modal) { _state.delete(id); return false; }
|
||
if (s.isMinimized) return restore(id);
|
||
return false;
|
||
}
|
||
|
||
/** Full close — calls closeFn (which should tear down DOM + state) and unregisters. */
|
||
export function close(id) {
|
||
const s = _state.get(id);
|
||
if (!s) return;
|
||
const modalBeforeClose = document.getElementById(id);
|
||
const contentBeforeClose = modalBeforeClose?.querySelector?.('.modal-content');
|
||
const suspendedDockSide = contentBeforeClose?._dockSuspended
|
||
|| (modalBeforeClose?.classList?.contains('modal-left-docked') ? 'left'
|
||
: modalBeforeClose?.classList?.contains('modal-right-docked') ? 'right'
|
||
: null);
|
||
const shouldRememberDock = s.isMinimized && !!suspendedDockSide;
|
||
if (shouldRememberDock) _rememberDock(id, suspendedDockSide);
|
||
else _forgetDock(id);
|
||
try { s.closeFn(); } catch (e) { console.error('closeFn:', e); }
|
||
// Some tools (cookbook) animate their close over ~250ms before adding
|
||
// .hidden. If the user re-opens the tool before that finishes, open()
|
||
// sees the modal as "still visible" and takes its no-op early-return
|
||
// path — making the tool feel unresponsive. Force the modal into a
|
||
// fully-closed state synchronously so subsequent open() calls always
|
||
// hit the real open path.
|
||
const modal = document.getElementById(id);
|
||
if (modal) {
|
||
// Tear down the live dock push/classes before hiding. If this close came
|
||
// from a minimized dock chip, the side was persisted above and register()
|
||
// will intentionally re-apply it on the next open.
|
||
if (modal.classList.contains('modal-right-docked') || modal.classList.contains('modal-left-docked')) {
|
||
try { clearRightDock(modal); } catch (e) { console.warn('clearRightDock on close failed', e); }
|
||
}
|
||
modal.classList.add('hidden');
|
||
modal.classList.remove('modal-minimized');
|
||
const content = modal.querySelector('.modal-content');
|
||
if (content) {
|
||
content.classList.remove('modal-closing', 'sheet-ready');
|
||
content.style.transform = '';
|
||
content.style.transition = '';
|
||
content.style.animation = '';
|
||
content.style.opacity = '';
|
||
}
|
||
}
|
||
_setBadge(s.btnIds, false);
|
||
_state.delete(id);
|
||
_chipPositions.delete(id);
|
||
_saveDockState();
|
||
_renderDock();
|
||
}
|
||
|
||
/** Inject a minimize (`_`) button next to the close button in a modal.
|
||
* Skips if a minimize button already exists (any class containing "minimize"). */
|
||
export function injectMinimizeButton(modal, modalId) {
|
||
const header = modal.querySelector('.modal-header');
|
||
if (!header) return;
|
||
if (header.querySelector('.modal-minimize-btn, .minimize-btn, [data-minimize]')) {
|
||
// An existing minimize button is present — wire it to the manager instead
|
||
const existing = header.querySelector('.minimize-btn, [data-minimize]');
|
||
if (existing && !existing.dataset._modalsBound) {
|
||
existing.dataset._modalsBound = '1';
|
||
existing.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
minimize(modalId);
|
||
}, true);
|
||
}
|
||
return;
|
||
}
|
||
const closeBtn = header.querySelector('.close-btn, .modal-close');
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'modal-minimize-btn';
|
||
btn.title = 'Minimize';
|
||
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="5" y1="18" x2="19" y2="18"/></svg>';
|
||
// Anchor the _/X pair to the right edge regardless of the header's
|
||
// justify-content. Some headers (cookbook) use `space-between`, which
|
||
// would otherwise distribute three children as left/center/right and
|
||
// strand the `_` in the middle. `margin-left:auto` eats the free space
|
||
// to the left so `_` + close sit snug at the right.
|
||
btn.style.flexShrink = '0';
|
||
btn.style.marginLeft = 'auto';
|
||
if (closeBtn) {
|
||
// The close button may carry its own left margin (e.g. compare's inline
|
||
// "margin-left:8px") meant to separate it from the title when it stood
|
||
// alone. Now that `_` sits to its left, that margin becomes a stray gap
|
||
// between the two buttons — zero it. The minimize button's own
|
||
// margin-right (2px, from .modal-minimize-btn) provides the gap.
|
||
closeBtn.style.marginLeft = '0';
|
||
closeBtn.style.flexShrink = '0';
|
||
}
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
minimize(modalId);
|
||
});
|
||
if (closeBtn && closeBtn.parentNode) closeBtn.parentNode.insertBefore(btn, closeBtn);
|
||
else header.appendChild(btn);
|
||
}
|
||
|
||
// ── Auto-wire fallback for modals not explicitly registered ──
|
||
// Maps modal-id → { rail btn id, sidebar btn id }. Used to auto-register any
|
||
// modal that gets swipe-dismissed so the rail/sidebar shows the badge and
|
||
// clicking the same button restores it. Tools that need rebuild-on-restore
|
||
// can still register explicitly with custom restoreFn/closeFn.
|
||
const _AUTO_WIRE = {
|
||
'cookbook-modal': { rail: 'rail-cookbook', sidebar: 'tool-cookbook-btn' },
|
||
'calendar-modal': { rail: 'rail-calendar', sidebar: 'tool-calendar-btn' },
|
||
'gallery-modal': { rail: 'rail-gallery', sidebar: 'tool-gallery-btn' },
|
||
'tasks-modal': { rail: 'rail-tasks', sidebar: 'tool-tasks-btn' },
|
||
'doclib-modal': { rail: 'rail-archive', sidebar: 'tool-library-btn' },
|
||
'memory-modal': { rail: null, sidebar: 'tool-memory-btn' },
|
||
'notes-panel': { rail: 'rail-notes', sidebar: 'tool-notes-btn' },
|
||
// Email already has its own #email-unread-dot inline next to the title —
|
||
// don't add a second modalManager badge that lands at the right edge.
|
||
'email-lib-modal': { rail: null, sidebar: null },
|
||
'research-overlay': { rail: 'rail-research', sidebar: 'tool-research-btn' },
|
||
'theme-modal': { rail: null, sidebar: 'tool-theme-btn' },
|
||
'settings-modal': { rail: null, sidebar: 'tool-settings-btn' },
|
||
'compare-model-overlay':{ rail: 'rail-compare', sidebar: 'tool-compare-btn' },
|
||
'ge-shortcuts-modal': { rail: null, sidebar: null },
|
||
// Prompt window opens from the overflow menu (no rail/sidebar button), but
|
||
// wiring it here makes tab-down use the new .minimized-dock-chip instead of
|
||
// the legacy .modal-dock-item.
|
||
'custom-preset-modal': { rail: null, sidebar: null },
|
||
};
|
||
|
||
function _autoRegister(id) {
|
||
if (_state.has(id)) return _state.get(id);
|
||
const wire = _AUTO_WIRE[id];
|
||
if (!wire) return null;
|
||
// Default close: try to invoke the tool's own close button (so it tears down
|
||
// properly), then hide as a fallback.
|
||
register(id, {
|
||
railBtnId: wire.rail,
|
||
sidebarBtnId: wire.sidebar,
|
||
closeFn: () => {
|
||
const m = document.getElementById(id);
|
||
if (!m) return;
|
||
const closeBtn = m.querySelector('.close-btn, .modal-close, [data-close]');
|
||
if (closeBtn) {
|
||
closeBtn.click();
|
||
} else {
|
||
m.classList.add('hidden');
|
||
m.style.display = 'none';
|
||
}
|
||
},
|
||
restoreFn: () => {},
|
||
});
|
||
return _state.get(id);
|
||
}
|
||
|
||
// Watch the document for tool modals being added/shown and inject the `_`
|
||
// button next to the close button. We do NOT pre-register here — only inject
|
||
// the button. Registration happens when the modal is actually minimized,
|
||
// either via the `_` button click or via swipe-dismiss.
|
||
function _scanAndWire() {
|
||
for (const id of Object.keys(_AUTO_WIRE)) {
|
||
const modal = document.getElementById(id);
|
||
if (!modal) continue;
|
||
injectMinimizeButton(modal, id);
|
||
}
|
||
}
|
||
const _scanTimer = setInterval(_scanAndWire, 1000);
|
||
// First scan after DOM ready
|
||
if (document.readyState !== 'loading') {
|
||
setTimeout(_scanAndWire, 100);
|
||
} else {
|
||
document.addEventListener('DOMContentLoaded', () => setTimeout(_scanAndWire, 100));
|
||
}
|
||
|
||
// Tools that survive a swipe-down as a dock chip. Anything else falls
|
||
// through to the legacy close handler and goes away entirely.
|
||
const _SWIPE_DOWN_MINIMIZES = new Set([
|
||
'cookbook-modal',
|
||
'calendar-modal',
|
||
'email-lib-modal',
|
||
]);
|
||
// Same idea but matched by id prefix — so dynamically-created modals
|
||
// (per-email reader tabs) survive swipe-down too.
|
||
const _SWIPE_DOWN_MINIMIZES_PREFIX = ['email-reader-'];
|
||
|
||
// Re-route swipe-dismiss to minimize-rather-than-close — but only for the
|
||
// allowlisted tools above. For every other modal, return early so the
|
||
// default close handler runs and the modal goes away.
|
||
// Close any open body-mounted popups (kebab dropdowns, split-button menus,
|
||
// etc.) when the cookbook modal is swiped away. Otherwise the dropdowns
|
||
// stay floating in the middle of the page with no anchor.
|
||
window.addEventListener('modal-dismissed', (e) => {
|
||
const id = e.detail?.id;
|
||
if (id === 'cookbook-modal') {
|
||
document.querySelectorAll(
|
||
'.cookbook-task-dropdown, .cookbook-gpu-split-menu, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu'
|
||
).forEach(d => d.remove());
|
||
}
|
||
});
|
||
|
||
window.addEventListener('modal-dismissed', (e) => {
|
||
const id = e.detail?.id;
|
||
if (!id) return;
|
||
if (!_SWIPE_DOWN_MINIMIZES.has(id) && !_SWIPE_DOWN_MINIMIZES_PREFIX.some(p => id.startsWith(p))) return;
|
||
// Auto-register if it's a known tool modal
|
||
if (!_state.has(id)) _autoRegister(id);
|
||
const s = _state.get(id);
|
||
if (!s) return;
|
||
s.isMinimized = true;
|
||
_setBadge(s.btnIds, true);
|
||
const modal = document.getElementById(id);
|
||
if (modal) modal.classList.add('modal-minimized');
|
||
_ensureDock();
|
||
_renderDock();
|
||
// Stop legacy listeners that reset internal `_open` state
|
||
e.stopImmediatePropagation();
|
||
});
|
||
|
||
// Capture-phase intercept: if user clicks a sidebar/rail button whose
|
||
// associated modal is currently MINIMIZED, restore it and stop the click
|
||
// before the tool's own toggle handler runs (which would try to re-open or
|
||
// close it).
|
||
document.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('[id]');
|
||
if (!btn) return;
|
||
const btnId = btn.id;
|
||
for (const [modalId, s] of _state.entries()) {
|
||
if (!s.isMinimized) continue;
|
||
if (s.btnIds.includes(btnId)) {
|
||
restore(modalId);
|
||
e.stopImmediatePropagation();
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
}
|
||
}, true);
|
||
|
||
export default { register, unregister, isRegistered, isMinimized, minimize, restore, toggle, close, injectMinimizeButton };
|