Right-clicking a card opens a custom context menu (rename, spawn similar, kill). Rename swaps the name for an inline field; Enter commits, Escape and blur cancel. The tree stops rebuilding while the field is open, so a catalog delta cannot replace what is being typed into. Refs #125 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
82 lines
2.8 KiB
TypeScript
82 lines
2.8 KiB
TypeScript
/** Custom right-click menu (#125).
|
|
*
|
|
* One menu exists at a time: opening a second closes the first. The menu is
|
|
* appended to `<body>` rather than to the row it belongs to, so an overflowing
|
|
* sidebar cannot clip it.
|
|
*/
|
|
|
|
export interface ContextMenuItem {
|
|
label: string;
|
|
onSelect: () => void;
|
|
/** Renders the entry in the destructive style. */
|
|
danger?: boolean;
|
|
}
|
|
|
|
let open: { el: HTMLElement; dispose: () => void } | null = null;
|
|
|
|
/** Close the menu that is currently up, if any. */
|
|
export function closeContextMenu(): void {
|
|
open?.dispose();
|
|
}
|
|
|
|
/**
|
|
* Open a menu at viewport coordinates `x`/`y`. The menu closes on selection,
|
|
* on Escape, on a pointer press anywhere outside it, and on scroll or resize —
|
|
* a menu anchored to coordinates goes stale the moment the page moves under it.
|
|
*/
|
|
export function openContextMenu(x: number, y: number, items: ContextMenuItem[]): HTMLElement {
|
|
closeContextMenu();
|
|
|
|
const el = document.createElement("div");
|
|
el.className = "context-menu";
|
|
el.setAttribute("role", "menu");
|
|
for (const item of items) {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = item.danger ? "context-menu-item is-danger" : "context-menu-item";
|
|
btn.setAttribute("role", "menuitem");
|
|
btn.textContent = item.label;
|
|
btn.addEventListener("click", (ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
closeContextMenu();
|
|
item.onSelect();
|
|
});
|
|
el.appendChild(btn);
|
|
}
|
|
document.body.appendChild(el);
|
|
|
|
// Nudge back inside the viewport when the click was near an edge. happy-dom
|
|
// reports 0 for every measurement, which lands the menu at the click point —
|
|
// the same place it would sit with room to spare.
|
|
const rect = el.getBoundingClientRect();
|
|
const maxX = window.innerWidth - rect.width;
|
|
const maxY = window.innerHeight - rect.height;
|
|
el.style.left = `${Math.max(0, Math.min(x, maxX))}px`;
|
|
el.style.top = `${Math.max(0, Math.min(y, maxY))}px`;
|
|
|
|
const onPointerDown = (ev: Event) => {
|
|
if (!el.contains(ev.target as Node)) closeContextMenu();
|
|
};
|
|
const onKeyDown = (ev: KeyboardEvent) => {
|
|
if (ev.key === "Escape") closeContextMenu();
|
|
};
|
|
const dispose = () => {
|
|
if (open?.el !== el) return;
|
|
open = null;
|
|
el.remove();
|
|
window.removeEventListener("pointerdown", onPointerDown, true);
|
|
window.removeEventListener("keydown", onKeyDown, true);
|
|
window.removeEventListener("scroll", closeContextMenu, true);
|
|
window.removeEventListener("resize", closeContextMenu);
|
|
};
|
|
window.addEventListener("pointerdown", onPointerDown, true);
|
|
window.addEventListener("keydown", onKeyDown, true);
|
|
window.addEventListener("scroll", closeContextMenu, true);
|
|
window.addEventListener("resize", closeContextMenu);
|
|
|
|
open = { el, dispose };
|
|
(el.firstElementChild as HTMLElement | null)?.focus();
|
|
return el;
|
|
}
|