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 * as Modals from './modalManager.js';
|
||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
import { attachColorPicker } from './colorPicker.js';
|
import { attachColorPicker } from './colorPicker.js';
|
||||||
|
import { bindMenuDismiss } from './escMenuStack.js';
|
||||||
import {
|
import {
|
||||||
WEEKDAYS, MONTHS, MON_SHORT,
|
WEEKDAYS, MONTHS, MON_SHORT,
|
||||||
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
||||||
@@ -426,9 +427,10 @@ function _clampDropdown(dropdown, anchorRect) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _showEventMoreMenu(ev, anchor) {
|
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');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'cal-event-dropdown';
|
dropdown.className = 'cal-event-dropdown';
|
||||||
|
let closeMenu = () => dropdown.remove();
|
||||||
const rect = anchor.getBoundingClientRect();
|
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;`;
|
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>';
|
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.appendChild(_item(_editIcon, 'Edit', () => {
|
||||||
dropdown.remove();
|
closeMenu();
|
||||||
_showEventForm(ev);
|
_showEventForm(ev);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
dropdown.appendChild(_item(_trashIcon, 'Delete', async () => {
|
dropdown.appendChild(_item(_trashIcon, 'Delete', async () => {
|
||||||
dropdown.remove();
|
closeMenu();
|
||||||
const name = ev.summary ? `"${ev.summary}"` : 'this event';
|
const name = ev.summary ? `"${ev.summary}"` : 'this event';
|
||||||
const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true });
|
const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -459,14 +461,7 @@ function _showEventMoreMenu(ev, anchor) {
|
|||||||
dropdown._anchorRect = rect;
|
dropdown._anchorRect = rect;
|
||||||
_clampDropdown(dropdown, rect);
|
_clampDropdown(dropdown, rect);
|
||||||
dropdown.style.visibility = '';
|
dropdown.style.visibility = '';
|
||||||
const close = (ev2) => {
|
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (ev2) => !dropdown.contains(ev2.target) && ev2.target !== anchor);}
|
||||||
if (!dropdown.contains(ev2.target) && ev2.target !== anchor) {
|
|
||||||
dropdown.remove();
|
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _createEventReminder(ev, dueDate) {
|
async function _createEventReminder(ev, dueDate) {
|
||||||
// Store the reminder as an absolute UTC instant (with the Z suffix) so the
|
// 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 { providerLogo } from './providers.js';
|
||||||
import settingsModule from './settings.js';
|
import settingsModule from './settings.js';
|
||||||
import spinnerModule from './spinner.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 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>';
|
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.style.cursor = 'pointer';
|
||||||
roleEl.addEventListener('click', (e) => {
|
roleEl.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
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 info = getModelInfo(modelName);
|
||||||
const short = shortModel(modelName);
|
const short = shortModel(modelName);
|
||||||
const logoHtml = providerLogo(modelName);
|
const logoHtml = providerLogo(modelName);
|
||||||
@@ -626,10 +627,7 @@ export function applyModelColor(roleEl, modelName) {
|
|||||||
const pr = popup.getBoundingClientRect();
|
const pr = popup.getBoundingClientRect();
|
||||||
if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px';
|
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';
|
if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px';
|
||||||
const closePopup = (ev) => {
|
bindMenuDismiss(popup, () => popup.remove());
|
||||||
if (!popup.contains(ev.target)) { popup.remove(); document.removeEventListener('click', closePopup, true); }
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', closePopup, true), 0);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1332,12 +1330,17 @@ export function createMsgFooter(msgElement) {
|
|||||||
moreBtn.textContent = '\u00B7\u00B7\u00B7';
|
moreBtn.textContent = '\u00B7\u00B7\u00B7';
|
||||||
moreBtn.addEventListener('click', (e) => {
|
moreBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
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');
|
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');
|
const menu = document.createElement('div');
|
||||||
menu.className = 'msg-overflow-menu';
|
menu.className = 'msg-overflow-menu';
|
||||||
|
let closeMenu = () => menu.remove();
|
||||||
overflow.forEach(a => {
|
overflow.forEach(a => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.className = 'msg-overflow-item';
|
item.className = 'msg-overflow-item';
|
||||||
@@ -1347,7 +1350,7 @@ export function createMsgFooter(msgElement) {
|
|||||||
item.addEventListener('click', (ev) => {
|
item.addEventListener('click', (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
_trackAction(a.id);
|
_trackAction(a.id);
|
||||||
menu.remove();
|
closeMenu();
|
||||||
a.handler(ev);
|
a.handler(ev);
|
||||||
});
|
});
|
||||||
menu.appendChild(item);
|
menu.appendChild(item);
|
||||||
@@ -1363,15 +1366,9 @@ export function createMsgFooter(msgElement) {
|
|||||||
// Keep within right edge
|
// Keep within right edge
|
||||||
const mr = menu.getBoundingClientRect();
|
const mr = menu.getBoundingClientRect();
|
||||||
if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
|
if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
|
||||||
// Close on outside click
|
// Close on outside click or Escape. The trigger button is treated as
|
||||||
const close = (ev) => {
|
// "inside" so its own click toggles rather than double-fires.
|
||||||
if (!menu.contains(ev.target) && ev.target !== moreBtn) {
|
closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); });
|
||||||
menu.remove();
|
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
|
||||||
});
|
|
||||||
actions.appendChild(moreBtn);
|
actions.appendChild(moreBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1392,9 +1389,14 @@ export function createMsgFooter(msgElement) {
|
|||||||
pill.addEventListener('click', (e) => {
|
pill.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
let detail = pill._openDetail || document.querySelector('.memory-used-detail');
|
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 = document.createElement('div');
|
||||||
detail.className = 'memory-used-detail';
|
detail.className = 'memory-used-detail';
|
||||||
|
let closeDetail = () => { detail.remove(); pill._openDetail = null; };
|
||||||
mems.forEach(m => {
|
mems.forEach(m => {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'memory-used-row';
|
row.className = 'memory-used-row';
|
||||||
@@ -1410,8 +1412,7 @@ export function createMsgFooter(msgElement) {
|
|||||||
row.appendChild(text);
|
row.appendChild(text);
|
||||||
row.addEventListener('click', (ev) => {
|
row.addEventListener('click', (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
detail.remove();
|
closeDetail();
|
||||||
pill._openDetail = null;
|
|
||||||
const memModal = document.getElementById('memory-modal');
|
const memModal = document.getElementById('memory-modal');
|
||||||
if (memModal) memModal.classList.remove('hidden');
|
if (memModal) memModal.classList.remove('hidden');
|
||||||
});
|
});
|
||||||
@@ -1435,15 +1436,8 @@ export function createMsgFooter(msgElement) {
|
|||||||
if (parseFloat(detail.style.left) < 8) detail.style.left = '8px';
|
if (parseFloat(detail.style.left) < 8) detail.style.left = '8px';
|
||||||
detail.style.visibility = '';
|
detail.style.visibility = '';
|
||||||
pill._openDetail = detail;
|
pill._openDetail = detail;
|
||||||
const close = (ev) => {
|
// Close on outside click or Escape (pill click toggles, so it's inside).
|
||||||
if (!detail.contains(ev.target) && ev.target !== pill) {
|
closeDetail = bindMenuDismiss(detail, () => { detail.remove(); pill._openDetail = null; }, (ev) => !detail.contains(ev.target) && ev.target !== pill); });
|
||||||
detail.remove();
|
|
||||||
pill._openDetail = null;
|
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
footer.appendChild(pill);
|
footer.appendChild(pill);
|
||||||
}
|
}
|
||||||
@@ -1528,10 +1522,14 @@ export function createUserMsgFooter(msgElement) {
|
|||||||
moreBtn.addEventListener('click', (e) => {
|
moreBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const existing = document.querySelector('.msg-overflow-menu');
|
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');
|
const menu = document.createElement('div');
|
||||||
menu.className = 'msg-overflow-menu';
|
menu.className = 'msg-overflow-menu';
|
||||||
|
let closeMenu = () => menu.remove();
|
||||||
overflow.forEach(a => {
|
overflow.forEach(a => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.className = 'msg-overflow-item';
|
item.className = 'msg-overflow-item';
|
||||||
@@ -1541,7 +1539,7 @@ export function createUserMsgFooter(msgElement) {
|
|||||||
item.addEventListener('click', (ev) => {
|
item.addEventListener('click', (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
_trackUserAction(a.id);
|
_trackUserAction(a.id);
|
||||||
menu.remove();
|
closeMenu();
|
||||||
a.handler(ev);
|
a.handler(ev);
|
||||||
});
|
});
|
||||||
menu.appendChild(item);
|
menu.appendChild(item);
|
||||||
@@ -1554,14 +1552,7 @@ export function createUserMsgFooter(msgElement) {
|
|||||||
if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px';
|
if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px';
|
||||||
const mr = menu.getBoundingClientRect();
|
const mr = menu.getBoundingClientRect();
|
||||||
if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
|
if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
|
||||||
const close = (ev) => {
|
closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); });
|
||||||
if (!menu.contains(ev.target) && ev.target !== moreBtn) {
|
|
||||||
menu.remove();
|
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
|
||||||
});
|
|
||||||
actions.appendChild(moreBtn);
|
actions.appendChild(moreBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1625,7 +1616,7 @@ export function displayMetrics(messageElement, metrics) {
|
|||||||
metricsDivider.style.pointerEvents = 'none';
|
metricsDivider.style.pointerEvents = 'none';
|
||||||
metricsContainer.addEventListener('click', (e) => {
|
metricsContainer.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
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 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';
|
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';
|
if (parseFloat(popup.style.left) < 8) popup.style.left = '8px';
|
||||||
popup.style.visibility = '';
|
popup.style.visibility = '';
|
||||||
|
|
||||||
const closePopup = (ev) => {
|
bindMenuDismiss(popup, () => popup.remove());
|
||||||
if (!popup.contains(ev.target)) {
|
|
||||||
popup.remove();
|
|
||||||
document.removeEventListener('click', closePopup, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', closePopup, true), 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store real context length for model info popup
|
// Store real context length for model info popup
|
||||||
@@ -1722,7 +1707,7 @@ export function displayMetrics(messageElement, metrics) {
|
|||||||
|
|
||||||
ctxRing.addEventListener('click', (e) => {
|
ctxRing.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
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 usedTokens = inputTokens || 0;
|
||||||
const totalCtx = ctxLen || 0;
|
const totalCtx = ctxLen || 0;
|
||||||
@@ -1826,13 +1811,7 @@ export function displayMetrics(messageElement, metrics) {
|
|||||||
}
|
}
|
||||||
popup.style.visibility = '';
|
popup.style.visibility = '';
|
||||||
|
|
||||||
const closePopup = (ev) => {
|
bindMenuDismiss(popup, () => popup.remove(), (ev) => !popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target));
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { _clearProbeWaves } from './probe.js';
|
|||||||
import Storage from '../storage.js';
|
import Storage from '../storage.js';
|
||||||
import uiModule from '../ui.js';
|
import uiModule from '../ui.js';
|
||||||
import spinnerModule from '../spinner.js';
|
import spinnerModule from '../spinner.js';
|
||||||
|
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||||
|
|
||||||
var escapeHtml = uiModule.esc;
|
var escapeHtml = uiModule.esc;
|
||||||
|
|
||||||
@@ -282,10 +283,11 @@ async function _addPane(anchorBtn) {
|
|||||||
|
|
||||||
// Toggle existing dropdown
|
// Toggle existing dropdown
|
||||||
const existing = document.querySelector('.add-pane-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');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'add-pane-dropdown';
|
dropdown.className = 'add-pane-dropdown';
|
||||||
|
let closeMenu = () => dropdown.remove();
|
||||||
|
|
||||||
// Search input for large model lists
|
// Search input for large model lists
|
||||||
if (filtered.length >= 5) {
|
if (filtered.length >= 5) {
|
||||||
@@ -326,7 +328,7 @@ async function _addPane(anchorBtn) {
|
|||||||
|
|
||||||
item.addEventListener('click', async (e) => {
|
item.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dropdown.remove();
|
closeMenu();
|
||||||
await _createAndAppendPane(m);
|
await _createAndAppendPane(m);
|
||||||
});
|
});
|
||||||
dropdown.appendChild(item);
|
dropdown.appendChild(item);
|
||||||
@@ -371,15 +373,8 @@ async function _addPane(anchorBtn) {
|
|||||||
dropdown.style.bottom = 'auto';
|
dropdown.style.bottom = 'auto';
|
||||||
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
|
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
|
||||||
|
|
||||||
// Close on outside click
|
// Close on outside click or Escape (the latter via the registry).
|
||||||
const close = (e) => {
|
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== anchorBtn);}
|
||||||
if (!dropdown.contains(e.target) && e.target !== anchorBtn) {
|
|
||||||
dropdown.remove();
|
|
||||||
document.removeEventListener('click', close);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create a new pane for the given model and append it to the compare grid. */
|
/** Create a new pane for the given model and append it to the compare grid. */
|
||||||
async function _createAndAppendPane(m) {
|
async function _createAndAppendPane(m) {
|
||||||
@@ -551,7 +546,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
|||||||
|
|
||||||
// Remove any existing dropdown
|
// Remove any existing dropdown
|
||||||
const existing = document.querySelector('.pane-model-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 _effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode;
|
||||||
const filtered = state._cachedModels.filter(m => m.type === _effectiveType);
|
const filtered = state._cachedModels.filter(m => m.type === _effectiveType);
|
||||||
@@ -559,6 +554,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
|||||||
|
|
||||||
const dropdown = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'pane-model-dropdown';
|
dropdown.className = 'pane-model-dropdown';
|
||||||
|
let closeMenu = () => dropdown.remove();
|
||||||
|
|
||||||
filtered.forEach(m => {
|
filtered.forEach(m => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
@@ -573,7 +569,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
|||||||
}
|
}
|
||||||
item.addEventListener('click', async (e) => {
|
item.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dropdown.remove();
|
closeMenu();
|
||||||
|
|
||||||
// Update the model for this pane and persist
|
// Update the model for this pane and persist
|
||||||
state._selectedModels[paneIdx] = {
|
state._selectedModels[paneIdx] = {
|
||||||
@@ -653,15 +649,8 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
|||||||
dropdown.style.top = top + 'px';
|
dropdown.style.top = top + 'px';
|
||||||
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
|
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
|
||||||
|
|
||||||
// Close on outside click
|
// Close on outside click or Escape (the latter via the registry).
|
||||||
const close = (e) => {
|
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== titleBtn);}
|
||||||
if (!dropdown.contains(e.target) && e.target !== titleBtn) {
|
|
||||||
dropdown.remove();
|
|
||||||
document.removeEventListener('click', close);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shuffle / reset ──
|
// ── Shuffle / reset ──
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.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
|
// 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
|
// 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 });
|
el.addEventListener('touchcancel', _lpCancel, { passive: true });
|
||||||
menuBtn.addEventListener('click', (e) => {
|
menuBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
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');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'cookbook-task-dropdown';
|
dropdown.className = 'cookbook-task-dropdown';
|
||||||
@@ -1696,7 +1697,7 @@ export function _renderRunningTab() {
|
|||||||
const ic = _MENU_ICONS[item.action] || '';
|
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.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', () => {
|
div.addEventListener('click', () => {
|
||||||
dropdown.remove();
|
_cleanup();
|
||||||
if (item.custom) { item.custom(); return; }
|
if (item.custom) { item.custom(); return; }
|
||||||
el.querySelector('.cookbook-task-action-' + item.action)?.click();
|
el.querySelector('.cookbook-task-action-' + item.action)?.click();
|
||||||
});
|
});
|
||||||
@@ -1736,17 +1737,21 @@ export function _renderRunningTab() {
|
|||||||
// fixed position no longer matches the originating ⋮ button, so
|
// fixed position no longer matches the originating ⋮ button, so
|
||||||
// it visually drifts. Matches the email kebab behaviour.
|
// it visually drifts. Matches the email kebab behaviour.
|
||||||
const scrollClose = () => _cleanup();
|
const scrollClose = () => _cleanup();
|
||||||
|
let _unreg = () => {};
|
||||||
const _cleanup = () => {
|
const _cleanup = () => {
|
||||||
|
_unreg(); _unreg = () => {};
|
||||||
dropdown.remove();
|
dropdown.remove();
|
||||||
document.removeEventListener('click', closeHandler);
|
document.removeEventListener('click', closeHandler);
|
||||||
window.removeEventListener('scroll', scrollClose, true);
|
window.removeEventListener('scroll', scrollClose, true);
|
||||||
window.visualViewport?.removeEventListener('scroll', scrollClose);
|
window.visualViewport?.removeEventListener('scroll', scrollClose);
|
||||||
};
|
};
|
||||||
|
dropdown._dismiss = _cleanup;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.addEventListener('click', closeHandler);
|
document.addEventListener('click', closeHandler);
|
||||||
window.addEventListener('scroll', scrollClose, true);
|
window.addEventListener('scroll', scrollClose, true);
|
||||||
window.visualViewport?.addEventListener('scroll', scrollClose);
|
window.visualViewport?.addEventListener('scroll', scrollClose);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
_unreg = registerMenuDismiss(_cleanup);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import uiModule from './ui.js';
|
|||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { providerLogo } from './providers.js';
|
import { providerLogo } from './providers.js';
|
||||||
import { modelColor } from './chatRenderer.js';
|
import { modelColor } from './chatRenderer.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
// Shared state/functions injected by init()
|
// Shared state/functions injected by init()
|
||||||
let _envState;
|
let _envState;
|
||||||
@@ -193,18 +194,19 @@ function _rerenderCachedModels() {
|
|||||||
list.querySelectorAll('.hwfit-cached-menu-btn').forEach(btn => {
|
list.querySelectorAll('.hwfit-cached-menu-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
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');
|
const existing = document.querySelector('.hwfit-cached-dropdown');
|
||||||
if (existing && existing._anchor === btn) {
|
if (existing && existing._anchor === btn) {
|
||||||
existing.remove();
|
if (typeof existing._dismiss === 'function') existing._dismiss();
|
||||||
btn.classList.remove('cookbook-menu-active');
|
else { existing.remove(); btn.classList.remove('cookbook-menu-active'); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Otherwise close any other open menu (and clear its anchor's active
|
// Otherwise close any other open menu (and clear its anchor's active
|
||||||
// state) before opening fresh.
|
// state) before opening fresh.
|
||||||
document.querySelectorAll('.hwfit-cached-dropdown').forEach(d => {
|
document.querySelectorAll('.hwfit-cached-dropdown').forEach(d => {
|
||||||
if (d._anchor) d._anchor.classList.remove('cookbook-menu-active');
|
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 item = btn.closest('.memory-item');
|
||||||
const repo = item?.dataset.repo;
|
const repo = item?.dataset.repo;
|
||||||
@@ -215,6 +217,9 @@ function _rerenderCachedModels() {
|
|||||||
dropdown.className = 'hwfit-cached-dropdown';
|
dropdown.className = 'hwfit-cached-dropdown';
|
||||||
dropdown._anchor = btn;
|
dropdown._anchor = btn;
|
||||||
btn.classList.add('cookbook-menu-active');
|
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 _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 _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>';
|
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.className = 'dropdown-item-compact' + (opt.danger ? ' dropdown-item-danger' : '');
|
||||||
div.innerHTML = _di(opt.icon) + '<span>' + opt.label + '</span>';
|
div.innerHTML = _di(opt.icon) + '<span>' + opt.label + '</span>';
|
||||||
div.addEventListener('click', () => {
|
div.addEventListener('click', () => {
|
||||||
dropdown.remove();
|
closeDropdown();
|
||||||
btn.classList.remove('cookbook-menu-active');
|
|
||||||
if (opt.action === 'serve') item.click();
|
if (opt.action === 'serve') item.click();
|
||||||
else if (opt.action === 'delete') _deleteCachedModel(repo, item, false, m);
|
else if (opt.action === 'delete') _deleteCachedModel(repo, item, false, m);
|
||||||
else if (opt.action === 'retry') _retryCachedModel(repo, m);
|
else if (opt.action === 'retry') _retryCachedModel(repo, m);
|
||||||
@@ -264,10 +268,7 @@ function _rerenderCachedModels() {
|
|||||||
const cancelDiv = document.createElement('div');
|
const cancelDiv = document.createElement('div');
|
||||||
cancelDiv.className = 'dropdown-item-compact dropdown-cancel-mobile';
|
cancelDiv.className = 'dropdown-item-compact dropdown-cancel-mobile';
|
||||||
cancelDiv.innerHTML = _di(_cancelIco) + '<span>Cancel</span>';
|
cancelDiv.innerHTML = _di(_cancelIco) + '<span>Cancel</span>';
|
||||||
cancelDiv.addEventListener('click', () => {
|
cancelDiv.addEventListener('click', () => { closeDropdown(); });
|
||||||
dropdown.remove();
|
|
||||||
btn.classList.remove('cookbook-menu-active');
|
|
||||||
});
|
|
||||||
dropdown.appendChild(cancelDiv);
|
dropdown.appendChild(cancelDiv);
|
||||||
const rect = btn.getBoundingClientRect();
|
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;`;
|
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.top = top + 'px';
|
||||||
dropdown.style.visibility = '';
|
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); } };
|
closeDropdown = bindMenuDismiss(dropdown, () => { dropdown.remove(); btn.classList.remove('cookbook-menu-active'); }, (ev) => !dropdown.contains(ev.target) && ev.target !== btn);
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -666,10 +666,11 @@ function _rerenderCachedModels() {
|
|||||||
// reflects the stored presets. Standard Odysseus .dropdown look, positioned
|
// reflects the stored presets. Standard Odysseus .dropdown look, positioned
|
||||||
// fixed at the toggle and right-aligned to it.
|
// fixed at the toggle and right-aligned to it.
|
||||||
function _showSavedConfigMenu(anchor) {
|
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 modelSlots = _presetsForModel(_loadPresets(), repo);
|
||||||
const dropdown = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'dropdown cookbook-saved-menu';
|
dropdown.className = 'dropdown cookbook-saved-menu';
|
||||||
|
let closeMenu = () => { dropdown.remove(); anchor.classList.remove('cookbook-menu-active'); };
|
||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
const minW = 190;
|
const minW = 190;
|
||||||
// Cap width/height to the viewport and start hidden — we clamp the final
|
// Cap width/height to the viewport and start hidden — we clamp the final
|
||||||
@@ -710,7 +711,7 @@ function _rerenderCachedModels() {
|
|||||||
if (e.target === del) return;
|
if (e.target === del) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Close the menu FIRST so it always dismisses, even if loading throws.
|
// Close the menu FIRST so it always dismisses, even if loading throws.
|
||||||
dropdown.remove();
|
closeMenu();
|
||||||
_loadSlotIntoPanel(idx);
|
_loadSlotIntoPanel(idx);
|
||||||
// Confirm the click landed — loading is silent otherwise, so it was
|
// Confirm the click landed — loading is silent otherwise, so it was
|
||||||
// unclear the settings actually changed.
|
// unclear the settings actually changed.
|
||||||
@@ -751,14 +752,7 @@ function _rerenderCachedModels() {
|
|||||||
dropdown.style.left = `${left}px`;
|
dropdown.style.left = `${left}px`;
|
||||||
dropdown.style.top = `${top}px`;
|
dropdown.style.top = `${top}px`;
|
||||||
dropdown.style.visibility = '';
|
dropdown.style.visibility = '';
|
||||||
const close = (ev) => {
|
closeMenu = bindMenuDismiss(dropdown, () => { dropdown.remove(); anchor.classList.remove('cookbook-menu-active'); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Save" segment — save the current config directly.
|
// "Save" segment — save the current config directly.
|
||||||
@@ -766,7 +760,7 @@ function _rerenderCachedModels() {
|
|||||||
if (savedSaveBtn) {
|
if (savedSaveBtn) {
|
||||||
savedSaveBtn.addEventListener('click', async (e) => {
|
savedSaveBtn.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
document.querySelectorAll('.cookbook-saved-menu').forEach(d => d.remove());
|
document.querySelectorAll('.cookbook-saved-menu').forEach(dismissOrRemove);
|
||||||
await _saveCurrentConfig();
|
await _saveCurrentConfig();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -775,9 +769,10 @@ function _rerenderCachedModels() {
|
|||||||
if (savedArrowBtn) {
|
if (savedArrowBtn) {
|
||||||
savedArrowBtn.addEventListener('click', (e) => {
|
savedArrowBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (document.querySelector('.cookbook-saved-menu')) {
|
const openSaved = document.querySelector('.cookbook-saved-menu');
|
||||||
document.querySelectorAll('.cookbook-saved-menu').forEach(d => d.remove());
|
if (openSaved) {
|
||||||
savedArrowBtn.classList.remove('cookbook-menu-active');
|
if (typeof openSaved._dismiss === 'function') openSaved._dismiss();
|
||||||
|
else { openSaved.remove(); savedArrowBtn.classList.remove('cookbook-menu-active'); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
savedArrowBtn.classList.add('cookbook-menu-active');
|
savedArrowBtn.classList.add('cookbook-menu-active');
|
||||||
@@ -822,9 +817,10 @@ function _rerenderCachedModels() {
|
|||||||
if (_splitArrow) {
|
if (_splitArrow) {
|
||||||
_splitArrow.addEventListener('click', (ev) => {
|
_splitArrow.addEventListener('click', (ev) => {
|
||||||
ev.stopPropagation();
|
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');
|
const menu = document.createElement('div');
|
||||||
menu.className = 'cookbook-task-dropdown cookbook-gpu-split-menu';
|
menu.className = 'cookbook-task-dropdown cookbook-gpu-split-menu';
|
||||||
|
let closeMenu = () => menu.remove();
|
||||||
const mk = (label, cls, onClick) => {
|
const mk = (label, cls, onClick) => {
|
||||||
const it = document.createElement('div');
|
const it = document.createElement('div');
|
||||||
it.className = 'dropdown-item-compact' + (cls ? ' ' + cls : '');
|
it.className = 'dropdown-item-compact' + (cls ? ' ' + cls : '');
|
||||||
@@ -832,7 +828,7 @@ function _rerenderCachedModels() {
|
|||||||
it.textContent = label;
|
it.textContent = label;
|
||||||
it.addEventListener('click', (e) => {
|
it.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
menu.remove();
|
closeMenu();
|
||||||
if (onClick) onClick();
|
if (onClick) onClick();
|
||||||
});
|
});
|
||||||
return it;
|
return it;
|
||||||
@@ -859,18 +855,11 @@ function _rerenderCachedModels() {
|
|||||||
}
|
}
|
||||||
menu.style.top = top + 'px';
|
menu.style.top = top + 'px';
|
||||||
}
|
}
|
||||||
const close = (e) => {
|
// Close on outside click or Escape (via the registry); also dismiss
|
||||||
if (!menu.contains(e.target) && e.target !== _splitArrow) {
|
// on scroll since the popup is fixed-positioned to the arrow.
|
||||||
menu.remove();
|
const _scrollClose = () => closeMenu();
|
||||||
document.removeEventListener('click', close);
|
closeMenu = bindMenuDismiss(menu, () => { menu.remove(); window.removeEventListener('scroll', _scrollClose, true); }, (e) => !menu.contains(e.target) && e.target !== _splitArrow);
|
||||||
window.removeEventListener('scroll', _scrollClose, true);
|
window.addEventListener('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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const _withSpinner = async (btn, fn) => {
|
const _withSpinner = async (btn, fn) => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import spinnerModule from './spinner.js';
|
|||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
import { langIcon } from './langIcons.js';
|
import { langIcon } from './langIcons.js';
|
||||||
|
import { registerMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
// ── Injected references from documentModule ──
|
// ── Injected references from documentModule ──
|
||||||
let API_BASE = '';
|
let API_BASE = '';
|
||||||
@@ -184,7 +185,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
|||||||
|
|
||||||
function _showLibDropdown(anchor, items, opts) {
|
function _showLibDropdown(anchor, items, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
document.querySelectorAll('._lib-dd').forEach(d => d.remove());
|
document.querySelectorAll('._lib-dd').forEach(dismissOrRemove);
|
||||||
const dd = document.createElement('div');
|
const dd = document.createElement('div');
|
||||||
dd.className = 'dropdown session-dropdown-menu _lib-dd';
|
dd.className = 'dropdown session-dropdown-menu _lib-dd';
|
||||||
for (const item of items) {
|
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 iconKey = item.icon || item.label.toLowerCase();
|
||||||
const iconSvg = _LIB_DD_ICONS[iconKey] || '';
|
const iconSvg = _LIB_DD_ICONS[iconKey] || '';
|
||||||
row.innerHTML = (iconSvg ? '<span class="dropdown-icon">' + iconSvg + '</span>' : '') + '<span>' + item.label + '</span>';
|
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);
|
dd.appendChild(row);
|
||||||
}
|
}
|
||||||
if (typeof opts.onSelect === 'function') {
|
if (typeof opts.onSelect === 'function') {
|
||||||
@@ -202,7 +203,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
|||||||
sel.innerHTML =
|
sel.innerHTML =
|
||||||
'<span class="dropdown-icon"><span style="font-size:16px;line-height:1;position:relative;top:-2px;">●</span></span>'
|
'<span class="dropdown-icon"><span style="font-size:16px;line-height:1;position:relative;top:-2px;">●</span></span>'
|
||||||
+ '<span>Select</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);
|
dd.appendChild(sel);
|
||||||
}
|
}
|
||||||
const cancel = document.createElement('div');
|
const cancel = document.createElement('div');
|
||||||
@@ -210,7 +211,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
|||||||
cancel.innerHTML =
|
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 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>';
|
+ '<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);
|
dd.appendChild(cancel);
|
||||||
document.body.appendChild(dd);
|
document.body.appendChild(dd);
|
||||||
const rect = anchor.getBoundingClientRect();
|
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'; }
|
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);
|
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
|
// Swipe-down-to-dismiss (mobile). Mirrors the bottom-sheet feel — drag the
|
||||||
// popup down and release past the threshold to close. Below threshold,
|
// 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.transition = 'transform 0.15s ease, opacity 0.15s ease';
|
||||||
dd.style.transform = 'translateY(120px)';
|
dd.style.transform = 'translateY(120px)';
|
||||||
dd.style.opacity = '0';
|
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);
|
document.removeEventListener('click', close);
|
||||||
|
setTimeout(() => dd.remove(), 160);
|
||||||
} else {
|
} else {
|
||||||
dd.style.transition = 'transform 0.18s ease, opacity 0.18s ease';
|
dd.style.transition = 'transform 0.18s ease, opacity 0.18s ease';
|
||||||
dd.style.transform = '';
|
dd.style.transform = '';
|
||||||
@@ -380,6 +394,10 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
|||||||
function libraryRenderGrid() {
|
function libraryRenderGrid() {
|
||||||
const grid = document.getElementById('doclib-grid');
|
const grid = document.getElementById('doclib-grid');
|
||||||
if (!grid) return;
|
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 = '';
|
grid.innerHTML = '';
|
||||||
// Drop any previous inline load-more — regenerated below alongside the list.
|
// 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());
|
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) {
|
if (dropdown) {
|
||||||
const isOpen = dropdown.style.display !== 'none' && dropdown.parentElement === document.body;
|
const isOpen = dropdown.style.display !== 'none' && dropdown.parentElement === document.body;
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
dropdown.style.display = 'none';
|
hideCardDropdown();
|
||||||
menuWrap.appendChild(dropdown);
|
|
||||||
} else {
|
} else {
|
||||||
// Position fixed on body to escape overflow clipping
|
// Position fixed on body to escape overflow clipping
|
||||||
const rect = menuBtn.getBoundingClientRect();
|
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.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'; }
|
if (mr.left < 8) { dropdown.style.left = '8px'; dropdown.style.right = 'auto'; }
|
||||||
});
|
});
|
||||||
// Close on outside click
|
// Close on outside click or Escape (the latter via the registry).
|
||||||
const close = (ev) => {
|
_cardDocClick = (ev) => {
|
||||||
if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) {
|
if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) hideCardDropdown();
|
||||||
dropdown.style.display = 'none';
|
|
||||||
menuWrap.appendChild(dropdown);
|
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
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.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;';
|
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 _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>';
|
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.style.cssText = 'background:none;border:none;width:100%;';
|
||||||
openItem.innerHTML = _di(_openIco) + '<span>Open</span>';
|
openItem.innerHTML = _di(_openIco) + '<span>Open</span>';
|
||||||
if (doc.session_id) {
|
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 {
|
} else {
|
||||||
openItem.disabled = true;
|
openItem.disabled = true;
|
||||||
openItem.style.opacity = '0.35';
|
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.style.cssText = 'background:none;border:none;width:100%;';
|
||||||
cloneItem.innerHTML = _di(_cloneIco) + '<span>Clone</span>';
|
cloneItem.innerHTML = _di(_cloneIco) + '<span>Clone</span>';
|
||||||
cloneItem.title = 'Clone to active session';
|
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);
|
dropdown.appendChild(cloneItem);
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
@@ -647,7 +676,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
|||||||
exportItem.innerHTML = _di(_exportIco) + '<span>Export</span>';
|
exportItem.innerHTML = _di(_exportIco) + '<span>Export</span>';
|
||||||
exportItem.addEventListener('click', async (e) => {
|
exportItem.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dropdown.style.display = 'none';
|
hideCardDropdown();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/document/${doc.id}`);
|
const res = await fetch(`${API_BASE}/api/document/${doc.id}`);
|
||||||
if (!res.ok) throw new Error('Failed');
|
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.title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)';
|
||||||
archiveItem.addEventListener('click', async (e) => {
|
archiveItem.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dropdown.style.display = 'none';
|
hideCardDropdown();
|
||||||
const toArchived = !_libraryArchivedView;
|
const toArchived = !_libraryArchivedView;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
|
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.className = 'dropdown-item-compact dropdown-item-danger';
|
||||||
deleteItem.style.cssText = 'background:none;border:none;width:100%;';
|
deleteItem.style.cssText = 'background:none;border:none;width:100%;';
|
||||||
deleteItem.innerHTML = _di(_deleteIco) + '<span>Delete</span>';
|
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);
|
dropdown.appendChild(deleteItem);
|
||||||
|
|
||||||
menuWrap.appendChild(dropdown);
|
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 { previewZoneAt, clearPreview, snapModalToZone } from './tileManager.js';
|
||||||
import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js';
|
import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js';
|
||||||
|
import { dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
const _state = new Map(); // id -> { restoreFn, closeFn, railBtnId, isMinimized, restoreMinHeight }
|
const _state = new Map(); // id -> { restoreFn, closeFn, railBtnId, isMinimized, restoreMinHeight }
|
||||||
|
|
||||||
@@ -1463,7 +1464,7 @@ window.addEventListener('modal-dismissed', (e) => {
|
|||||||
if (id === 'cookbook-modal') {
|
if (id === 'cookbook-modal') {
|
||||||
document.querySelectorAll(
|
document.querySelectorAll(
|
||||||
'.cookbook-task-dropdown, .cookbook-gpu-split-menu, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu'
|
'.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 themeModule from './theme.js';
|
||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
|
import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
let toastEl = null;
|
let toastEl = null;
|
||||||
let autoScrollEnabled = true;
|
let autoScrollEnabled = true;
|
||||||
@@ -769,7 +770,7 @@ function _initScrollDismiss() {
|
|||||||
if (chatHistory) {
|
if (chatHistory) {
|
||||||
chatHistory.addEventListener('scroll', () => {
|
chatHistory.addEventListener('scroll', () => {
|
||||||
chatHistory.querySelectorAll('.dropdown.show').forEach(d => d.classList.remove('show'));
|
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 });
|
}, { passive: true });
|
||||||
} else {
|
} else {
|
||||||
// Retry once if element doesn't exist yet
|
// Retry once if element doesn't exist yet
|
||||||
@@ -822,7 +823,8 @@ const uiModule = {
|
|||||||
el,
|
el,
|
||||||
esc,
|
esc,
|
||||||
isTouchInsideModal,
|
isTouchInsideModal,
|
||||||
emptyStateIcon
|
emptyStateIcon,
|
||||||
|
registerMenuDismiss
|
||||||
};
|
};
|
||||||
|
|
||||||
export default uiModule;
|
export default uiModule;
|
||||||
@@ -883,7 +885,9 @@ if ('ontouchstart' in window) {
|
|||||||
'.email-card-dropdown, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu'
|
'.email-card-dropdown, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu'
|
||||||
).forEach(d => {
|
).forEach(d => {
|
||||||
if (d._anchor) d._anchor.classList.remove('cookbook-menu-active', 'reader-more-active');
|
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();
|
e.stopImmediatePropagation(); e.preventDefault();
|
||||||
return;
|
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;
|
const t = e.target;
|
||||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||||
const expanded = document.querySelector('.doclib-card-expanded');
|
const expanded = document.querySelector('.doclib-card-expanded');
|
||||||
|
|||||||
116
tests/test_esc_menu_stack_js.py
Normal file
116
tests/test_esc_menu_stack_js.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Pin the DOM-free Escape-dismissal registry in static/js/escMenuStack.js.
|
||||||
|
|
||||||
|
Driven through `node --input-type=module` so we exercise the real JS without a
|
||||||
|
full Vitest/Jest setup (same spirit as test_reply_recipients_js.py). Skips when
|
||||||
|
`node` is not installed rather than failing.
|
||||||
|
|
||||||
|
The module source is inlined into the eval'd module body (rather than imported
|
||||||
|
by path) so the test runs identically on Windows and POSIX — the repo has no
|
||||||
|
`"type": "module"` in package.json, so a path import of a `.js` file is treated
|
||||||
|
as CommonJS by node and rejects the ES `export`s. escMenuStack.js has no
|
||||||
|
imports of its own, so inlining is exact.
|
||||||
|
|
||||||
|
Background: ad-hoc dropdowns/popups (document-library card menus, chat context
|
||||||
|
popups, cookbook serve menus, calendar event menus, compare pane menus) live
|
||||||
|
outside the .modal system, so the global Escape arbiter in ui.js couldn't see
|
||||||
|
them. They register a dismiss callback here while open; the arbiter calls
|
||||||
|
dismissTopMenu() to close the most-recently-opened one. These tests lock in the
|
||||||
|
LIFO contract and the "exactly one menu per Escape, never get stuck" guarantees
|
||||||
|
the arbiter relies on.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_HELPER = _REPO / "static" / "js" / "escMenuStack.js"
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
_SRC = _HELPER.read_text(encoding="utf-8") if _HELPER.exists() else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _run(body: str) -> str:
|
||||||
|
"""Run `body` as a module with the registry's functions already in scope."""
|
||||||
|
js = _SRC + "\n" + body
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=js, capture_output=True, text=True, encoding="utf-8",
|
||||||
|
cwd=str(_REPO), timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return proc.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_empty_stack_dismiss_is_noop():
|
||||||
|
# Nothing open: returns false so the arbiter can fall through to modals.
|
||||||
|
body = "console.log(JSON.stringify([dismissTopMenu(), _openMenuCount()]));"
|
||||||
|
assert json.loads(_run(body)) == [False, 0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_dismiss_is_lifo_and_closes_exactly_one():
|
||||||
|
body = """
|
||||||
|
const order = [];
|
||||||
|
registerMenuDismiss(() => order.push('A'));
|
||||||
|
registerMenuDismiss(() => order.push('B'));
|
||||||
|
const r1 = dismissTopMenu(); // closes B (most recent)
|
||||||
|
const r2 = dismissTopMenu(); // closes A
|
||||||
|
const r3 = dismissTopMenu(); // nothing left
|
||||||
|
console.log(JSON.stringify({ order, r1, r2, r3, left: _openMenuCount() }));
|
||||||
|
"""
|
||||||
|
out = json.loads(_run(body))
|
||||||
|
assert out["order"] == ["B", "A"] # LIFO
|
||||||
|
assert [out["r1"], out["r2"], out["r3"]] == [True, True, False]
|
||||||
|
assert out["left"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_unregister_removes_entry_without_firing():
|
||||||
|
body = """
|
||||||
|
let fired = false;
|
||||||
|
const unreg = registerMenuDismiss(() => { fired = true; });
|
||||||
|
unreg(); // menu closed itself via outside-click
|
||||||
|
const r = dismissTopMenu(); // Escape should now find nothing
|
||||||
|
console.log(JSON.stringify({ fired, r, left: _openMenuCount() }));
|
||||||
|
"""
|
||||||
|
# Unregistering must not invoke the callback and must leave the stack empty.
|
||||||
|
assert json.loads(_run(body)) == {"fired": False, "r": False, "left": 0}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_unregister_targets_correct_entry_when_interleaved():
|
||||||
|
body = """
|
||||||
|
const order = [];
|
||||||
|
const unregA = registerMenuDismiss(() => order.push('A'));
|
||||||
|
registerMenuDismiss(() => order.push('B'));
|
||||||
|
unregA(); // remove the older entry, keep B
|
||||||
|
dismissTopMenu(); // should fire B, not A
|
||||||
|
console.log(JSON.stringify({ order, left: _openMenuCount() }));
|
||||||
|
"""
|
||||||
|
out = json.loads(_run(body))
|
||||||
|
assert out["order"] == ["B"]
|
||||||
|
assert out["left"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_throwing_dismiss_still_pops_and_reports_handled():
|
||||||
|
body = """
|
||||||
|
registerMenuDismiss(() => { throw new Error('boom'); });
|
||||||
|
const r = dismissTopMenu(); // must swallow the error...
|
||||||
|
console.log(JSON.stringify({ r, left: _openMenuCount() }));
|
||||||
|
"""
|
||||||
|
# A misbehaving menu must not wedge the stack or crash the arbiter.
|
||||||
|
assert json.loads(_run(body)) == {"r": True, "left": 0}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_non_function_registration_is_ignored():
|
||||||
|
body = """
|
||||||
|
const unreg = registerMenuDismiss(null);
|
||||||
|
console.log(JSON.stringify({ left: _openMenuCount(), unregType: typeof unreg }));
|
||||||
|
"""
|
||||||
|
# Bad input must not enter the stack, and must still return a callable.
|
||||||
|
assert json.loads(_run(body)) == {"left": 0, "unregType": "function"}
|
||||||
Reference in New Issue
Block a user