Node deletion affordance (node interlude): right-click menu + full-death confirm
Platform's 50bfcba engine, surfaced. Three pieces: 1. THE RIGHT-CLICK (graph-canvas.ts): a contextmenu listener — always preventDefault (the native menu on a full-bleed custom canvas is never useful) — picks the node under the pointer and hands the canvas-relative position to a new onContext callback (null on empty stage). pickAt already respected the visibility filters: a hidden node cannot be right-clicked, same as it cannot be clicked. The view opens a MenuShell anchored at a zero-size point-div positioned at the pointer — MenuShell's placement machine measures it like any element, so the menu gets the full discipline: placement, focus on open, arrow cycling, honest-fit flip. Delete… is its one action; the dismissal contract is the sidebar's (wave 6): outside-click / Esc (claimed) close, and a camera move closes it too — a menu floating over a moved graph is anchored to nothing. 2. THE CONFIRM (delete-node-dialog.tsx, portaled z-50 like the session-delete dialog whose tone it follows): the pre-flight impact read (GET /api/graph/nodes/[id]) renders the plain facts — link count, quiz-answer count, session-note count (zeros stay visible — hiding a zero fact would understate the blast), and the referencing sessions with display names joined CLIENT-SIDE from the snapshot (chat titles; imported-notes file paths; unknown ids honestly themselves). "This cannot be undone" says the permanence. The 409 extraction_running renders the dock phrasing (EXTRACTION_RETRY_LINE — the server names the extraction, the ACTIVITY DOCK is the surface the user actually watches drain) with a Try-again; every other failure shows the server's line verbatim. The honest 404 (deleted elsewhere meanwhile) states it plainly and its Close rides the same refresh. The pure body is exported separately (the settings-dialog pattern) — SSR pins everything the live dialog shows. 3. SUCCESS: onDeleted dispatches lh:graph-revision with the returned graphRevision — the wave-6 chain refetches snapshot + costs. The detail card closes if it pointed at the dying node (the refetch would close it anyway; the same frame should not show a card for a node the user just killed). Clusters shrink / unrender below the minimum on the server's read — the client just renders the refreshed snapshot. Tests: 867/867 (+8 — the join helper, honest plurals, the 409 phrasing pin, the dialog body at loading/gone/impact/failure states). Live-verified on :3001 against the real engine, scratch data: impact facts match the API, the intercepted 409 shows the dock line and Try-again recovers into a real delete, the three-member cluster died below MIN_CLUSTER_SIZE server-side, the labeled seedplan cluster + its canvas label died with it (cold load draws nothing stale; the in-session refresh refetches and the dying node's label stops drawing), other nodes survived, and the full menu contract holds (empty stage → no menu; Esc, outside-click, camera move all close).
This commit is contained in:
@@ -124,6 +124,10 @@ export interface CanvasData {
|
||||
export interface RendererCallbacks {
|
||||
/** A node was clicked (screen position included for card anchoring). */
|
||||
onSelect: (nodeId: string, screenPos: Pt) => void;
|
||||
/** Right-click: the picked node (canvas-relative screen position) or
|
||||
* null when the pointer hit empty stage — the caller opens/closes
|
||||
* its context menu accordingly. (node interlude, wave 7) */
|
||||
onContext: (nodeId: string | null, screenPos: Pt) => void;
|
||||
/** The empty stage was clicked. */
|
||||
onStageClick: () => void;
|
||||
/** Any camera move (pan/zoom) — the caller dismisses anchored UI and
|
||||
@@ -635,6 +639,10 @@ export class GraphCanvasRenderer {
|
||||
canvas.addEventListener("pointerdown", this.onPointerDown);
|
||||
canvas.addEventListener("pointerup", this.onPointerUp);
|
||||
canvas.addEventListener("pointerleave", this.onPointerLeave);
|
||||
// Right-click → the caller's context menu (node interlude). Always
|
||||
// preventDefault: the native menu on a full-bleed custom canvas is
|
||||
// never useful; the caller decides what (if anything) opens.
|
||||
canvas.addEventListener("contextmenu", this.onContextMenu);
|
||||
|
||||
// The persisted camera: replace the default with wherever the user
|
||||
// left the graph. Applied through the zoom behavior so d3's internal
|
||||
@@ -809,6 +817,7 @@ export class GraphCanvasRenderer {
|
||||
this.canvas.removeEventListener("pointerdown", this.onPointerDown);
|
||||
this.canvas.removeEventListener("pointerup", this.onPointerUp);
|
||||
this.canvas.removeEventListener("pointerleave", this.onPointerLeave);
|
||||
this.canvas.removeEventListener("contextmenu", this.onContextMenu);
|
||||
this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
@@ -926,6 +935,15 @@ export class GraphCanvasRenderer {
|
||||
else this.callbacks.onStageClick();
|
||||
};
|
||||
|
||||
/** Right-click: pick the node under the pointer, hand the position to
|
||||
* the caller (empty stage = null — the caller closes any open menu). */
|
||||
private onContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const pos = this.eventPos(e);
|
||||
const node = this.pickAt(pos.x, pos.y);
|
||||
this.callbacks.onContext(node?.id ?? null, pos);
|
||||
};
|
||||
|
||||
private onPointerLeave = () => {
|
||||
if (this.dragId) return; // pointer capture keeps the drag alive
|
||||
this.downPos = null;
|
||||
|
||||
Reference in New Issue
Block a user