feat(maestro): rename a session from the web sidebar
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bdeec6eff5
commit
43b90ccf0a
@@ -0,0 +1,81 @@
|
||||
/** 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;
|
||||
}
|
||||
Reference in New Issue
Block a user