fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many ad-hoc dropdowns and context popups that are built on the fly and appended to <body> ignored Escape entirely: document-library card/chat menus, chat context/stats/overflow popups, cookbook serve & running menus, calendar event menus, and compare pane menus. Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus register a dismiss callback while open, and the arbiter closes the most-recently-opened one first, so a menu opened over a modal closes before the modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside click" idiom to both the outside-click listener and the Escape stack in one call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/ modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown instead of orphaning its stack entry. Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe, cookbookRunning, calendar, and compare/panes. Every teardown path — item click, outside click, swipe, toggle, rebuild, bulk cleanup — routes through the registry so no entry is ever stranded. tests/test_esc_menu_stack_js.py pins the registry's LIFO and exactly-one-per-press guarantees (node-driven; skips when node is absent).
This commit is contained in:
@@ -7,6 +7,7 @@ import spinnerModule from './spinner.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { attachColorPicker } from './colorPicker.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
import {
|
||||
WEEKDAYS, MONTHS, MON_SHORT,
|
||||
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
||||
@@ -426,9 +427,10 @@ function _clampDropdown(dropdown, anchorRect) {
|
||||
}
|
||||
|
||||
function _showEventMoreMenu(ev, anchor) {
|
||||
document.querySelectorAll('.cal-event-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.cal-event-dropdown').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); });
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'cal-event-dropdown';
|
||||
let closeMenu = () => dropdown.remove();
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`;
|
||||
|
||||
@@ -443,12 +445,12 @@ function _showEventMoreMenu(ev, anchor) {
|
||||
const _editIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
||||
|
||||
dropdown.appendChild(_item(_editIcon, 'Edit', () => {
|
||||
dropdown.remove();
|
||||
closeMenu();
|
||||
_showEventForm(ev);
|
||||
}));
|
||||
|
||||
dropdown.appendChild(_item(_trashIcon, 'Delete', async () => {
|
||||
dropdown.remove();
|
||||
closeMenu();
|
||||
const name = ev.summary ? `"${ev.summary}"` : 'this event';
|
||||
const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true });
|
||||
if (!ok) return;
|
||||
@@ -459,14 +461,7 @@ function _showEventMoreMenu(ev, anchor) {
|
||||
dropdown._anchorRect = rect;
|
||||
_clampDropdown(dropdown, rect);
|
||||
dropdown.style.visibility = '';
|
||||
const close = (ev2) => {
|
||||
if (!dropdown.contains(ev2.target) && ev2.target !== anchor) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
}
|
||||
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (ev2) => !dropdown.contains(ev2.target) && ev2.target !== anchor);}
|
||||
|
||||
async function _createEventReminder(ev, dueDate) {
|
||||
// Store the reminder as an absolute UTC instant (with the Z suffix) so the
|
||||
|
||||
@@ -7,6 +7,7 @@ import { addAITTSButton } from './tts-ai.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import settingsModule from './settings.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
|
||||
const SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
||||
const REPORT_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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
|
||||
@@ -568,7 +569,7 @@ export function applyModelColor(roleEl, modelName) {
|
||||
roleEl.style.cursor = 'pointer';
|
||||
roleEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll('.ctx-popup').forEach(p => p.remove());
|
||||
document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
|
||||
const info = getModelInfo(modelName);
|
||||
const short = shortModel(modelName);
|
||||
const logoHtml = providerLogo(modelName);
|
||||
@@ -626,10 +627,7 @@ export function applyModelColor(roleEl, modelName) {
|
||||
const pr = popup.getBoundingClientRect();
|
||||
if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px';
|
||||
if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px';
|
||||
const closePopup = (ev) => {
|
||||
if (!popup.contains(ev.target)) { popup.remove(); document.removeEventListener('click', closePopup, true); }
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closePopup, true), 0);
|
||||
bindMenuDismiss(popup, () => popup.remove());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1332,12 +1330,17 @@ export function createMsgFooter(msgElement) {
|
||||
moreBtn.textContent = '\u00B7\u00B7\u00B7';
|
||||
moreBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Toggle overflow menu — close any existing one first
|
||||
// Toggle overflow menu — close any existing one first (through its own
|
||||
// dismiss so the Escape registry entry goes with it).
|
||||
const existing = document.querySelector('.msg-overflow-menu');
|
||||
if (existing) { existing.remove(); if (existing._trigger === moreBtn) return; }
|
||||
if (existing) {
|
||||
if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
|
||||
if (existing._trigger === moreBtn) return;
|
||||
}
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'msg-overflow-menu';
|
||||
let closeMenu = () => menu.remove();
|
||||
overflow.forEach(a => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'msg-overflow-item';
|
||||
@@ -1347,7 +1350,7 @@ export function createMsgFooter(msgElement) {
|
||||
item.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
_trackAction(a.id);
|
||||
menu.remove();
|
||||
closeMenu();
|
||||
a.handler(ev);
|
||||
});
|
||||
menu.appendChild(item);
|
||||
@@ -1363,15 +1366,9 @@ export function createMsgFooter(msgElement) {
|
||||
// Keep within right edge
|
||||
const mr = menu.getBoundingClientRect();
|
||||
if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
|
||||
// Close on outside click
|
||||
const close = (ev) => {
|
||||
if (!menu.contains(ev.target) && ev.target !== moreBtn) {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
});
|
||||
// Close on outside click or Escape. The trigger button is treated as
|
||||
// "inside" so its own click toggles rather than double-fires.
|
||||
closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); });
|
||||
actions.appendChild(moreBtn);
|
||||
}
|
||||
|
||||
@@ -1392,9 +1389,14 @@ export function createMsgFooter(msgElement) {
|
||||
pill.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let detail = pill._openDetail || document.querySelector('.memory-used-detail');
|
||||
if (detail) { detail.remove(); pill._openDetail = null; return; }
|
||||
if (detail) {
|
||||
if (typeof detail._dismiss === 'function') detail._dismiss();
|
||||
else { detail.remove(); pill._openDetail = null; }
|
||||
return;
|
||||
}
|
||||
detail = document.createElement('div');
|
||||
detail.className = 'memory-used-detail';
|
||||
let closeDetail = () => { detail.remove(); pill._openDetail = null; };
|
||||
mems.forEach(m => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'memory-used-row';
|
||||
@@ -1410,8 +1412,7 @@ export function createMsgFooter(msgElement) {
|
||||
row.appendChild(text);
|
||||
row.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
detail.remove();
|
||||
pill._openDetail = null;
|
||||
closeDetail();
|
||||
const memModal = document.getElementById('memory-modal');
|
||||
if (memModal) memModal.classList.remove('hidden');
|
||||
});
|
||||
@@ -1435,15 +1436,8 @@ export function createMsgFooter(msgElement) {
|
||||
if (parseFloat(detail.style.left) < 8) detail.style.left = '8px';
|
||||
detail.style.visibility = '';
|
||||
pill._openDetail = detail;
|
||||
const close = (ev) => {
|
||||
if (!detail.contains(ev.target) && ev.target !== pill) {
|
||||
detail.remove();
|
||||
pill._openDetail = null;
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
});
|
||||
// Close on outside click or Escape (pill click toggles, so it's inside).
|
||||
closeDetail = bindMenuDismiss(detail, () => { detail.remove(); pill._openDetail = null; }, (ev) => !detail.contains(ev.target) && ev.target !== pill); });
|
||||
|
||||
footer.appendChild(pill);
|
||||
}
|
||||
@@ -1528,10 +1522,14 @@ export function createUserMsgFooter(msgElement) {
|
||||
moreBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const existing = document.querySelector('.msg-overflow-menu');
|
||||
if (existing) { existing.remove(); if (existing._trigger === moreBtn) return; }
|
||||
if (existing) {
|
||||
if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
|
||||
if (existing._trigger === moreBtn) return;
|
||||
}
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'msg-overflow-menu';
|
||||
let closeMenu = () => menu.remove();
|
||||
overflow.forEach(a => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'msg-overflow-item';
|
||||
@@ -1541,7 +1539,7 @@ export function createUserMsgFooter(msgElement) {
|
||||
item.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
_trackUserAction(a.id);
|
||||
menu.remove();
|
||||
closeMenu();
|
||||
a.handler(ev);
|
||||
});
|
||||
menu.appendChild(item);
|
||||
@@ -1554,14 +1552,7 @@ export function createUserMsgFooter(msgElement) {
|
||||
if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px';
|
||||
const mr = menu.getBoundingClientRect();
|
||||
if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
|
||||
const close = (ev) => {
|
||||
if (!menu.contains(ev.target) && ev.target !== moreBtn) {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
});
|
||||
closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); });
|
||||
actions.appendChild(moreBtn);
|
||||
}
|
||||
|
||||
@@ -1625,7 +1616,7 @@ export function displayMetrics(messageElement, metrics) {
|
||||
metricsDivider.style.pointerEvents = 'none';
|
||||
metricsContainer.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll('.ctx-popup').forEach(p => p.remove());
|
||||
document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
|
||||
|
||||
const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : 'n/a';
|
||||
const speedStr = tps != null && tps !== 'undefined' ? `${tps} tok/s` : 'n/a';
|
||||
@@ -1685,13 +1676,7 @@ export function displayMetrics(messageElement, metrics) {
|
||||
if (parseFloat(popup.style.left) < 8) popup.style.left = '8px';
|
||||
popup.style.visibility = '';
|
||||
|
||||
const closePopup = (ev) => {
|
||||
if (!popup.contains(ev.target)) {
|
||||
popup.remove();
|
||||
document.removeEventListener('click', closePopup, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closePopup, true), 0);
|
||||
bindMenuDismiss(popup, () => popup.remove());
|
||||
});
|
||||
|
||||
// Store real context length for model info popup
|
||||
@@ -1722,7 +1707,7 @@ export function displayMetrics(messageElement, metrics) {
|
||||
|
||||
ctxRing.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll('.ctx-detail-popup').forEach(p => p.remove());
|
||||
document.querySelectorAll('.ctx-detail-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
|
||||
|
||||
const usedTokens = inputTokens || 0;
|
||||
const totalCtx = ctxLen || 0;
|
||||
@@ -1826,13 +1811,7 @@ export function displayMetrics(messageElement, metrics) {
|
||||
}
|
||||
popup.style.visibility = '';
|
||||
|
||||
const closePopup = (ev) => {
|
||||
if (!popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target)) {
|
||||
popup.remove();
|
||||
document.removeEventListener('click', closePopup, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closePopup, true), 0);
|
||||
bindMenuDismiss(popup, () => popup.remove(), (ev) => !popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { _clearProbeWaves } from './probe.js';
|
||||
import Storage from '../storage.js';
|
||||
import uiModule from '../ui.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -282,10 +283,11 @@ async function _addPane(anchorBtn) {
|
||||
|
||||
// Toggle existing dropdown
|
||||
const existing = document.querySelector('.add-pane-dropdown');
|
||||
if (existing) { existing.remove(); return; }
|
||||
if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; }
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'add-pane-dropdown';
|
||||
let closeMenu = () => dropdown.remove();
|
||||
|
||||
// Search input for large model lists
|
||||
if (filtered.length >= 5) {
|
||||
@@ -326,7 +328,7 @@ async function _addPane(anchorBtn) {
|
||||
|
||||
item.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
closeMenu();
|
||||
await _createAndAppendPane(m);
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
@@ -371,15 +373,8 @@ async function _addPane(anchorBtn) {
|
||||
dropdown.style.bottom = 'auto';
|
||||
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
|
||||
|
||||
// Close on outside click
|
||||
const close = (e) => {
|
||||
if (!dropdown.contains(e.target) && e.target !== anchorBtn) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close), 0);
|
||||
}
|
||||
// Close on outside click or Escape (the latter via the registry).
|
||||
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== anchorBtn);}
|
||||
|
||||
/** Create a new pane for the given model and append it to the compare grid. */
|
||||
async function _createAndAppendPane(m) {
|
||||
@@ -551,7 +546,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
|
||||
// Remove any existing dropdown
|
||||
const existing = document.querySelector('.pane-model-dropdown');
|
||||
if (existing) { existing.remove(); return; }
|
||||
if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; }
|
||||
|
||||
const _effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode;
|
||||
const filtered = state._cachedModels.filter(m => m.type === _effectiveType);
|
||||
@@ -559,6 +554,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'pane-model-dropdown';
|
||||
let closeMenu = () => dropdown.remove();
|
||||
|
||||
filtered.forEach(m => {
|
||||
const item = document.createElement('button');
|
||||
@@ -573,7 +569,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
}
|
||||
item.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
closeMenu();
|
||||
|
||||
// Update the model for this pane and persist
|
||||
state._selectedModels[paneIdx] = {
|
||||
@@ -653,15 +649,8 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
dropdown.style.top = top + 'px';
|
||||
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
|
||||
|
||||
// Close on outside click
|
||||
const close = (e) => {
|
||||
if (!dropdown.contains(e.target) && e.target !== titleBtn) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close), 0);
|
||||
}
|
||||
// Close on outside click or Escape (the latter via the registry).
|
||||
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== titleBtn);}
|
||||
|
||||
// ── Shuffle / reset ──
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
|
||||
import { registerMenuDismiss } from './escMenuStack.js';
|
||||
|
||||
// Human-friendly badge label for a task's internal status. Avoids surfacing
|
||||
// the word "error" in the sidebar — a server the user stopped or one that
|
||||
@@ -1546,7 +1547,7 @@ export function _renderRunningTab() {
|
||||
el.addEventListener('touchcancel', _lpCancel, { passive: true });
|
||||
menuBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll('.cookbook-task-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.cookbook-task-dropdown').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); });
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'cookbook-task-dropdown';
|
||||
@@ -1696,7 +1697,7 @@ export function _renderRunningTab() {
|
||||
const ic = _MENU_ICONS[item.action] || '';
|
||||
div.innerHTML = `<span style="display:inline-flex;flex-shrink:0;opacity:0.7;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ic}</svg></span><span>${item.label}</span>`;
|
||||
div.addEventListener('click', () => {
|
||||
dropdown.remove();
|
||||
_cleanup();
|
||||
if (item.custom) { item.custom(); return; }
|
||||
el.querySelector('.cookbook-task-action-' + item.action)?.click();
|
||||
});
|
||||
@@ -1736,17 +1737,21 @@ export function _renderRunningTab() {
|
||||
// fixed position no longer matches the originating ⋮ button, so
|
||||
// it visually drifts. Matches the email kebab behaviour.
|
||||
const scrollClose = () => _cleanup();
|
||||
let _unreg = () => {};
|
||||
const _cleanup = () => {
|
||||
_unreg(); _unreg = () => {};
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', closeHandler);
|
||||
window.removeEventListener('scroll', scrollClose, true);
|
||||
window.visualViewport?.removeEventListener('scroll', scrollClose);
|
||||
};
|
||||
dropdown._dismiss = _cleanup;
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', closeHandler);
|
||||
window.addEventListener('scroll', scrollClose, true);
|
||||
window.visualViewport?.addEventListener('scroll', scrollClose);
|
||||
}, 0);
|
||||
_unreg = registerMenuDismiss(_cleanup);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import uiModule from './ui.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { modelColor } from './chatRenderer.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
// Shared state/functions injected by init()
|
||||
let _envState;
|
||||
@@ -193,18 +194,19 @@ function _rerenderCachedModels() {
|
||||
list.querySelectorAll('.hwfit-cached-menu-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Toggle: if a dropdown for THIS button is already open, close it.
|
||||
// Toggle: if a dropdown for THIS button is already open, close it
|
||||
// (through its own dismiss so the Escape-stack entry goes with it).
|
||||
const existing = document.querySelector('.hwfit-cached-dropdown');
|
||||
if (existing && existing._anchor === btn) {
|
||||
existing.remove();
|
||||
btn.classList.remove('cookbook-menu-active');
|
||||
if (typeof existing._dismiss === 'function') existing._dismiss();
|
||||
else { existing.remove(); btn.classList.remove('cookbook-menu-active'); }
|
||||
return;
|
||||
}
|
||||
// Otherwise close any other open menu (and clear its anchor's active
|
||||
// state) before opening fresh.
|
||||
document.querySelectorAll('.hwfit-cached-dropdown').forEach(d => {
|
||||
if (d._anchor) d._anchor.classList.remove('cookbook-menu-active');
|
||||
d.remove();
|
||||
if (typeof d._dismiss === 'function') d._dismiss(); else d.remove();
|
||||
});
|
||||
const item = btn.closest('.memory-item');
|
||||
const repo = item?.dataset.repo;
|
||||
@@ -215,6 +217,9 @@ function _rerenderCachedModels() {
|
||||
dropdown.className = 'hwfit-cached-dropdown';
|
||||
dropdown._anchor = btn;
|
||||
btn.classList.add('cookbook-menu-active');
|
||||
// Shared close — used by every item, the mobile Cancel, outside-click,
|
||||
// and the Escape arbiter (reassigned to the registry-aware close below).
|
||||
let closeDropdown = () => { dropdown.remove(); btn.classList.remove('cookbook-menu-active'); };
|
||||
const _di = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _serveIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
|
||||
const _retryIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
|
||||
@@ -230,8 +235,7 @@ function _rerenderCachedModels() {
|
||||
div.className = 'dropdown-item-compact' + (opt.danger ? ' dropdown-item-danger' : '');
|
||||
div.innerHTML = _di(opt.icon) + '<span>' + opt.label + '</span>';
|
||||
div.addEventListener('click', () => {
|
||||
dropdown.remove();
|
||||
btn.classList.remove('cookbook-menu-active');
|
||||
closeDropdown();
|
||||
if (opt.action === 'serve') item.click();
|
||||
else if (opt.action === 'delete') _deleteCachedModel(repo, item, false, m);
|
||||
else if (opt.action === 'retry') _retryCachedModel(repo, m);
|
||||
@@ -264,10 +268,7 @@ function _rerenderCachedModels() {
|
||||
const cancelDiv = document.createElement('div');
|
||||
cancelDiv.className = 'dropdown-item-compact dropdown-cancel-mobile';
|
||||
cancelDiv.innerHTML = _di(_cancelIco) + '<span>Cancel</span>';
|
||||
cancelDiv.addEventListener('click', () => {
|
||||
dropdown.remove();
|
||||
btn.classList.remove('cookbook-menu-active');
|
||||
});
|
||||
cancelDiv.addEventListener('click', () => { closeDropdown(); });
|
||||
dropdown.appendChild(cancelDiv);
|
||||
const rect = btn.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
|
||||
@@ -290,8 +291,7 @@ function _rerenderCachedModels() {
|
||||
dropdown.style.top = top + 'px';
|
||||
dropdown.style.visibility = '';
|
||||
}
|
||||
const close = (ev) => { if (!dropdown.contains(ev.target) && ev.target !== btn) { dropdown.remove(); btn.classList.remove('cookbook-menu-active'); document.removeEventListener('click', close, true); } };
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
closeDropdown = bindMenuDismiss(dropdown, () => { dropdown.remove(); btn.classList.remove('cookbook-menu-active'); }, (ev) => !dropdown.contains(ev.target) && ev.target !== btn);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -666,10 +666,11 @@ function _rerenderCachedModels() {
|
||||
// reflects the stored presets. Standard Odysseus .dropdown look, positioned
|
||||
// fixed at the toggle and right-aligned to it.
|
||||
function _showSavedConfigMenu(anchor) {
|
||||
document.querySelectorAll('.cookbook-saved-menu').forEach(d => d.remove());
|
||||
document.querySelectorAll('.cookbook-saved-menu').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); });
|
||||
const modelSlots = _presetsForModel(_loadPresets(), repo);
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'dropdown cookbook-saved-menu';
|
||||
let closeMenu = () => { dropdown.remove(); anchor.classList.remove('cookbook-menu-active'); };
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const minW = 190;
|
||||
// Cap width/height to the viewport and start hidden — we clamp the final
|
||||
@@ -710,7 +711,7 @@ function _rerenderCachedModels() {
|
||||
if (e.target === del) return;
|
||||
e.stopPropagation();
|
||||
// Close the menu FIRST so it always dismisses, even if loading throws.
|
||||
dropdown.remove();
|
||||
closeMenu();
|
||||
_loadSlotIntoPanel(idx);
|
||||
// Confirm the click landed — loading is silent otherwise, so it was
|
||||
// unclear the settings actually changed.
|
||||
@@ -751,14 +752,7 @@ function _rerenderCachedModels() {
|
||||
dropdown.style.left = `${left}px`;
|
||||
dropdown.style.top = `${top}px`;
|
||||
dropdown.style.visibility = '';
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('cookbook-menu-active');
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
closeMenu = bindMenuDismiss(dropdown, () => { dropdown.remove(); anchor.classList.remove('cookbook-menu-active'); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target));
|
||||
}
|
||||
|
||||
// "Save" segment — save the current config directly.
|
||||
@@ -766,7 +760,7 @@ function _rerenderCachedModels() {
|
||||
if (savedSaveBtn) {
|
||||
savedSaveBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll('.cookbook-saved-menu').forEach(d => d.remove());
|
||||
document.querySelectorAll('.cookbook-saved-menu').forEach(dismissOrRemove);
|
||||
await _saveCurrentConfig();
|
||||
});
|
||||
}
|
||||
@@ -775,9 +769,10 @@ function _rerenderCachedModels() {
|
||||
if (savedArrowBtn) {
|
||||
savedArrowBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (document.querySelector('.cookbook-saved-menu')) {
|
||||
document.querySelectorAll('.cookbook-saved-menu').forEach(d => d.remove());
|
||||
savedArrowBtn.classList.remove('cookbook-menu-active');
|
||||
const openSaved = document.querySelector('.cookbook-saved-menu');
|
||||
if (openSaved) {
|
||||
if (typeof openSaved._dismiss === 'function') openSaved._dismiss();
|
||||
else { openSaved.remove(); savedArrowBtn.classList.remove('cookbook-menu-active'); }
|
||||
return;
|
||||
}
|
||||
savedArrowBtn.classList.add('cookbook-menu-active');
|
||||
@@ -822,9 +817,10 @@ function _rerenderCachedModels() {
|
||||
if (_splitArrow) {
|
||||
_splitArrow.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
document.querySelectorAll('.cookbook-gpu-split-menu').forEach(m => m.remove());
|
||||
document.querySelectorAll('.cookbook-gpu-split-menu').forEach(m => { if (typeof m._dismiss === 'function') m._dismiss(); else m.remove(); });
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'cookbook-task-dropdown cookbook-gpu-split-menu';
|
||||
let closeMenu = () => menu.remove();
|
||||
const mk = (label, cls, onClick) => {
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact' + (cls ? ' ' + cls : '');
|
||||
@@ -832,7 +828,7 @@ function _rerenderCachedModels() {
|
||||
it.textContent = label;
|
||||
it.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.remove();
|
||||
closeMenu();
|
||||
if (onClick) onClick();
|
||||
});
|
||||
return it;
|
||||
@@ -859,18 +855,11 @@ function _rerenderCachedModels() {
|
||||
}
|
||||
menu.style.top = top + 'px';
|
||||
}
|
||||
const close = (e) => {
|
||||
if (!menu.contains(e.target) && e.target !== _splitArrow) {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', close);
|
||||
window.removeEventListener('scroll', _scrollClose, true);
|
||||
}
|
||||
};
|
||||
const _scrollClose = () => { menu.remove(); document.removeEventListener('click', close); window.removeEventListener('scroll', _scrollClose, true); };
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', close);
|
||||
window.addEventListener('scroll', _scrollClose, true);
|
||||
}, 0);
|
||||
// Close on outside click or Escape (via the registry); also dismiss
|
||||
// on scroll since the popup is fixed-positioned to the arrow.
|
||||
const _scrollClose = () => closeMenu();
|
||||
closeMenu = bindMenuDismiss(menu, () => { menu.remove(); window.removeEventListener('scroll', _scrollClose, true); }, (e) => !menu.contains(e.target) && e.target !== _splitArrow);
|
||||
window.addEventListener('scroll', _scrollClose, true);
|
||||
});
|
||||
}
|
||||
const _withSpinner = async (btn, fn) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import spinnerModule from './spinner.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { langIcon } from './langIcons.js';
|
||||
import { registerMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
// ── Injected references from documentModule ──
|
||||
let API_BASE = '';
|
||||
@@ -184,7 +185,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
|
||||
function _showLibDropdown(anchor, items, opts) {
|
||||
opts = opts || {};
|
||||
document.querySelectorAll('._lib-dd').forEach(d => d.remove());
|
||||
document.querySelectorAll('._lib-dd').forEach(dismissOrRemove);
|
||||
const dd = document.createElement('div');
|
||||
dd.className = 'dropdown session-dropdown-menu _lib-dd';
|
||||
for (const item of items) {
|
||||
@@ -193,7 +194,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
const iconKey = item.icon || item.label.toLowerCase();
|
||||
const iconSvg = _LIB_DD_ICONS[iconKey] || '';
|
||||
row.innerHTML = (iconSvg ? '<span class="dropdown-icon">' + iconSvg + '</span>' : '') + '<span>' + item.label + '</span>';
|
||||
row.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); item.action(); });
|
||||
row.addEventListener('click', (e) => { e.stopPropagation(); teardown(); item.action(); });
|
||||
dd.appendChild(row);
|
||||
}
|
||||
if (typeof opts.onSelect === 'function') {
|
||||
@@ -202,7 +203,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
sel.innerHTML =
|
||||
'<span class="dropdown-icon"><span style="font-size:16px;line-height:1;position:relative;top:-2px;">●</span></span>'
|
||||
+ '<span>Select</span>';
|
||||
sel.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); opts.onSelect(); });
|
||||
sel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); opts.onSelect(); });
|
||||
dd.appendChild(sel);
|
||||
}
|
||||
const cancel = document.createElement('div');
|
||||
@@ -210,7 +211,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
cancel.innerHTML =
|
||||
'<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>'
|
||||
+ '<span>Cancel</span>';
|
||||
cancel.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); if (typeof opts.onCancel === 'function') opts.onCancel(); });
|
||||
cancel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); if (typeof opts.onCancel === 'function') opts.onCancel(); });
|
||||
dd.appendChild(cancel);
|
||||
document.body.appendChild(dd);
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
@@ -225,8 +226,18 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
}
|
||||
if (mr.left < 8) { dd.style.left = '8px'; dd.style.right = 'auto'; }
|
||||
});
|
||||
const close = (e) => { if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); } };
|
||||
// Single idempotent teardown shared by every dismissal path (item click,
|
||||
// outside click, swipe, and the Escape arbiter via registerMenuDismiss).
|
||||
let _unreg = () => {};
|
||||
const teardown = () => {
|
||||
_unreg(); _unreg = () => {};
|
||||
document.removeEventListener('click', close);
|
||||
dd.remove();
|
||||
};
|
||||
const close = (e) => { if (!dd.contains(e.target)) teardown(); };
|
||||
setTimeout(() => document.addEventListener('click', close), 0);
|
||||
_unreg = registerMenuDismiss(teardown);
|
||||
dd._dismiss = teardown; // let bulk removers (reopen sweep) tear down cleanly
|
||||
|
||||
// Swipe-down-to-dismiss (mobile). Mirrors the bottom-sheet feel — drag the
|
||||
// popup down and release past the threshold to close. Below threshold,
|
||||
@@ -257,8 +268,11 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
dd.style.transition = 'transform 0.15s ease, opacity 0.15s ease';
|
||||
dd.style.transform = 'translateY(120px)';
|
||||
dd.style.opacity = '0';
|
||||
setTimeout(() => dd.remove(), 160);
|
||||
// Unregister + drop the outside-click listener now; defer the DOM
|
||||
// removal so the slide-out animation can play.
|
||||
_unreg(); _unreg = () => {};
|
||||
document.removeEventListener('click', close);
|
||||
setTimeout(() => dd.remove(), 160);
|
||||
} else {
|
||||
dd.style.transition = 'transform 0.18s ease, opacity 0.18s ease';
|
||||
dd.style.transform = '';
|
||||
@@ -380,6 +394,10 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
function libraryRenderGrid() {
|
||||
const grid = document.getElementById('doclib-grid');
|
||||
if (!grid) return;
|
||||
// An open card menu is mounted on <body> (to escape overflow clipping), so
|
||||
// clearing the grid would orphan it; dismiss it first so its listener +
|
||||
// Escape-stack entry go too.
|
||||
document.querySelectorAll('.doclib-card-dropdown').forEach(dismissOrRemove);
|
||||
grid.innerHTML = '';
|
||||
// Drop any previous inline load-more — regenerated below alongside the list.
|
||||
if (grid.parentElement) grid.parentElement.querySelectorAll(':scope > .doclib-inline-load-more').forEach(b => b.remove());
|
||||
@@ -576,8 +594,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
if (dropdown) {
|
||||
const isOpen = dropdown.style.display !== 'none' && dropdown.parentElement === document.body;
|
||||
if (isOpen) {
|
||||
dropdown.style.display = 'none';
|
||||
menuWrap.appendChild(dropdown);
|
||||
hideCardDropdown();
|
||||
} else {
|
||||
// Position fixed on body to escape overflow clipping
|
||||
const rect = menuBtn.getBoundingClientRect();
|
||||
@@ -593,15 +610,12 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
if (mr.bottom > window.innerHeight - 8) dropdown.style.top = (rect.top - mr.height - 4) + 'px';
|
||||
if (mr.left < 8) { dropdown.style.left = '8px'; dropdown.style.right = 'auto'; }
|
||||
});
|
||||
// Close on outside click
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) {
|
||||
dropdown.style.display = 'none';
|
||||
menuWrap.appendChild(dropdown);
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
// Close on outside click or Escape (the latter via the registry).
|
||||
_cardDocClick = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) hideCardDropdown();
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
setTimeout(() => document.addEventListener('click', _cardDocClick, true), 0);
|
||||
_cardUnreg = registerMenuDismiss(hideCardDropdown);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -612,6 +626,21 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
dropdown.className = 'doclib-card-dropdown';
|
||||
dropdown.style.cssText = 'display:none;position:absolute;top:100%;right:0;z-index:1000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;';
|
||||
|
||||
// Single close path for the card action dropdown, shared by the toggle
|
||||
// button, the outside-click listener, every menu item, and the Escape
|
||||
// arbiter (via registerMenuDismiss). Hides the menu, returns it to its
|
||||
// wrapper, drops the outside-click listener, and unregisters from the
|
||||
// Escape stack. Idempotent — safe to call from whichever path fires first.
|
||||
let _cardUnreg = () => {};
|
||||
let _cardDocClick = null;
|
||||
function hideCardDropdown() {
|
||||
_cardUnreg(); _cardUnreg = () => {};
|
||||
if (_cardDocClick) { document.removeEventListener('click', _cardDocClick, true); _cardDocClick = null; }
|
||||
dropdown.style.display = 'none';
|
||||
if (dropdown.parentElement === document.body) menuWrap.appendChild(dropdown);
|
||||
}
|
||||
dropdown._dismiss = hideCardDropdown; // bulk removers tear down through this
|
||||
|
||||
const _di = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _openIco = '<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 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
|
||||
|
||||
@@ -621,7 +650,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
openItem.style.cssText = 'background:none;border:none;width:100%;';
|
||||
openItem.innerHTML = _di(_openIco) + '<span>Open</span>';
|
||||
if (doc.session_id) {
|
||||
openItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; libraryOpenInSession(doc); });
|
||||
openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenInSession(doc); });
|
||||
} else {
|
||||
openItem.disabled = true;
|
||||
openItem.style.opacity = '0.35';
|
||||
@@ -636,7 +665,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
cloneItem.style.cssText = 'background:none;border:none;width:100%;';
|
||||
cloneItem.innerHTML = _di(_cloneIco) + '<span>Clone</span>';
|
||||
cloneItem.title = 'Clone to active session';
|
||||
cloneItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; libraryImportDocument(doc); });
|
||||
cloneItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryImportDocument(doc); });
|
||||
dropdown.appendChild(cloneItem);
|
||||
|
||||
// Export
|
||||
@@ -647,7 +676,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
exportItem.innerHTML = _di(_exportIco) + '<span>Export</span>';
|
||||
exportItem.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.style.display = 'none';
|
||||
hideCardDropdown();
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/document/${doc.id}`);
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
@@ -673,7 +702,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
archiveItem.title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)';
|
||||
archiveItem.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.style.display = 'none';
|
||||
hideCardDropdown();
|
||||
const toArchived = !_libraryArchivedView;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
|
||||
@@ -693,7 +722,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
deleteItem.className = 'dropdown-item-compact dropdown-item-danger';
|
||||
deleteItem.style.cssText = 'background:none;border:none;width:100%;';
|
||||
deleteItem.innerHTML = _di(_deleteIco) + '<span>Delete</span>';
|
||||
deleteItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; libraryDeleteSingle(doc.id, card); });
|
||||
deleteItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryDeleteSingle(doc.id, card); });
|
||||
dropdown.appendChild(deleteItem);
|
||||
|
||||
menuWrap.appendChild(dropdown);
|
||||
|
||||
102
static/js/escMenuStack.js
Normal file
102
static/js/escMenuStack.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// static/js/escMenuStack.js
|
||||
//
|
||||
// Dismissal registry for transient, ad-hoc overlays — dropdown menus and
|
||||
// context popups that are built on the fly and appended to <body>, living
|
||||
// OUTSIDE the .modal system. The global Escape arbiter in ui.js can find
|
||||
// modals but not these, so each menu registers a dismiss callback here while
|
||||
// it is open and unregisters when it closes.
|
||||
//
|
||||
// The stack is LIFO: dismissTopMenu() closes the most-recently-opened menu
|
||||
// first, so a dropdown opened on top of a modal closes before the modal does.
|
||||
// Deliberately DOM-free so it can be unit-tested under plain node (see
|
||||
// tests/test_esc_menu_stack_js.py).
|
||||
|
||||
const _stack = [];
|
||||
|
||||
/**
|
||||
* Register a menu's dismiss callback. Returns an unregister function that the
|
||||
* menu MUST call from its own teardown (outside-click close, item click, etc.)
|
||||
* so the stack never holds a stale entry. Calling the returned function more
|
||||
* than once, or after the menu was already dismissed via Escape, is safe.
|
||||
*/
|
||||
export function registerMenuDismiss(dismissFn) {
|
||||
if (typeof dismissFn !== 'function') return () => {};
|
||||
const entry = { dismissFn };
|
||||
_stack.push(entry);
|
||||
return () => {
|
||||
const i = _stack.indexOf(entry);
|
||||
if (i !== -1) _stack.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the most-recently-registered menu, if any. Returns true when a menu
|
||||
* was dismissed (so the caller can swallow the Escape key), false when nothing
|
||||
* was open. The entry is popped BEFORE its callback runs, so even if a
|
||||
* dismissFn forgets to unregister or throws, a single Escape closes exactly
|
||||
* one menu and the stack never gets stuck.
|
||||
*/
|
||||
export function dismissTopMenu() {
|
||||
const entry = _stack.pop();
|
||||
if (!entry) return false;
|
||||
try { entry.dismissFn(); } catch {}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Test/debug helper: number of currently-registered menus. */
|
||||
export function _openMenuCount() {
|
||||
return _stack.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear a transient menu down through its registered dismiss callback if it has
|
||||
* one (releasing its Escape-stack entry and any listeners), else fall back to a
|
||||
* plain node removal. Use this anywhere menus are cleared in bulk — scroll /
|
||||
* swipe / modal-dismiss cleanup, or a "close the previous one" reopen sweep —
|
||||
* instead of a raw `el.remove()`, which would strand the stack entry.
|
||||
*/
|
||||
export function dismissOrRemove(el) {
|
||||
if (!el) return;
|
||||
if (typeof el._dismiss === 'function') el._dismiss();
|
||||
else el.remove();
|
||||
}
|
||||
|
||||
// ── DOM convenience wrapper ──────────────────────────────────────────────
|
||||
// The registry above is intentionally DOM-free (and unit-tested as such).
|
||||
// bindMenuDismiss is the thin DOM layer most callers actually want: it wires
|
||||
// the ubiquitous "overlay appended to <body>, closes on an outside click"
|
||||
// idiom to BOTH the outside-click listener AND the Escape stack in one call,
|
||||
// so a menu only has to describe how to tear itself down once.
|
||||
//
|
||||
// const close = bindMenuDismiss(popup, () => popup.remove());
|
||||
// // outside-click and Escape now both call close(); call it yourself from
|
||||
// // item handlers too.
|
||||
//
|
||||
// `onClose` runs exactly once (idempotent) and owns the actual teardown
|
||||
// (removing/hiding the node, clearing anchor state, …). `isOutside(ev)`
|
||||
// defaults to "the click landed outside `el`"; override it when extra anchors
|
||||
// should count as inside the menu. The returned idempotent close() is also
|
||||
// stashed on `el._dismiss`, so bulk removers (see dismissOrRemove) can tear the
|
||||
// menu down through its real teardown rather than orphaning its stack entry.
|
||||
export function bindMenuDismiss(el, onClose, isOutside) {
|
||||
let done = false;
|
||||
let unreg = () => {};
|
||||
const onDocClick = (ev) => {
|
||||
const outside = typeof isOutside === 'function' ? isOutside(ev) : !el.contains(ev.target);
|
||||
if (outside) close();
|
||||
};
|
||||
function close() {
|
||||
if (done) return;
|
||||
done = true;
|
||||
unreg(); unreg = () => {};
|
||||
document.removeEventListener('click', onDocClick, true);
|
||||
try { if (typeof onClose === 'function') onClose(); } catch {}
|
||||
}
|
||||
// Defer attaching the outside-click listener so the opening click doesn't
|
||||
// immediately close the menu. Skip the attach if close() already ran in the
|
||||
// same tick (e.g. an instant Escape) so we never leave a dangling listener.
|
||||
setTimeout(() => { if (!done) document.addEventListener('click', onDocClick, true); }, 0);
|
||||
unreg = registerMenuDismiss(close);
|
||||
el._dismiss = close;
|
||||
return close;
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
import { previewZoneAt, clearPreview, snapModalToZone } from './tileManager.js';
|
||||
import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js';
|
||||
import { dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const _state = new Map(); // id -> { restoreFn, closeFn, railBtnId, isMinimized, restoreMinHeight }
|
||||
|
||||
@@ -1463,7 +1464,7 @@ window.addEventListener('modal-dismissed', (e) => {
|
||||
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());
|
||||
).forEach(dismissOrRemove);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import themeModule from './theme.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
let toastEl = null;
|
||||
let autoScrollEnabled = true;
|
||||
@@ -769,7 +770,7 @@ function _initScrollDismiss() {
|
||||
if (chatHistory) {
|
||||
chatHistory.addEventListener('scroll', () => {
|
||||
chatHistory.querySelectorAll('.dropdown.show').forEach(d => d.classList.remove('show'));
|
||||
document.querySelectorAll('.ctx-popup').forEach(p => p.remove());
|
||||
document.querySelectorAll('.ctx-popup').forEach(dismissOrRemove);
|
||||
}, { passive: true });
|
||||
} else {
|
||||
// Retry once if element doesn't exist yet
|
||||
@@ -822,7 +823,8 @@ const uiModule = {
|
||||
el,
|
||||
esc,
|
||||
isTouchInsideModal,
|
||||
emptyStateIcon
|
||||
emptyStateIcon,
|
||||
registerMenuDismiss
|
||||
};
|
||||
|
||||
export default uiModule;
|
||||
@@ -883,7 +885,9 @@ if ('ontouchstart' in window) {
|
||||
'.email-card-dropdown, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu'
|
||||
).forEach(d => {
|
||||
if (d._anchor) d._anchor.classList.remove('cookbook-menu-active', 'reader-more-active');
|
||||
d.remove();
|
||||
// Registered menus tear down through their own dismiss (releasing the
|
||||
// Escape-stack entry); unregistered ones (email/dep) just get removed.
|
||||
dismissOrRemove(d);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1200,6 +1204,15 @@ if (!window._odyEscExpandGuard) {
|
||||
e.stopImmediatePropagation(); e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Transient ad-hoc menus (dropdowns / context popups) live outside the
|
||||
// .modal system and register a dismiss callback in escMenuStack. Close the
|
||||
// most-recently-opened one first — so a menu opened over a modal dismisses
|
||||
// before the modal — and do it BEFORE the text-input guard below, since a
|
||||
// menu may own the focused input (e.g. a search dropdown).
|
||||
if (dismissTopMenu()) {
|
||||
e.stopImmediatePropagation(); e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||
const expanded = document.querySelector('.doclib-card-expanded');
|
||||
|
||||
Reference in New Issue
Block a user