372 lines
12 KiB
JavaScript
372 lines
12 KiB
JavaScript
/**
|
|
* 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 {}
|
|
// Fallback: escape special CSS selector characters (conservative).
|
|
return str.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
|
}
|
|
|
|
export function cleanupMenu(menu) {
|
|
if (!menu) return;
|
|
try {
|
|
menu._mjrAbortController?.abort?.();
|
|
} catch {}
|
|
try {
|
|
menu._mjrAbortController = null;
|
|
} catch {}
|
|
try {
|
|
menu._mjrOnHideCallbacks = [];
|
|
} catch {}
|
|
try {
|
|
menu._mjrSessionCleanup = null;
|
|
} catch {}
|
|
try {
|
|
menu.remove?.();
|
|
} catch {}
|
|
}
|
|
|
|
export { cleanupMenu as destroyMenu };
|
|
|
|
export function setMenuSessionCleanup(menu, fn) {
|
|
if (!menu) return;
|
|
try {
|
|
menu._mjrSessionCleanup = typeof fn === "function" ? fn : null;
|
|
} catch {}
|
|
}
|
|
|
|
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 {}
|
|
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 {}
|
|
// 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 {}
|
|
|
|
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 {}
|
|
}
|
|
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 {}
|
|
};
|
|
|
|
menu._mjrNavHandler = navHandler;
|
|
menu.addEventListener("keydown", navHandler, { signal: ac.signal, capture: true });
|
|
|
|
try {
|
|
menu._mjrOnHideCallbacks = [];
|
|
if (typeof onHide === "function") menu._mjrOnHideCallbacks.push(onHide);
|
|
} catch {}
|
|
|
|
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 {}
|
|
}
|
|
} catch {}
|
|
|
|
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 {}
|
|
};
|
|
|
|
const hide = () => {
|
|
try {
|
|
menu.style.display = "none";
|
|
} catch {}
|
|
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 {}
|
|
}
|
|
|
|
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 {}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
export function clearMenu(menu) {
|
|
if (!menu) return;
|
|
try {
|
|
menu.replaceChildren();
|
|
} catch {}
|
|
try {
|
|
menu.innerHTML = "";
|
|
} catch {}
|
|
try {
|
|
menu.style.display = "none";
|
|
menu.style.pointerEvents = "";
|
|
menu.style.opacity = "";
|
|
menu.style.visibility = "";
|
|
} catch {}
|
|
try {
|
|
menu.classList.remove("active", "open", "is-open", "mjr-open", "mjr-active");
|
|
} catch {}
|
|
}
|
|
|
|
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 {}
|
|
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 {}
|
|
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 {}
|
|
|
|
// 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 {}
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|