/** * Context Menu Core * * Shared primitives for all Majoor context menus (grid/viewer/collections). * Avoids duplicated DOM + event wiring and prevents selector collisions. */ export const MENU_Z_INDEX = Object.freeze({ MAIN: 10001, SUBMENU: 10002, POPOVER: 10003, COLLECTIONS: 10005, }); const _DEFAULT_Z = MENU_Z_INDEX.MAIN; function _asClassName(value) { const s = String(value || "").trim(); if (!s) return ""; return s.startsWith(".") ? s.slice(1) : s; } export function safeEscapeSelector(value) { const str = String(value ?? ""); try { if (typeof CSS?.escape === "function") return CSS.escape(str); } catch (e) { console.debug?.(e); } // Fallback: escape special CSS selector characters (conservative). return str.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1"); } export function cleanupMenu(menu) { if (!menu) return; try { menu._mjrAbortController?.abort?.(); } catch (e) { console.debug?.(e); } try { menu._mjrAbortController = null; } catch (e) { console.debug?.(e); } try { menu._mjrOnHideCallbacks = []; } catch (e) { console.debug?.(e); } try { menu._mjrSessionCleanup = null; } catch (e) { console.debug?.(e); } try { menu.remove?.(); } catch (e) { console.debug?.(e); } } export function setMenuSessionCleanup(menu, fn) { if (!menu) return; try { menu._mjrSessionCleanup = typeof fn === "function" ? fn : null; } catch (e) { console.debug?.(e); } } export function getOrCreateMenu({ selector, className, minWidth = 220, zIndex = _DEFAULT_Z, onHide = null, } = {}) { const sel = String(selector || "").trim(); if (!sel) { console.warn("[MenuCore] getOrCreateMenu: missing selector"); return null; } const existing = document.querySelector(sel); if (existing) { try { if (!Array.isArray(existing._mjrOnHideCallbacks)) existing._mjrOnHideCallbacks = []; if (typeof onHide === "function") existing._mjrOnHideCallbacks.push(onHide); } catch (e) { console.debug?.(e); } return existing; } const menu = document.createElement("div"); menu.className = `${_asClassName(className) || _asClassName(sel)} mjr-context-menu`; try { menu.setAttribute("role", "menu"); menu.setAttribute("aria-label", "Context menu"); } catch (e) { console.debug?.(e); } // Prefer CSS for visuals; keep only dynamic values inline (width + z-index). try { menu.style.position = "fixed"; menu.style.display = "none"; menu.style.minWidth = `${Math.max(180, Number(minWidth) || 220)}px`; menu.style.zIndex = String(Number(zIndex) || _DEFAULT_Z); } catch (e) { console.debug?.(e); } const ac = new AbortController(); menu._mjrAbortController = ac; const navHandler = (e) => { try { if (!menu || !["ArrowDown", "ArrowUp", "ArrowRight"].includes(e.key)) return; const items = Array.from( menu.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])'), ); if (!items.length) return; const current = items.indexOf(document.activeElement); if (e.key === "ArrowRight") { e.preventDefault(); const active = document.activeElement; if (active?.dataset?.mjrHasSubmenu === "true") { try { active.dispatchEvent( new MouseEvent("mouseenter", { bubbles: true, cancelable: true }), ); } catch (e) { console.debug?.(e); } } return; } e.preventDefault(); const direction = e.key === "ArrowDown" ? 1 : -1; const next = ((current >= 0 ? current : 0) + direction + items.length) % items.length; items[next]?.focus(); } catch (e) { console.debug?.(e); } }; menu._mjrNavHandler = navHandler; menu.addEventListener("keydown", navHandler, { signal: ac.signal, capture: true }); try { menu._mjrOnHideCallbacks = []; if (typeof onHide === "function") menu._mjrOnHideCallbacks.push(onHide); } catch (e) { console.debug?.(e); } const runOnHide = () => { try { const fn = menu._mjrSessionCleanup; if (typeof fn === "function") { try { fn(); } catch (err) { console.error("[MenuCore] Session cleanup failed:", err); } try { menu._mjrSessionCleanup = null; } catch (e) { console.debug?.(e); } } } catch (e) { console.debug?.(e); } try { const list = Array.isArray(menu._mjrOnHideCallbacks) ? menu._mjrOnHideCallbacks : []; for (const fn of list) { try { fn?.(); } catch (err) { console.error("[MenuCore] onHide failed:", err); } } } catch (e) { console.debug?.(e); } }; const hide = () => { try { menu.style.display = "none"; } catch (e) { console.debug?.(e); } runOnHide(); }; menu._mjrHide = hide; document.addEventListener( "click", (e) => { if (!menu.contains(e.target)) hide(); }, { signal: ac.signal, capture: true }, ); // Some parts of the viewer use pointerdown handlers that preventDefault() and can suppress "click". // Use pointerdown as the primary outside-dismiss signal to avoid stuck menus. document.addEventListener( "pointerdown", (e) => { if (!menu.contains(e.target)) hide(); }, { signal: ac.signal, capture: true, passive: true }, ); document.addEventListener( "keydown", (e) => { if (e.key === "Escape") hide(); }, { signal: ac.signal, capture: true }, ); document.addEventListener("scroll", hide, { capture: true, passive: true, signal: ac.signal }); document.addEventListener("wheel", hide, { capture: true, passive: true, signal: ac.signal }); window.addEventListener("mjr-close-all-menus", hide, { signal: ac.signal }); document.body.appendChild(menu); return menu; } export function hideMenu(menu) { try { if (typeof menu?._mjrHide === "function") { menu._mjrHide(); } else { menu.style.display = "none"; } // Cleanup orphans immediately after hiding to avoid leaking detached menus. cleanupMenu(menu); } catch (e) { console.debug?.(e); } } export function hideAllMenus() { try { const menus = Array.from(document.querySelectorAll(".mjr-context-menu")); for (const menu of menus) { try { if (typeof menu?._mjrHide === "function") menu._mjrHide(); else menu.style.display = "none"; } catch (e) { console.debug?.(e); } } } catch (e) { console.debug?.(e); } } export function clearMenu(menu) { if (!menu) return; try { menu.replaceChildren(); } catch (e) { console.debug?.(e); } try { menu.innerHTML = ""; } catch (e) { console.debug?.(e); } try { menu.style.display = "none"; menu.style.pointerEvents = ""; menu.style.opacity = ""; menu.style.visibility = ""; } catch (e) { console.debug?.(e); } try { menu.classList.remove("active", "open", "is-open", "mjr-open", "mjr-active"); } catch (e) { console.debug?.(e); } } export function showMenuAt(menu, x, y) { menu.style.display = "block"; menu.style.left = `${x}px`; menu.style.top = `${y}px`; const rect = menu.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; let fx = x; let fy = y; if (x + rect.width > vw) fx = vw - rect.width - 10; if (y + rect.height > vh) fy = vh - rect.height - 10; menu.style.left = `${Math.max(8, fx)}px`; menu.style.top = `${Math.max(8, fy)}px`; } export function createMenuItem(label, iconClass, rightHint, onClick, { disabled = false } = {}) { const item = document.createElement("div"); item.className = "mjr-context-menu-item"; try { item.setAttribute("role", "menuitem"); item.setAttribute("tabindex", disabled ? "-1" : "0"); item.setAttribute("aria-disabled", disabled ? "true" : "false"); } catch (e) { console.debug?.(e); } item.style.cssText = ` padding: 8px 14px; display: flex; align-items: center; justify-content: space-between; cursor: ${disabled ? "default" : "pointer"}; color: var(--fg-color); font-size: 13px; opacity: ${disabled ? "0.45" : "1"}; transition: background 0.15s; user-select: none; `; const left = document.createElement("div"); left.style.cssText = "display:flex; align-items:center; gap:10px;"; if (iconClass) { const iconEl = document.createElement("i"); iconEl.className = iconClass; iconEl.style.cssText = "width:16px; text-align:center; opacity:0.8;"; left.appendChild(iconEl); } const labelEl = document.createElement("span"); labelEl.textContent = label; left.appendChild(labelEl); item.appendChild(left); if (rightHint) { const hint = document.createElement("span"); hint.textContent = rightHint; hint.style.cssText = "font-size: 11px; opacity: 0.55; margin-left: 16px;"; item.appendChild(hint); } item.addEventListener("mouseenter", () => { if (!disabled) item.style.background = "var(--comfy-input-bg)"; }); item.addEventListener("mouseleave", () => { item.style.background = "transparent"; }); item.addEventListener("click", async (e) => { e.stopPropagation(); e.preventDefault(); if (disabled) return; if (item.dataset?.executing === "1") return; try { item.dataset.executing = "1"; item.style.pointerEvents = "none"; item.style.opacity = "0.7"; } catch (e) { console.debug?.(e); } try { await onClick?.(); } catch (err) { console.error("[ContextMenu] Action failed:", err); } finally { try { item.dataset.executing = "0"; item.style.pointerEvents = ""; item.style.opacity = disabled ? "0.45" : "1"; } catch (e) { console.debug?.(e); } // Close the menu if this item has a close callback configured try { // Find parent menu and trigger hide if available const menu = item.closest(".mjr-context-menu"); if (menu && typeof menu._mjrHide === "function") { // Check if this action wants to keep the menu open (e.g. submenus return specific flag?) // For now, assume most actions want to close. // Submenu triggers usually don't have onClick, or use mouseover. // If onClick was provided, it's likely an action. // We can also let the caller invoke hideMenu(menu). // However, to enforce what the user asked: "must desapear once a validate my choice" // we can modify createMenuItem to accept an option "autoClose: true" or simple force it here. menu._mjrHide(); } else if (menu) { menu.style.display = "none"; } } catch (e) { console.debug?.(e); } } }); item.addEventListener("keydown", (e) => { if (disabled) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); item.click(); } }); return item; } export function createMenuSeparator() { const s = document.createElement("div"); s.style.cssText = "height:1px;background:var(--border-color);margin:4px 0;"; return s; }