import type { ContextMenuItem } from "@t3tools/contracts"; const SVG_NS = "http://www.w3.org/2000/svg"; // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). const ICON_PATHS: Record }>> = { pencil: [ { tag: "path", attrs: { d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z", }, }, { tag: "path", attrs: { d: "m15 5 4 4" } }, ], copy: [ { tag: "rect", attrs: { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" } }, { tag: "path", attrs: { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" } }, ], "folder-tree": [ { tag: "path", attrs: { d: "M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z", }, }, { tag: "path", attrs: { d: "M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z", }, }, { tag: "path", attrs: { d: "M3 5a2 2 0 0 0 2 2h3" } }, { tag: "path", attrs: { d: "M3 3v13a2 2 0 0 0 2 2h3" } }, ], trash: [ { tag: "path", attrs: { d: "M3 6h18" } }, { tag: "path", attrs: { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" } }, { tag: "path", attrs: { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" } }, { tag: "line", attrs: { x1: "10", x2: "10", y1: "11", y2: "17" } }, { tag: "line", attrs: { x1: "14", x2: "14", y1: "11", y2: "17" } }, ], }; function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSVGElement | null { const paths = ICON_PATHS[name]; if (!paths) { return null; } const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("xmlns", SVG_NS); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "2"); svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); svg.setAttribute( "class", tone === "destructive" ? "size-3.5 shrink-0" : "size-3.5 shrink-0 text-muted-foreground", ); for (const node of paths) { const child = document.createElementNS(SVG_NS, node.tag); for (const [key, value] of Object.entries(node.attrs)) { child.setAttribute(key, value); } svg.appendChild(child); } return svg; } function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) { const rect = menu.getBoundingClientRect(); const left = Math.min( Math.max(4, preferredLeft), Math.max(4, window.innerWidth - rect.width - 4), ); const top = Math.min( Math.max(4, preferredTop), Math.max(4, window.innerHeight - rect.height - 4), ); menu.style.left = `${left}px`; menu.style.top = `${top}px`; } function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly HTMLDivElement[]) { if (typeof Node !== "undefined" && target instanceof Node) { return menuStack.some((menu) => menu.contains(target)); } if (!target || typeof target !== "object") { return false; } let current: unknown = target; while (current && typeof current === "object") { if (menuStack.includes(current as HTMLDivElement)) { return true; } current = (current as { parent?: unknown }).parent; } return false; } /** * Imperative DOM-based context menu for non-Electron environments. * Supports nested submenus and resolves with the clicked leaf item id. */ export function showContextMenuFallback( items: readonly ContextMenuItem[], position?: { x: number; y: number }, ): Promise { return new Promise((resolve) => { const menuStack: HTMLDivElement[] = []; let isDisposed = false; let canDismissFromPointer = false; const cleanup = (result: T | null) => { if (isDisposed) { return; } isDisposed = true; document.removeEventListener("keydown", onKeyDown); document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("contextmenu", onContextMenu, true); for (const menu of menuStack) { menu.remove(); } resolve(result); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); cleanup(null); } }; const onPointerDown = (event: PointerEvent) => { if (!canDismissFromPointer || isNodeWithinMenuStack(event.target, menuStack)) { return; } cleanup(null); }; const onContextMenu = (event: MouseEvent) => { if (!canDismissFromPointer || isNodeWithinMenuStack(event.target, menuStack)) { return; } event.preventDefault(); cleanup(null); }; const closeMenusFromLevel = (level: number) => { while (menuStack.length > level) { menuStack.pop()?.remove(); } }; const openMenu = ( entries: readonly ContextMenuItem[], preferredLeft: number, preferredTop: number, level: number, ) => { closeMenusFromLevel(level); const menu = document.createElement("div"); menu.className = "fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg border border-border bg-popover bg-clip-padding text-popover-foreground shadow-lg/5 outline-none"; menu.style.cssText = "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);border:1px solid var(--border);background:var(--popover);background-clip:padding-box;color:var(--popover-foreground);box-shadow:0 10px 15px -3px rgb(0 0 0 / 0.05),0 4px 6px -4px rgb(0 0 0 / 0.05);outline:none;pointer-events:auto;"; menu.style.left = `${preferredLeft}px`; menu.style.top = `${preferredTop}px`; menu.dataset.level = String(level); const inner = document.createElement("div"); inner.className = "max-h-[min(24rem,70vh)] min-w-0 max-w-sm overflow-y-auto overflow-x-hidden p-1"; inner.style.cssText = "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; for (const item of entries) { if (item.header === true) { const header = document.createElement("div"); header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; header.textContent = item.label; inner.appendChild(header); continue; } const hasChildren = Array.isArray(item.children) && item.children.length > 0; const isLeafDestructive = !hasChildren && (item.destructive === true || item.id === ("delete" as T)); const button = document.createElement("button"); button.type = "button"; const isDisabled = item.disabled === true; button.disabled = isDisabled; const rowBase = "flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left outline-none transition-colors sm:min-h-7 sm:text-sm min-h-8 text-base"; button.className = isDisabled ? `${rowBase} pointer-events-none cursor-not-allowed text-muted-foreground opacity-64` : isLeafDestructive ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground` : `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`; button.style.cssText = "display:flex;width:100%;min-height:1.75rem;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.875rem;line-height:1.25rem;text-align:left;cursor:default;"; if (isLeafDestructive) { button.style.color = "var(--destructive-foreground)"; } if (isDisabled) { button.style.color = "var(--muted-foreground)"; button.style.opacity = "0.64"; button.style.pointerEvents = "none"; } if (typeof item.icon === "string") { const icon = createIconElement(item.icon, isLeafDestructive ? "destructive" : "neutral"); if (icon) { button.appendChild(icon); } } const label = document.createElement("span"); label.className = "min-w-0 flex-1 truncate"; label.textContent = item.label; button.appendChild(label); if (hasChildren) { const chevron = document.createElement("span"); chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none"; chevron.textContent = ">"; button.appendChild(chevron); } if (!isDisabled) { button.addEventListener("mouseenter", () => { button.style.background = isLeafDestructive ? "color-mix(in srgb, var(--destructive) 10%, transparent)" : "var(--accent)"; button.style.color = isLeafDestructive ? "var(--destructive-foreground)" : "var(--accent-foreground)"; }); button.addEventListener("mouseleave", () => { button.style.background = "transparent"; button.style.color = isLeafDestructive ? "var(--destructive-foreground)" : "var(--foreground)"; }); if (hasChildren) { button.addEventListener("mouseenter", () => { const rect = button.getBoundingClientRect(); const nextLeft = rect.right + 4; const nextTop = rect.top; openMenu(item.children!, nextLeft, nextTop, level + 1); const childMenu = menuStack[level + 1]; if (!childMenu) { return; } const childRect = childMenu.getBoundingClientRect(); if (childRect.right > window.innerWidth) { clampMenuPosition(childMenu, rect.left - childRect.width - 4, rect.top); } }); button.addEventListener("click", (event) => { event.preventDefault(); }); } else { button.addEventListener("mouseenter", () => { closeMenusFromLevel(level + 1); }); button.addEventListener("click", () => cleanup(item.id)); } } inner.appendChild(button); } menu.appendChild(inner); menu.addEventListener("mouseenter", () => { closeMenusFromLevel(level + 1); }); document.body.appendChild(menu); menuStack[level] = menu; requestAnimationFrame(() => { clampMenuPosition(menu, preferredLeft, preferredTop); }); }; document.addEventListener("keydown", onKeyDown); document.addEventListener("pointerdown", onPointerDown, true); document.addEventListener("contextmenu", onContextMenu, true); openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); requestAnimationFrame(() => { canDismissFromPointer = true; }); }); }