fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684)

The app's ad-hoc dropdown/context menus each wire their own document-level
outside-click listener, but that listener only removes itself on an *outside*
click. Every other dismissal path -- clicking a menu item (which calls
el.remove() directly), a Cancel button, Escape, or the "close the
previously-open menu" reopen sweep -- tears the node down without
unregistering the listener, orphaning it on `document`. The stranded listener
then lingers and can break the next menu interaction: the recurring "the
button stops working until I refresh the page" class of bug (e.g. delete an
email, then the kebab/more button is dead on the other rows).

Route all 16 of these menus through the existing escMenuStack helper
(bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js
_showLibDropdown, cookbookRunning.js, and research/panel.js already do: a
single idempotent close() owns the teardown and is released on every dismissal
path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and
Escape flows through the central LIFO esc-stack arbiter. Net -49 lines.

Menus migrated: cookbook _showDepMenu; document export menu and
_openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary
_showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery
_showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings
(3 unified-integrations dropdowns); skills _openSkillMenu; tasks
_showTaskDropdown; compare _toggleExportMenu.

Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms
ghost-click guard, emailLibrary's reader-more-active anchor class and the
bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles).

Six menus with custom lifecycles (notes _openReminderMenu, sessions
long-press, document markdown-toolbar, emojiPicker, compare model selector)
are intentionally left for a follow-up -- each needs individual review.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mocchibird
2026-06-22 20:40:56 +02:00
committed by GitHub
parent b899095f18
commit 4c82e4a172
10 changed files with 106 additions and 155 deletions
+4 -3
View File
@@ -39,6 +39,7 @@ import spinnerModule from '../spinner.js';
import themeModule from '../theme.js'; import themeModule from '../theme.js';
import presetsModule from '../presets.js'; import presetsModule from '../presets.js';
import markdownModule from '../markdown.js'; import markdownModule from '../markdown.js';
import { bindMenuDismiss } from '../escMenuStack.js';
var escapeHtml = uiModule.esc; var escapeHtml = uiModule.esc;
@@ -1062,6 +1063,7 @@ function _buildComparisonMarkdown() {
} }
let _exportMenuEl = null; let _exportMenuEl = null;
let _closeExportMenu = () => {};
function _toggleExportMenu(btn) { function _toggleExportMenu(btn) {
if (_exportMenuEl) { _closeExportMenu(); return; } if (_exportMenuEl) { _closeExportMenu(); return; }
const r = btn.getBoundingClientRect(); const r = btn.getBoundingClientRect();
@@ -1085,10 +1087,9 @@ function _toggleExportMenu(btn) {
} }
document.body.appendChild(m); document.body.appendChild(m);
_exportMenuEl = m; _exportMenuEl = m;
setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0); _closeExportMenu = bindMenuDismiss(m, () => {
}
function _closeExportMenu() {
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; } if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
}, (ev) => !m.contains(ev.target));
} }
async function _exportCopyMarkdown(_btn) { async function _exportCopyMarkdown(_btn) {
+7 -10
View File
@@ -33,6 +33,8 @@ import {
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel, _fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel,
} from './cookbookServe.js'; } from './cookbookServe.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const STORAGE_KEY = 'cookbook-presets'; const STORAGE_KEY = 'cookbook-presets';
const LAST_STATE_KEY = 'cookbook-last-state'; const LAST_STATE_KEY = 'cookbook-last-state';
const SERVE_STATE_KEY = 'cookbook-serve-state'; const SERVE_STATE_KEY = 'cookbook-serve-state';
@@ -1514,7 +1516,7 @@ async function _fetchDependencies() {
// Wire the installed-package menu. // Wire the installed-package menu.
function _showDepMenu(anchor) { function _showDepMenu(anchor) {
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove()); document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
const row = anchor.closest('.cookbook-dep-row'); const row = anchor.closest('.cookbook-dep-row');
if (!row) return; if (!row) return;
const pipName = row.dataset.depPip; const pipName = row.dataset.depPip;
@@ -1535,7 +1537,7 @@ async function _fetchDependencies() {
it.title = `Update ${pkgName} to the latest version (pip install -U)`; it.title = `Update ${pkgName} to the latest version (pip install -U)`;
it.addEventListener('click', async (e) => { it.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
dropdown.remove(); close();
await _installDep(pipName, pkgName, isLocalOnly, true, null); await _installDep(pipName, pkgName, isLocalOnly, true, null);
}); });
dropdown.appendChild(it); dropdown.appendChild(it);
@@ -1563,19 +1565,14 @@ async function _fetchDependencies() {
dropdown.appendChild(source); dropdown.appendChild(source);
} }
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
const close = (ev) => { const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) =>
if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) { !dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target));
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
} }
list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => { list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
if (document.querySelector('.cookbook-dep-menu')) { if (document.querySelector('.cookbook-dep-menu')) {
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove()); document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
return; return;
} }
_showDepMenu(btn); _showDepMenu(btn);
+21 -37
View File
@@ -16,6 +16,7 @@ import spinnerModule from './spinner.js';
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js'; import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
import signatureModule from './signature.js'; import signatureModule from './signature.js';
import * as Modals from './modalManager.js'; import * as Modals from './modalManager.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
let API_BASE = ''; let API_BASE = '';
let isOpen = false; let isOpen = false;
@@ -3331,7 +3332,10 @@ import * as Modals from './modalManager.js';
let _docAiReplyChoiceMenu = null; let _docAiReplyChoiceMenu = null;
function _closeDocAiReplyChoice() { function _closeDocAiReplyChoice() {
if (_docAiReplyChoiceMenu) { if (_docAiReplyChoiceMenu) {
try { _docAiReplyChoiceMenu.remove(); } catch (_) {} // Tear down through the menu's registered dismiss (drops its outside-click
// listener + Escape-stack entry) rather than orphaning them with a raw
// remove(); the onClose below nulls the ref.
try { dismissOrRemove(_docAiReplyChoiceMenu); } catch (_) {}
_docAiReplyChoiceMenu = null; _docAiReplyChoiceMenu = null;
} }
} }
@@ -3382,6 +3386,14 @@ import * as Modals from './modalManager.js';
const noteInput = menu.querySelector('[data-note-input]'); const noteInput = menu.querySelector('[data-note-input]');
setTimeout(() => noteInput?.focus(), 0); setTimeout(() => noteInput?.focus(), 0);
menu.addEventListener('mousedown', (ev) => ev.stopPropagation()); menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
document.body.appendChild(menu);
_docAiReplyChoiceMenu = menu;
// Outside-click AND Escape both route through the central esc-stack via
// bindMenuDismiss; onClose owns the actual teardown (node removal + state).
const close = bindMenuDismiss(menu, () => {
try { menu.remove(); } catch (_) {}
if (_docAiReplyChoiceMenu === menu) _docAiReplyChoiceMenu = null;
});
menu.addEventListener('click', async (ev) => { menu.addEventListener('click', async (ev) => {
const choice = ev.target.closest('[data-mode]'); const choice = ev.target.closest('[data-mode]');
if (!choice) return; if (!choice) return;
@@ -3389,26 +3401,9 @@ import * as Modals from './modalManager.js';
ev.stopPropagation(); ev.stopPropagation();
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast'; const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
const noteHint = (noteInput?.value || '').trim(); const noteHint = (noteInput?.value || '').trim();
_closeDocAiReplyChoice(); close();
await _aiReply({ mode, noteHint }); await _aiReply({ mode, noteHint });
}); });
document.body.appendChild(menu);
_docAiReplyChoiceMenu = menu;
const outsideClose = (ev) => {
if (menu.contains(ev.target)) return;
document.removeEventListener('click', outsideClose, true);
_closeDocAiReplyChoice();
};
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
// Esc to close.
const escClose = (ev) => {
if (ev.key === 'Escape') {
ev.stopPropagation();
document.removeEventListener('keydown', escClose, true);
_closeDocAiReplyChoice();
}
};
document.addEventListener('keydown', escClose, true);
} }
async function _aiReply(opts = {}) { async function _aiReply(opts = {}) {
@@ -8591,9 +8586,10 @@ import * as Modals from './modalManager.js';
function showExportMenu(e, anchorRect) { function showExportMenu(e, anchorRect) {
if (e) e.stopPropagation(); if (e) e.stopPropagation();
// Remove existing menu if any // Remove existing menu if any (toggle off) — tear it down through its
// registered dismiss so the outside-click listener + Escape-stack entry go.
const existing = document.getElementById('doc-export-menu'); const existing = document.getElementById('doc-export-menu');
if (existing) { existing.remove(); return; } if (existing) { dismissOrRemove(existing); return; }
// Position from provided rect, clicked element, or fallback to language select // Position from provided rect, clicked element, or fallback to language select
const rect = anchorRect const rect = anchorRect
@@ -8643,7 +8639,7 @@ import * as Modals from './modalManager.js';
const item = document.createElement('button'); const item = document.createElement('button');
item.className = 'doc-overflow-item'; item.className = 'doc-overflow-item';
item.textContent = opt.label; item.textContent = opt.label;
item.addEventListener('click', (ev) => { ev.stopPropagation(); menu.remove(); opt.fn(); }); item.addEventListener('click', (ev) => { ev.stopPropagation(); close(); opt.fn(); });
menu.appendChild(item); menu.appendChild(item);
if (opt._divider) { if (opt._divider) {
const sep = document.createElement('div'); const sep = document.createElement('div');
@@ -8661,21 +8657,9 @@ import * as Modals from './modalManager.js';
menu.style.top = 'auto'; menu.style.top = 'auto';
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px'; menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
} }
const close = (ev) => { // Outside-click AND Escape both route through the central esc-stack via
if (ev && ev.type === 'keydown') { // bindMenuDismiss; onClose owns the actual node removal.
if (ev.key !== 'Escape') return; const close = bindMenuDismiss(menu, () => { menu.remove(); });
ev.preventDefault();
ev.stopPropagation();
ev.stopImmediatePropagation?.();
} else if (ev && menu.contains(ev.target)) {
return;
}
menu.remove();
document.removeEventListener('click', close);
document.removeEventListener('keydown', close, true);
};
setTimeout(() => document.addEventListener('click', close), 100);
document.addEventListener('keydown', close, true);
} }
function exportAsHtml() { function exportAsHtml() {
+6 -11
View File
@@ -9,6 +9,7 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
import * as Modals from './modalManager.js'; import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js'; import { applyEdgeDock } from './modalSnap.js';
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js'; import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
const _acct = () => window.__odysseusActiveEmailAccount const _acct = () => window.__odysseusActiveEmailAccount
@@ -915,7 +916,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
} }
function _showEmailMenu(em, anchor, itemEl) { function _showEmailMenu(em, anchor, itemEl) {
document.querySelectorAll('.email-dropdown').forEach(d => d.remove()); document.querySelectorAll('.email-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div'); const dropdown = document.createElement('div');
dropdown.className = 'dropdown email-dropdown show'; dropdown.className = 'dropdown email-dropdown show';
@@ -938,7 +939,7 @@ function _showEmailMenu(em, anchor, itemEl) {
_showRemindSubmenu(em, dropdown); _showRemindSubmenu(em, dropdown);
return; return;
} }
dropdown.remove(); close();
a.action(); a.action();
}); });
dropdown.appendChild(menuItem); dropdown.appendChild(menuItem);
@@ -946,13 +947,7 @@ function _showEmailMenu(em, anchor, itemEl) {
anchor.appendChild(dropdown); anchor.appendChild(dropdown);
const close = (e) => { const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && !anchor.contains(ev.target));
if (!dropdown.contains(e.target) && !anchor.contains(e.target)) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
} }
// ---- Reminder submenu (creates a Note with a reminder for this email) ---- // ---- Reminder submenu (creates a Note with a reminder for this email) ----
@@ -987,7 +982,7 @@ function _showRemindSubmenu(em, parentDropdown) {
item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`; item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
item.addEventListener('click', async (e) => { item.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
parentDropdown.remove(); dismissOrRemove(parentDropdown);
await _createReplyReminder(em, p.date); await _createReplyReminder(em, p.date);
}); });
parentDropdown.appendChild(item); parentDropdown.appendChild(item);
@@ -997,7 +992,7 @@ function _showRemindSubmenu(em, parentDropdown) {
customItem.innerHTML = '<span>Pick date and time…</span>'; customItem.innerHTML = '<span>Pick date and time…</span>';
customItem.addEventListener('click', async (e) => { customItem.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
parentDropdown.remove(); dismissOrRemove(parentDropdown);
const tmp = document.createElement('input'); const tmp = document.createElement('input');
tmp.type = 'datetime-local'; tmp.type = 'datetime-local';
const def = new Date(tomorrow); const def = new Date(tomorrow);
+19 -38
View File
@@ -23,6 +23,7 @@ import {
} from './emailLibrary/signatureFold.js'; } from './emailLibrary/signatureFold.js';
import { state } from './emailLibrary/state.js'; import { state } from './emailLibrary/state.js';
import { collapseSidebarToRail } from './modalSnap.js'; import { collapseSidebarToRail } from './modalSnap.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
let _emailUnreadChipClickWired = false; let _emailUnreadChipClickWired = false;
@@ -5499,16 +5500,12 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
// Toggle: if a dropdown for THIS anchor is already open, close it. // Toggle: if a dropdown for THIS anchor is already open, close it.
const existing = document.querySelector('.email-card-dropdown'); const existing = document.querySelector('.email-card-dropdown');
if (existing && existing._anchor === anchor) { if (existing && existing._anchor === anchor) {
existing.remove(); dismissOrRemove(existing);
anchor.classList.remove('reader-more-active');
return; return;
} }
// Otherwise close any other open dropdown (and clear its anchor's active // Otherwise close any other open dropdown (its own teardown clears its
// state) before opening a fresh one. // anchor's active state) before opening a fresh one.
document.querySelectorAll('.email-card-dropdown').forEach(d => { document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
if (d._anchor) d._anchor.classList.remove('reader-more-active');
d.remove();
});
const dropdown = document.createElement('div'); const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown'; dropdown.className = 'email-card-dropdown';
@@ -5721,8 +5718,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
_showLibRemindSubmenu(em, dropdown); _showLibRemindSubmenu(em, dropdown);
return; return;
} }
dropdown.remove(); close();
anchor.classList.remove('reader-more-active');
a.action(); a.action();
}); });
dropdown.appendChild(item); dropdown.appendChild(item);
@@ -5735,25 +5731,20 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>'; cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
cancelItem.addEventListener('click', (e) => { cancelItem.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
dropdown.remove(); close();
anchor.classList.remove('reader-more-active');
}); });
dropdown.appendChild(cancelItem); dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect); _fitEmailDropdown(dropdown, rect);
const close = (ev) => { const close = bindMenuDismiss(dropdown, () => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove(); dropdown.remove();
anchor.classList.remove('reader-more-active'); anchor.classList.remove('reader-more-active');
document.removeEventListener('click', close, true); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
} }
function _showCardMenu(em, anchor) { function _showCardMenu(em, anchor) {
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove()); document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div'); const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown'; dropdown.className = 'email-card-dropdown';
@@ -5918,8 +5909,7 @@ function _showCardMenu(em, anchor) {
_showLibRemindSubmenu(em, dropdown); _showLibRemindSubmenu(em, dropdown);
return; return;
} }
dropdown.remove(); close();
anchor.classList.remove('reader-more-active');
a.action(); a.action();
}); });
dropdown.appendChild(item); dropdown.appendChild(item);
@@ -5932,26 +5922,21 @@ function _showCardMenu(em, anchor) {
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>'; cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
cancelItem.addEventListener('click', (e) => { cancelItem.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
dropdown.remove(); close();
anchor.classList.remove('reader-more-active');
}); });
dropdown.appendChild(cancelItem); dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect); _fitEmailDropdown(dropdown, rect);
const close = (ev) => { const close = bindMenuDismiss(dropdown, () => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove(); dropdown.remove();
anchor.classList.remove('reader-more-active'); anchor.classList.remove('reader-more-active');
document.removeEventListener('click', close, true); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
} }
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button. // Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
function _showBulkActionsMenu(anchor) { function _showBulkActionsMenu(anchor) {
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove()); document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div'); const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown email-bulk-menu'; dropdown.className = 'email-card-dropdown email-bulk-menu';
const rect = anchor.getBoundingClientRect(); const rect = anchor.getBoundingClientRect();
@@ -5968,7 +5953,7 @@ function _showBulkActionsMenu(anchor) {
const it = document.createElement('div'); const it = document.createElement('div');
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : ''); it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`; it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); }); it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
dropdown.appendChild(it); dropdown.appendChild(it);
} }
// Mobile-only Cancel — matches the per-card and sidebar dropdowns. // Mobile-only Cancel — matches the per-card and sidebar dropdowns.
@@ -5978,7 +5963,7 @@ function _showBulkActionsMenu(anchor) {
cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`; cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
cancelIt.addEventListener('click', (e) => { cancelIt.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
dropdown.remove(); close();
// Cancel inside the bulk-Actions menu also exits select mode — matches the // Cancel inside the bulk-Actions menu also exits select mode — matches the
// documents bulk dropdown. // documents bulk dropdown.
state._selectMode = false; state._selectMode = false;
@@ -5989,13 +5974,9 @@ function _showBulkActionsMenu(anchor) {
dropdown.appendChild(cancelIt); dropdown.appendChild(cancelIt);
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect); _fitEmailDropdown(dropdown, rect);
const close = (ev) => { const close = bindMenuDismiss(dropdown, () => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove(); dropdown.remove();
document.removeEventListener('click', close, true); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
} }
function _updateBulkBar() { function _updateBulkBar() {
+5 -10
View File
@@ -6,6 +6,7 @@ import uiModule from './ui.js';
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js'; import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js'; import { makeWindowDraggable } from './windowDrag.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
let _open = false; let _open = false;
@@ -2514,7 +2515,7 @@ export function openGallery() {
// shares the exact same dropdown style/behaviour. // shares the exact same dropdown style/behaviour.
const _bulkActionsBtn = document.getElementById('gallery-bulk-actions'); const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
function _showGalleryBulkMenu(anchor) { function _showGalleryBulkMenu(anchor) {
document.querySelectorAll('.gallery-bulk-menu').forEach(d => d.remove()); document.querySelectorAll('.gallery-bulk-menu').forEach(dismissOrRemove);
// Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it // Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it
// matches every other menu in the app. Positioned fixed at the button. // matches every other menu in the app. Positioned fixed at the button.
const dropdown = document.createElement('div'); const dropdown = document.createElement('div');
@@ -2548,17 +2549,11 @@ export function openGallery() {
const it = document.createElement('div'); const it = document.createElement('div');
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : ''); it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`; it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); }); it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
dropdown.appendChild(it); dropdown.appendChild(it);
} }
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
const close = (ev) => { const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
} }
_bulkActionsBtn?.addEventListener('click', (e) => { _bulkActionsBtn?.addEventListener('click', (e) => {
@@ -2567,7 +2562,7 @@ export function openGallery() {
// should close it. The outside-click handler explicitly skips clicks on // should close it. The outside-click handler explicitly skips clicks on
// the anchor, so the button itself has to do its own dismiss. // the anchor, so the button itself has to do its own dismiss.
const existing = document.querySelector('.gallery-bulk-menu'); const existing = document.querySelector('.gallery-bulk-menu');
if (existing) { existing.remove(); return; } if (existing) { dismissOrRemove(existing); return; }
if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; } if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; }
_showGalleryBulkMenu(e.currentTarget); _showGalleryBulkMenu(e.currentTarget);
}); });
+8 -15
View File
@@ -11,6 +11,7 @@ import { makeWindowDraggable } from './windowDrag.js';
import { snapModalToZone } from './tileManager.js'; import { snapModalToZone } from './tileManager.js';
import { applyEdgeDock, clearDockSide } from './modalSnap.js'; import { applyEdgeDock, clearDockSide } from './modalSnap.js';
import { topToolWindowZ } from './toolWindowZOrder.js'; import { topToolWindowZ } from './toolWindowZOrder.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
let _open = false; let _open = false;
@@ -3360,7 +3361,7 @@ function _buildForm(note = null) {
function _pickCustomDate() { function _pickCustomDate() {
// Replace the dropdown menu with a small inline picker // Replace the dropdown menu with a small inline picker
document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove()); document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove);
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.className = 'note-reminder-menu'; menu.className = 'note-reminder-menu';
const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate()); const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
@@ -3394,14 +3395,11 @@ function _buildForm(note = null) {
if (typeof dInput.showPicker === 'function') { if (typeof dInput.showPicker === 'function') {
try { dInput.showPicker(); } catch {} try { dInput.showPicker(); } catch {}
} }
const close = bindMenuDismiss(menu, () => { menu.remove(); });
menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => { menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
if (dInput.value) _setReminder(dInput.value); if (dInput.value) _setReminder(dInput.value);
menu.remove(); close();
}); });
setTimeout(() => {
const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } };
document.addEventListener('click', close);
}, 0);
} }
if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); }); if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); });
@@ -4311,7 +4309,7 @@ function _serializeNoteForCopy(note) {
// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut. // toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut.
// ── ⋯ corner menu (Copy + Agent) ─────────────────────────────────── // ── ⋯ corner menu (Copy + Agent) ───────────────────────────────────
function _openNoteCornerMenu(btn) { function _openNoteCornerMenu(btn) {
document.querySelectorAll('.note-corner-menu-dropdown').forEach(d => d.remove()); document.querySelectorAll('.note-corner-menu-dropdown').forEach(dismissOrRemove);
const id = btn.dataset.noteId; const id = btn.dataset.noteId;
const note = _notes.find(n => n.id === id); const note = _notes.find(n => n.id === id);
if (!note) return; if (!note) return;
@@ -4338,14 +4336,9 @@ function _openNoteCornerMenu(btn) {
const below = window.innerHeight - r.bottom; const below = window.innerHeight - r.bottom;
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4); const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`; menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`;
const close = (ev) => { const close = bindMenuDismiss(menu, () => { menu.remove(); });
if (ev && menu.contains(ev.target)) return; menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); });
menu.remove(); menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
document.removeEventListener('click', close, true);
};
setTimeout(() => document.addEventListener('click', close, true), 0);
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { menu.remove(); _copyNote(id, btn); });
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { menu.remove(); _agentSolveNote(id); });
} }
function _positionNoteMenu(menu, btn, width = 196) { function _positionNoteMenu(menu, btn, width = 196) {
+16 -9
View File
@@ -8,6 +8,7 @@ import { clearDockSide } from './modalSnap.js';
import { sortModelIds } from './modelSort.js'; import { sortModelIds } from './modelSort.js';
import { providerLogo } from './providers.js'; import { providerLogo } from './providers.js';
import { isAltGrEvent } from './platform.js'; import { isAltGrEvent } from './platform.js';
import { bindMenuDismiss } from './escMenuStack.js';
let initialized = false; let initialized = false;
let modalEl = null; let modalEl = null;
@@ -3838,7 +3839,10 @@ async function initUnifiedIntegrations() {
if (lbl) lbl.textContent = text; if (lbl) lbl.textContent = text;
if (ico) ico.innerHTML = _apiIconFor(k); if (ico) ico.innerHTML = _apiIconFor(k);
}; };
const _close = () => { menu.style.display = 'none'; }; // Menu is reused (hidden, not recreated). close() hides it and tears down
// its outside-click listener + Escape-stack entry; bindMenuDismiss is
// re-registered fresh on each open (see _open).
let _close = () => { menu.style.display = 'none'; };
const _open = () => { const _open = () => {
menu.style.display = 'block'; menu.style.display = 'block';
const tRect = trig.getBoundingClientRect(); const tRect = trig.getBoundingClientRect();
@@ -3847,8 +3851,7 @@ async function initUnifiedIntegrations() {
const above = tRect.top; const above = tRect.top;
if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; } if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; } else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; }
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } }; _close = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trig);
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
}; };
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); }); trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
menu.querySelectorAll('.ufapi-option').forEach(btn => { menu.querySelectorAll('.ufapi-option').forEach(btn => {
@@ -4584,7 +4587,10 @@ async function initUnifiedIntegrations() {
if (labelEl) labelEl.textContent = lbl; if (labelEl) labelEl.textContent = lbl;
if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo; if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
}; };
const _closeMenu = () => { menu.style.display = 'none'; }; // Menu is reused (hidden, not recreated). _closeMenu hides it and tears
// down its outside-click listener + Escape-stack entry; bindMenuDismiss is
// re-registered fresh on each open (see _openMenu).
let _closeMenu = () => { menu.style.display = 'none'; };
const _openMenu = () => { const _openMenu = () => {
menu.style.display = 'block'; menu.style.display = 'block';
// Drop-up when there's not enough room below the trigger. // Drop-up when there's not enough room below the trigger.
@@ -4597,8 +4603,7 @@ async function initUnifiedIntegrations() {
} else { } else {
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
} }
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } }; _closeMenu = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trigger);
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
}; };
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); }); trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
menu.querySelectorAll('.ufp-option').forEach(btn => { menu.querySelectorAll('.ufp-option').forEach(btn => {
@@ -5650,8 +5655,11 @@ async function initUnifiedIntegrations() {
addBtn.parentElement.style.position = 'relative'; addBtn.parentElement.style.position = 'relative';
addBtn.parentElement.classList.add('uf-add-anchor'); addBtn.parentElement.classList.add('uf-add-anchor');
} }
// Menu is created per open and removed on close. _closeMenu routes through
// the bindMenuDismiss close() bound when the menu opens, so the outside-click
// listener + Escape-stack entry are torn down alongside the node removal.
let _menuEl = null; let _menuEl = null;
const _closeMenu = () => { if (_menuEl) { _menuEl.remove(); _menuEl = null; } }; let _closeMenu = () => {};
addBtn.addEventListener('click', (e) => { addBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
if (_menuEl) { _closeMenu(); return; } if (_menuEl) { _closeMenu(); return; }
@@ -5683,8 +5691,7 @@ async function initUnifiedIntegrations() {
showForm(k, 'new'); showForm(k, 'new');
}); });
}); });
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== addBtn) { _closeMenu(); document.removeEventListener('click', onDoc, true); } }; _closeMenu = bindMenuDismiss(menu, () => { menu.remove(); _menuEl = null; }, (ev) => !menu.contains(ev.target) && ev.target !== addBtn);
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
}); });
} }
+6 -6
View File
@@ -7,6 +7,7 @@
import uiModule from './ui.js'; import uiModule from './ui.js';
import * as spinnerModule from './spinner.js'; import * as spinnerModule from './spinner.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API = window.location.origin; const API = window.location.origin;
let skills = []; let skills = [];
@@ -391,14 +392,14 @@ function _svg(paths, { fill = 'none', size = 13 } = {}) {
// Kebab dropdown for a collapsed skill card — same actions + icons as the // Kebab dropdown for a collapsed skill card — same actions + icons as the
// expanded footer (Publish/Unpublish · Edit · Delete). // expanded footer (Publish/Unpublish · Edit · Delete).
function _openSkillMenu(btn, card, sk, name, isPublished) { function _openSkillMenu(btn, card, sk, name, isPublished) {
document.querySelectorAll('.skill-kebab-menu').forEach(m => m.remove()); document.querySelectorAll('.skill-kebab-menu').forEach(dismissOrRemove);
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.className = 'skill-kebab-menu'; menu.className = 'skill-kebab-menu';
const mk = (paths, label, opts, onClick) => { const mk = (paths, label, opts, onClick) => {
const item = document.createElement('button'); const item = document.createElement('button');
item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : ''); item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`; item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`;
item.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); onClick(); }); item.addEventListener('click', (e) => { e.stopPropagation(); close(); onClick(); });
menu.appendChild(item); menu.appendChild(item);
}; };
if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft')); if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
@@ -410,7 +411,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
selItem.innerHTML = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg><span>Select</span>'; selItem.innerHTML = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg><span>Select</span>';
selItem.addEventListener('click', (e) => { selItem.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
menu.remove(); close();
if (!_selectMode) _enterSelectMode(); if (!_selectMode) _enterSelectMode();
_selectedNames.add(name); _selectedNames.add(name);
renderSkillsList(); renderSkillsList();
@@ -432,7 +433,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
const cancelItem = document.createElement('button'); const cancelItem = document.createElement('button');
cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile'; cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile';
cancelItem.innerHTML = '<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>Cancel</span>'; cancelItem.innerHTML = '<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>Cancel</span>';
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); }); cancelItem.addEventListener('click', (e) => { e.stopPropagation(); close(); });
menu.appendChild(cancelItem); menu.appendChild(cancelItem);
document.body.appendChild(menu); document.body.appendChild(menu);
@@ -453,8 +454,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px'; menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
menu.style.overflowY = 'auto'; menu.style.overflowY = 'auto';
} }
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } }; const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target));
setTimeout(() => document.addEventListener('click', close, true), 0);
} }
// Cards for the agent's built-in tool capabilities (from // Cards for the agent's built-in tool capabilities (from
+8 -10
View File
@@ -8,6 +8,7 @@ import * as spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js'; import { makeWindowDraggable } from './windowDrag.js';
import { sortModelIds } from './modelSort.js'; import { sortModelIds } from './modelSort.js';
import { ordinalSuffix } from './util/ordinal.js'; import { ordinalSuffix } from './util/ordinal.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
let _open = false; let _open = false;
@@ -899,7 +900,7 @@ function _attachTaskLongPress(card, menuBtn) {
function _showTaskDropdown(anchor, items) { function _showTaskDropdown(anchor, items) {
// Remove any existing dropdown // Remove any existing dropdown
document.querySelectorAll('.task-dropdown').forEach(d => d.remove()); document.querySelectorAll('.task-dropdown').forEach(dismissOrRemove);
const dd = document.createElement('div'); const dd = document.createElement('div');
dd.className = 'task-dropdown'; dd.className = 'task-dropdown';
dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;'; dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
@@ -914,7 +915,7 @@ function _showTaskDropdown(anchor, items) {
} }
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; }); btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'none'; }); btn.addEventListener('mouseleave', () => { btn.style.background = 'none'; });
btn.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); item.action(); }); btn.addEventListener('click', (e) => { e.stopPropagation(); close(); item.action(); });
dd.appendChild(btn); dd.appendChild(btn);
}); });
document.body.appendChild(dd); document.body.appendChild(dd);
@@ -926,16 +927,13 @@ function _showTaskDropdown(anchor, items) {
dd.style.top = top + 'px'; dd.style.top = top + 'px';
dd.style.left = left + 'px'; dd.style.left = left + 'px';
const openedAt = performance.now(); const openedAt = performance.now();
const close = (e) => { const close = bindMenuDismiss(dd, () => { dd.remove(); }, (ev) => {
// Ignore any clicks that occur within 250ms of the open (covers touch // Ignore any clicks that occur within 250ms of the open (covers touch
// "ghost click" duplicates that were firing right after pointerup and // "ghost click" duplicates that were firing right after pointerup and
// removing the dropdown before the user could see it). // removing the dropdown before the user could see it) — treat as inside.
if (performance.now() - openedAt < 250) return; if (performance.now() - openedAt < 250) return false;
if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); } return !dd.contains(ev.target);
}; });
// requestAnimationFrame so the listener is registered AFTER the current
// pointer/click event cycle has finished bubbling.
requestAnimationFrame(() => document.addEventListener('click', close));
} }
// ---- Presets ---- // ---- Presets ----