From 4c82e4a17240a607e52bd14420bcf9f90d98b815 Mon Sep 17 00:00:00 2001 From: Mocchibird Date: Mon, 22 Jun 2026 20:40:56 +0200 Subject: [PATCH] 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) --- static/js/compare/index.js | 9 ++--- static/js/cookbook.js | 17 ++++------ static/js/document.js | 58 ++++++++++++--------------------- static/js/emailInbox.js | 17 ++++------ static/js/emailLibrary.js | 67 ++++++++++++++------------------------ static/js/gallery.js | 15 +++------ static/js/notes.js | 23 +++++-------- static/js/settings.js | 25 +++++++++----- static/js/skills.js | 12 +++---- static/js/tasks.js | 18 +++++----- 10 files changed, 106 insertions(+), 155 deletions(-) diff --git a/static/js/compare/index.js b/static/js/compare/index.js index c8b4d8f3a..88b23e36b 100644 --- a/static/js/compare/index.js +++ b/static/js/compare/index.js @@ -39,6 +39,7 @@ import spinnerModule from '../spinner.js'; import themeModule from '../theme.js'; import presetsModule from '../presets.js'; import markdownModule from '../markdown.js'; +import { bindMenuDismiss } from '../escMenuStack.js'; var escapeHtml = uiModule.esc; @@ -1062,6 +1063,7 @@ function _buildComparisonMarkdown() { } let _exportMenuEl = null; +let _closeExportMenu = () => {}; function _toggleExportMenu(btn) { if (_exportMenuEl) { _closeExportMenu(); return; } const r = btn.getBoundingClientRect(); @@ -1085,10 +1087,9 @@ function _toggleExportMenu(btn) { } document.body.appendChild(m); _exportMenuEl = m; - setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0); -} -function _closeExportMenu() { - if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; } + _closeExportMenu = bindMenuDismiss(m, () => { + if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; } + }, (ev) => !m.contains(ev.target)); } async function _exportCopyMarkdown(_btn) { diff --git a/static/js/cookbook.js b/static/js/cookbook.js index 43a3ad5d0..967557184 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -33,6 +33,8 @@ import { _fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel, } from './cookbookServe.js'; +import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; + const STORAGE_KEY = 'cookbook-presets'; const LAST_STATE_KEY = 'cookbook-last-state'; const SERVE_STATE_KEY = 'cookbook-serve-state'; @@ -1514,7 +1516,7 @@ async function _fetchDependencies() { // Wire the installed-package menu. 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'); if (!row) return; const pipName = row.dataset.depPip; @@ -1535,7 +1537,7 @@ async function _fetchDependencies() { it.title = `Update ${pkgName} to the latest version (pip install -U)`; it.addEventListener('click', async (e) => { e.stopPropagation(); - dropdown.remove(); + close(); await _installDep(pipName, pkgName, isLocalOnly, true, null); }); dropdown.appendChild(it); @@ -1563,19 +1565,14 @@ async function _fetchDependencies() { dropdown.appendChild(source); } document.body.appendChild(dropdown); - const close = (ev) => { - if (!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); + const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => + !dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)); } list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); if (document.querySelector('.cookbook-dep-menu')) { - document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove()); + document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove); return; } _showDepMenu(btn); diff --git a/static/js/document.js b/static/js/document.js index f2e61f559..d4f67a8d9 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -16,6 +16,7 @@ import spinnerModule from './spinner.js'; import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js'; import signatureModule from './signature.js'; import * as Modals from './modalManager.js'; +import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; let API_BASE = ''; let isOpen = false; @@ -3331,7 +3332,10 @@ import * as Modals from './modalManager.js'; let _docAiReplyChoiceMenu = null; function _closeDocAiReplyChoice() { 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; } } @@ -3382,6 +3386,14 @@ import * as Modals from './modalManager.js'; const noteInput = menu.querySelector('[data-note-input]'); setTimeout(() => noteInput?.focus(), 0); 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) => { const choice = ev.target.closest('[data-mode]'); if (!choice) return; @@ -3389,26 +3401,9 @@ import * as Modals from './modalManager.js'; ev.stopPropagation(); const mode = choice.getAttribute('data-mode') || 'ai-reply-fast'; const noteHint = (noteInput?.value || '').trim(); - _closeDocAiReplyChoice(); + close(); 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 = {}) { @@ -8591,9 +8586,10 @@ import * as Modals from './modalManager.js'; function showExportMenu(e, anchorRect) { 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'); - if (existing) { existing.remove(); return; } + if (existing) { dismissOrRemove(existing); return; } // Position from provided rect, clicked element, or fallback to language select const rect = anchorRect @@ -8643,7 +8639,7 @@ import * as Modals from './modalManager.js'; const item = document.createElement('button'); item.className = 'doc-overflow-item'; 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); if (opt._divider) { const sep = document.createElement('div'); @@ -8661,21 +8657,9 @@ import * as Modals from './modalManager.js'; menu.style.top = 'auto'; menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px'; } - const close = (ev) => { - if (ev && ev.type === 'keydown') { - if (ev.key !== 'Escape') return; - 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); + // Outside-click AND Escape both route through the central esc-stack via + // bindMenuDismiss; onClose owns the actual node removal. + const close = bindMenuDismiss(menu, () => { menu.remove(); }); } function exportAsHtml() { diff --git a/static/js/emailInbox.js b/static/js/emailInbox.js index 1b4d67a4e..8155df7ab 100644 --- a/static/js/emailInbox.js +++ b/static/js/emailInbox.js @@ -9,6 +9,7 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO import * as Modals from './modalManager.js'; import { applyEdgeDock } from './modalSnap.js'; import { buildReplyAllCc } from './emailLibrary/replyRecipients.js'; +import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; const API_BASE = window.location.origin; const _acct = () => window.__odysseusActiveEmailAccount @@ -915,7 +916,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note } function _showEmailMenu(em, anchor, itemEl) { - document.querySelectorAll('.email-dropdown').forEach(d => d.remove()); + document.querySelectorAll('.email-dropdown').forEach(dismissOrRemove); const dropdown = document.createElement('div'); dropdown.className = 'dropdown email-dropdown show'; @@ -938,7 +939,7 @@ function _showEmailMenu(em, anchor, itemEl) { _showRemindSubmenu(em, dropdown); return; } - dropdown.remove(); + close(); a.action(); }); dropdown.appendChild(menuItem); @@ -946,13 +947,7 @@ function _showEmailMenu(em, anchor, itemEl) { anchor.appendChild(dropdown); - const close = (e) => { - if (!dropdown.contains(e.target) && !anchor.contains(e.target)) { - dropdown.remove(); - document.removeEventListener('click', close, true); - } - }; - setTimeout(() => document.addEventListener('click', close, true), 10); + const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && !anchor.contains(ev.target)); } // ---- Reminder submenu (creates a Note with a reminder for this email) ---- @@ -987,7 +982,7 @@ function _showRemindSubmenu(em, parentDropdown) { item.innerHTML = `${p.label}${p.sub}`; item.addEventListener('click', async (e) => { e.stopPropagation(); - parentDropdown.remove(); + dismissOrRemove(parentDropdown); await _createReplyReminder(em, p.date); }); parentDropdown.appendChild(item); @@ -997,7 +992,7 @@ function _showRemindSubmenu(em, parentDropdown) { customItem.innerHTML = 'Pick date and time…'; customItem.addEventListener('click', async (e) => { e.stopPropagation(); - parentDropdown.remove(); + dismissOrRemove(parentDropdown); const tmp = document.createElement('input'); tmp.type = 'datetime-local'; const def = new Date(tomorrow); diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index c38573e22..516bc932a 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -23,6 +23,7 @@ import { } from './emailLibrary/signatureFold.js'; import { state } from './emailLibrary/state.js'; import { collapseSidebarToRail } from './modalSnap.js'; +import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; const API_BASE = window.location.origin; 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. const existing = document.querySelector('.email-card-dropdown'); if (existing && existing._anchor === anchor) { - existing.remove(); - anchor.classList.remove('reader-more-active'); + dismissOrRemove(existing); return; } - // Otherwise close any other open dropdown (and clear its anchor's active - // state) before opening a fresh one. - document.querySelectorAll('.email-card-dropdown').forEach(d => { - if (d._anchor) d._anchor.classList.remove('reader-more-active'); - d.remove(); - }); + // Otherwise close any other open dropdown (its own teardown clears its + // anchor's active state) before opening a fresh one. + document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove); const dropdown = document.createElement('div'); dropdown.className = 'email-card-dropdown'; @@ -5721,8 +5718,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) { _showLibRemindSubmenu(em, dropdown); return; } - dropdown.remove(); - anchor.classList.remove('reader-more-active'); + close(); a.action(); }); dropdown.appendChild(item); @@ -5735,25 +5731,20 @@ function _showReaderMoreMenu(em, card, reader, anchor) { cancelItem.innerHTML = _icon(_cancelIco) + 'Cancel'; cancelItem.addEventListener('click', (e) => { e.stopPropagation(); - dropdown.remove(); - anchor.classList.remove('reader-more-active'); + close(); }); dropdown.appendChild(cancelItem); document.body.appendChild(dropdown); _fitEmailDropdown(dropdown, rect); - const close = (ev) => { - if (!dropdown.contains(ev.target) && ev.target !== anchor) { - dropdown.remove(); - anchor.classList.remove('reader-more-active'); - document.removeEventListener('click', close, true); - } - }; - setTimeout(() => document.addEventListener('click', close, true), 10); + const close = bindMenuDismiss(dropdown, () => { + dropdown.remove(); + anchor.classList.remove('reader-more-active'); + }, (ev) => !dropdown.contains(ev.target) && ev.target !== 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'); dropdown.className = 'email-card-dropdown'; @@ -5918,8 +5909,7 @@ function _showCardMenu(em, anchor) { _showLibRemindSubmenu(em, dropdown); return; } - dropdown.remove(); - anchor.classList.remove('reader-more-active'); + close(); a.action(); }); dropdown.appendChild(item); @@ -5932,26 +5922,21 @@ function _showCardMenu(em, anchor) { cancelItem.innerHTML = _icon(_cancelIco) + 'Cancel'; cancelItem.addEventListener('click', (e) => { e.stopPropagation(); - dropdown.remove(); - anchor.classList.remove('reader-more-active'); + close(); }); dropdown.appendChild(cancelItem); document.body.appendChild(dropdown); _fitEmailDropdown(dropdown, rect); - const close = (ev) => { - if (!dropdown.contains(ev.target) && ev.target !== anchor) { - dropdown.remove(); - anchor.classList.remove('reader-more-active'); - document.removeEventListener('click', close, true); - } - }; - setTimeout(() => document.addEventListener('click', close, true), 10); + const close = bindMenuDismiss(dropdown, () => { + dropdown.remove(); + anchor.classList.remove('reader-more-active'); + }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor); } // Bulk "Actions" dropdown for select mode — Delete is a separate visible button. function _showBulkActionsMenu(anchor) { - document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove()); + document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove); const dropdown = document.createElement('div'); dropdown.className = 'email-card-dropdown email-bulk-menu'; const rect = anchor.getBoundingClientRect(); @@ -5968,7 +5953,7 @@ function _showBulkActionsMenu(anchor) { const it = document.createElement('div'); it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : ''); it.innerHTML = `${a.icon}${a.label}`; - it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); }); + it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); }); dropdown.appendChild(it); } // Mobile-only Cancel — matches the per-card and sidebar dropdowns. @@ -5978,7 +5963,7 @@ function _showBulkActionsMenu(anchor) { cancelIt.innerHTML = `${_cancelIco2}Cancel`; cancelIt.addEventListener('click', (e) => { e.stopPropagation(); - dropdown.remove(); + close(); // Cancel inside the bulk-Actions menu also exits select mode — matches the // documents bulk dropdown. state._selectMode = false; @@ -5989,13 +5974,9 @@ function _showBulkActionsMenu(anchor) { dropdown.appendChild(cancelIt); document.body.appendChild(dropdown); _fitEmailDropdown(dropdown, rect); - const close = (ev) => { - if (!dropdown.contains(ev.target) && ev.target !== anchor) { - dropdown.remove(); - document.removeEventListener('click', close, true); - } - }; - setTimeout(() => document.addEventListener('click', close, true), 10); + const close = bindMenuDismiss(dropdown, () => { + dropdown.remove(); + }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor); } function _updateBulkBar() { diff --git a/static/js/gallery.js b/static/js/gallery.js index 1d35f6504..40a66a7ee 100644 --- a/static/js/gallery.js +++ b/static/js/gallery.js @@ -6,6 +6,7 @@ import uiModule from './ui.js'; import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js'; import spinnerModule from './spinner.js'; import { makeWindowDraggable } from './windowDrag.js'; +import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; const API_BASE = window.location.origin; let _open = false; @@ -2514,7 +2515,7 @@ export function openGallery() { // shares the exact same dropdown style/behaviour. const _bulkActionsBtn = document.getElementById('gallery-bulk-actions'); 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 // matches every other menu in the app. Positioned fixed at the button. const dropdown = document.createElement('div'); @@ -2548,17 +2549,11 @@ export function openGallery() { const it = document.createElement('div'); it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : ''); it.innerHTML = `${a.icon}${a.label}`; - it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); }); + it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); }); dropdown.appendChild(it); } document.body.appendChild(dropdown); - const close = (ev) => { - if (!dropdown.contains(ev.target) && ev.target !== anchor) { - dropdown.remove(); - document.removeEventListener('click', close, true); - } - }; - setTimeout(() => document.addEventListener('click', close, true), 10); + const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor); } _bulkActionsBtn?.addEventListener('click', (e) => { @@ -2567,7 +2562,7 @@ export function openGallery() { // should close it. The outside-click handler explicitly skips clicks on // the anchor, so the button itself has to do its own dismiss. 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; } _showGalleryBulkMenu(e.currentTarget); }); diff --git a/static/js/notes.js b/static/js/notes.js index e7e3a7ded..f19e350c4 100644 --- a/static/js/notes.js +++ b/static/js/notes.js @@ -11,6 +11,7 @@ import { makeWindowDraggable } from './windowDrag.js'; import { snapModalToZone } from './tileManager.js'; import { applyEdgeDock, clearDockSide } from './modalSnap.js'; import { topToolWindowZ } from './toolWindowZOrder.js'; +import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; const API_BASE = window.location.origin; let _open = false; @@ -3360,7 +3361,7 @@ function _buildForm(note = null) { function _pickCustomDate() { // 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'); menu.className = 'note-reminder-menu'; const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate()); @@ -3394,14 +3395,11 @@ function _buildForm(note = null) { if (typeof dInput.showPicker === 'function') { try { dInput.showPicker(); } catch {} } + const close = bindMenuDismiss(menu, () => { menu.remove(); }); menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => { 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); }); @@ -4311,7 +4309,7 @@ function _serializeNoteForCopy(note) { // toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut. // ── ⋯ corner menu (Copy + Agent) ─────────────────────────────────── 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 note = _notes.find(n => n.id === id); if (!note) return; @@ -4338,14 +4336,9 @@ function _openNoteCornerMenu(btn) { const below = window.innerHeight - r.bottom; 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;`; - const close = (ev) => { - if (ev && menu.contains(ev.target)) return; - menu.remove(); - 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); }); + const close = bindMenuDismiss(menu, () => { menu.remove(); }); + menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); }); + menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); }); } function _positionNoteMenu(menu, btn, width = 196) { diff --git a/static/js/settings.js b/static/js/settings.js index 1dbe7f6d7..8dbf1258e 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -8,6 +8,7 @@ import { clearDockSide } from './modalSnap.js'; import { sortModelIds } from './modelSort.js'; import { providerLogo } from './providers.js'; import { isAltGrEvent } from './platform.js'; +import { bindMenuDismiss } from './escMenuStack.js'; let initialized = false; let modalEl = null; @@ -3838,7 +3839,10 @@ async function initUnifiedIntegrations() { if (lbl) lbl.textContent = text; 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 = () => { menu.style.display = 'block'; const tRect = trig.getBoundingClientRect(); @@ -3847,8 +3851,7 @@ async function initUnifiedIntegrations() { const above = tRect.top; 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'; } - const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } }; - setTimeout(() => document.addEventListener('click', onDoc, true), 0); + _close = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trig); }; trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); }); menu.querySelectorAll('.ufapi-option').forEach(btn => { @@ -4584,7 +4587,10 @@ async function initUnifiedIntegrations() { if (labelEl) labelEl.textContent = lbl; 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 = () => { menu.style.display = 'block'; // Drop-up when there's not enough room below the trigger. @@ -4597,8 +4603,7 @@ async function initUnifiedIntegrations() { } else { 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); } }; - setTimeout(() => document.addEventListener('click', onDoc, true), 0); + _closeMenu = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trigger); }; trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); }); menu.querySelectorAll('.ufp-option').forEach(btn => { @@ -5650,8 +5655,11 @@ async function initUnifiedIntegrations() { addBtn.parentElement.style.position = 'relative'; 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; - const _closeMenu = () => { if (_menuEl) { _menuEl.remove(); _menuEl = null; } }; + let _closeMenu = () => {}; addBtn.addEventListener('click', (e) => { e.stopPropagation(); if (_menuEl) { _closeMenu(); return; } @@ -5683,8 +5691,7 @@ async function initUnifiedIntegrations() { showForm(k, 'new'); }); }); - const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== addBtn) { _closeMenu(); document.removeEventListener('click', onDoc, true); } }; - setTimeout(() => document.addEventListener('click', onDoc, true), 0); + _closeMenu = bindMenuDismiss(menu, () => { menu.remove(); _menuEl = null; }, (ev) => !menu.contains(ev.target) && ev.target !== addBtn); }); } diff --git a/static/js/skills.js b/static/js/skills.js index 44f7082b6..d60c933a2 100644 --- a/static/js/skills.js +++ b/static/js/skills.js @@ -7,6 +7,7 @@ import uiModule from './ui.js'; import * as spinnerModule from './spinner.js'; +import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; const API = window.location.origin; 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 // expanded footer (Publish/Unpublish · Edit · Delete). 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'); menu.className = 'skill-kebab-menu'; const mk = (paths, label, opts, onClick) => { const item = document.createElement('button'); item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : ''); item.innerHTML = _svg(paths, opts) + `${label}`; - item.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); onClick(); }); + item.addEventListener('click', (e) => { e.stopPropagation(); close(); onClick(); }); menu.appendChild(item); }; if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft')); @@ -410,7 +411,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) { selItem.innerHTML = 'Select'; selItem.addEventListener('click', (e) => { e.stopPropagation(); - menu.remove(); + close(); if (!_selectMode) _enterSelectMode(); _selectedNames.add(name); renderSkillsList(); @@ -432,7 +433,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) { const cancelItem = document.createElement('button'); cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile'; cancelItem.innerHTML = 'Cancel'; - cancelItem.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); }); + cancelItem.addEventListener('click', (e) => { e.stopPropagation(); close(); }); menu.appendChild(cancelItem); 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.overflowY = 'auto'; } - const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } }; - setTimeout(() => document.addEventListener('click', close, true), 0); + const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target)); } // Cards for the agent's built-in tool capabilities (from diff --git a/static/js/tasks.js b/static/js/tasks.js index 2914389b9..e44d13034 100644 --- a/static/js/tasks.js +++ b/static/js/tasks.js @@ -8,6 +8,7 @@ import * as spinnerModule from './spinner.js'; import { makeWindowDraggable } from './windowDrag.js'; import { sortModelIds } from './modelSort.js'; import { ordinalSuffix } from './util/ordinal.js'; +import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; const API_BASE = window.location.origin; let _open = false; @@ -899,7 +900,7 @@ function _attachTaskLongPress(card, menuBtn) { function _showTaskDropdown(anchor, items) { // Remove any existing dropdown - document.querySelectorAll('.task-dropdown').forEach(d => d.remove()); + document.querySelectorAll('.task-dropdown').forEach(dismissOrRemove); const dd = document.createElement('div'); 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;'; @@ -914,7 +915,7 @@ function _showTaskDropdown(anchor, items) { } btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; }); 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); }); document.body.appendChild(dd); @@ -926,16 +927,13 @@ function _showTaskDropdown(anchor, items) { dd.style.top = top + 'px'; dd.style.left = left + 'px'; 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 // "ghost click" duplicates that were firing right after pointerup and - // removing the dropdown before the user could see it). - if (performance.now() - openedAt < 250) return; - if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); } - }; - // requestAnimationFrame so the listener is registered AFTER the current - // pointer/click event cycle has finished bubbling. - requestAnimationFrame(() => document.addEventListener('click', close)); + // removing the dropdown before the user could see it) — treat as inside. + if (performance.now() - openedAt < 250) return false; + return !dd.contains(ev.target); + }); } // ---- Presets ----