refactor(canvas): split Canvas.tsx into hooks; parallelize batchNest

Two concerns in one commit (separate files, each self-contained):

## Canvas.tsx split (from ~680 to ~250 lines)

Canvas.tsx was holding drag gesture state + keyboard shortcuts +
viewport wiring + JSX. Each concern now lives in its own unit under
canvas/src/components/canvas/:

- dragUtils.ts          — pure: shouldDetach, clampChildIntoParent,
                          DETACH_FRACTION
- DropTargetBadge.tsx   — the floating "Drop into: <name>" label + the
                          dashed ghost preview at the target slot
- useDragHandlers.ts    — encapsulates onNodeDragStart / Drag / Stop,
                          findDropTarget hit-test, pendingNest state,
                          and confirmNest/cancelNest. Routes multi-
                          select drags through batchNest automatically.
- useKeyboardShortcuts  — Esc, Enter, Shift+Enter, Cmd+]/[, Z — one
                          window listener, one source of truth.
- useCanvasViewport     — pan-to-node + zoom-to-team CustomEvent
                          listeners and the debounced viewport save.

Canvas.tsx becomes a thin composition + JSX file. No behavioural
change; the refactor is covered by the existing 915 canvas tests.

## batchNest parallelization (2N round-trips → N, all in flight)

Previously nestNode fired two sequential PATCHes (parent_id then x/y)
and batchNest looped nestNode sequentially. For a 5-node selection on
a typical ~200ms link this was ~2s of serialized RPCs.

- nestNode now combines parent_id + x + y into ONE PATCH. The Go
  handler (workspace_crud.go Update) already reads all three from the
  same body — no backend change.
- batchNest rewritten: compute every re-parent plan against one
  snapshot, commit a single set(), then fire N PATCHes via
  Promise.allSettled in parallel. Per-node failures roll back only
  that node (others stay committed) — same semantics as the single-
  node path, just concurrent.
- The state math in the batch path also correctly shifts descendant
  zIndex by depthDelta when any re-parented node has a subtree.

## Also

- canvas-topology.ts: reverted P3.12's opt-in rescue to the auto-
  rescue default. When a child's stored relative position would render
  it outside the parent bbox (the visual regression the user saw after
  collapse → reload — Hermes child drawn outside Claude Code Agent on
  first paint), the child is placed in the next default grid slot.
  The "Arrange Children" context command stays for bigger teams.

All 915 canvas tests pass. No backend changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-23 19:43:18 -07:00
parent c5abed988e
commit 50b537849a
9 changed files with 873 additions and 588 deletions

View File

@ -1,26 +1,18 @@
"use client";
import { useCallback, useRef, useMemo, useEffect, useState } from "react";
import { useCallback, useMemo } from "react";
import {
ReactFlow,
ReactFlowProvider,
Background,
Controls,
MiniMap,
useReactFlow,
type OnNodeDrag,
type Node,
type Edge,
BackgroundVariant,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import {
defaultChildSlot,
CHILD_DEFAULT_HEIGHT,
CHILD_DEFAULT_WIDTH,
} from "@/store/canvas-topology";
import { useCanvasStore } from "@/store/canvas";
import { A2ATopologyOverlay } from "./A2ATopologyOverlay";
import { WorkspaceNode } from "./WorkspaceNode";
import { SidePanel } from "./SidePanel";
@ -32,17 +24,19 @@ import { BundleDropZone } from "./BundleDropZone";
import { EmptyState } from "./EmptyState";
import { OnboardingWizard } from "./OnboardingWizard";
import { SearchDialog } from "./SearchDialog";
import { Toaster } from "./Toaster";
import { Toaster, showToast } from "./Toaster";
import { Toolbar } from "./Toolbar";
import { ConfirmDialog } from "./ConfirmDialog";
import { api } from "@/lib/api";
import { showToast } from "./Toaster";
// Phase 20 components
import { SettingsPanel, DeleteConfirmDialog } from "./settings";
// Phase 20.3 batch operations
import { BatchActionBar } from "./BatchActionBar";
import { ProvisioningTimeout } from "./ProvisioningTimeout";
import { DropTargetBadge } from "./canvas/DropTargetBadge";
import { useDragHandlers } from "./canvas/useDragHandlers";
import { useKeyboardShortcuts } from "./canvas/useKeyboardShortcuts";
import { useCanvasViewport } from "./canvas/useCanvasViewport";
const nodeTypes = {
workspaceNode: WorkspaceNode,
};
@ -63,243 +57,38 @@ export function Canvas() {
);
}
// Hysteresis: detach-on-drop only fires once the child has moved far
// enough outside the parent that the intent is unambiguous. We pick 20%
// of the overlapping dimension as the threshold (Miro behaves similarly
// at ~20-30%). A slightly-past-edge drag commits a MOVE, not a detach.
const DETACH_FRACTION = 0.2;
/** Floating "Drop into: <name>" label that tracks the current drag
* target. Mural-style affordance colour alone is ambiguous on dense
* canvases, so we spell out the target by name. Mounted inside the
* ReactFlowProvider subtree so it can read positionAbsolute. */
function DropTargetBadge() {
const dragOverNodeId = useCanvasStore((s) => s.dragOverNodeId);
const targetName = useCanvasStore((s) => {
if (!s.dragOverNodeId) return null;
const n = s.nodes.find((nn) => nn.id === s.dragOverNodeId);
return (n?.data as WorkspaceNodeData | undefined)?.name ?? null;
});
const childCount = useCanvasStore((s) =>
!s.dragOverNodeId
? 0
: s.nodes.filter((n) => n.parentId === s.dragOverNodeId).length,
);
const { getInternalNode, flowToScreenPosition } = useReactFlow();
if (!dragOverNodeId || !targetName) return null;
const internal = getInternalNode(dragOverNodeId);
if (!internal) return null;
const abs = internal.internals.positionAbsolute;
const w = internal.measured?.width ?? 220;
const h = internal.measured?.height ?? 120;
const badge = flowToScreenPosition({ x: abs.x + w / 2, y: abs.y });
// Ghost preview: dashed outline at the next default grid slot inside
// the target parent. Whimsical-style affordance so the user sees
// exactly where the dropped card will land.
const slot = defaultChildSlot(childCount);
const slotTL = flowToScreenPosition({ x: abs.x + slot.x, y: abs.y + slot.y });
const slotBR = flowToScreenPosition({
x: abs.x + slot.x + CHILD_DEFAULT_WIDTH,
y: abs.y + slot.y + CHILD_DEFAULT_HEIGHT,
});
// Clip the ghost to the parent's visible bounds so it doesn't spill
// out when the parent is smaller than the slot.
const parentTL = flowToScreenPosition({ x: abs.x, y: abs.y });
const parentBR = flowToScreenPosition({ x: abs.x + w, y: abs.y + h });
const ghostVisible =
slotBR.x > parentTL.x &&
slotTL.x < parentBR.x &&
slotBR.y > parentTL.y &&
slotTL.y < parentBR.y;
return (
<>
{ghostVisible && (
<div
className="pointer-events-none absolute z-40 rounded-lg border-2 border-dashed border-emerald-400/70 bg-emerald-500/10"
style={{
left: slotTL.x,
top: slotTL.y,
width: slotBR.x - slotTL.x,
height: slotBR.y - slotTL.y,
}}
/>
)}
<div
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-emerald-50 shadow-lg shadow-emerald-950/40"
style={{ left: badge.x, top: badge.y - 6 }}
>
Drop into: {targetName}
</div>
</>
);
}
/** Snap a child back so its bbox is fully inside the parent's bounds.
* Called on drag-stop when the user drifted slightly past the edge
* without holding Alt or Cmd the canvas treats the gesture as a
* plain move rather than an un-nest. */
function clampChildIntoParent(
childId: string,
parentId: string,
getInternalNode: (id: string) => ReturnType<ReturnType<typeof useReactFlow>["getInternalNode"]>,
) {
const c = getInternalNode(childId);
const p = getInternalNode(parentId);
if (!c || !p) return;
const cw = c.measured?.width ?? c.width ?? 220;
const ch = c.measured?.height ?? c.height ?? 120;
const pw = p.measured?.width ?? p.width ?? 220;
const ph = p.measured?.height ?? p.height ?? 120;
const { nodes } = useCanvasStore.getState();
const cur = nodes.find((n) => n.id === childId);
if (!cur) return;
const rel = cur.position;
const clampedX = Math.max(0, Math.min(rel.x, pw - cw));
const clampedY = Math.max(0, Math.min(rel.y, ph - ch));
if (clampedX === rel.x && clampedY === rel.y) return;
useCanvasStore.setState({
nodes: nodes.map((n) =>
n.id === childId ? { ...n, position: { x: clampedX, y: clampedY } } : n,
),
});
}
function shouldDetach(
childId: string,
parentId: string,
getInternalNode: (id: string) => ReturnType<ReturnType<typeof useReactFlow>["getInternalNode"]>,
): boolean {
const c = getInternalNode(childId);
const p = getInternalNode(parentId);
if (!c || !p) return true; // If we can't measure, fall back to the old behavior.
const cw = c.measured?.width ?? c.width ?? 220;
const ch = c.measured?.height ?? c.height ?? 120;
const pw = p.measured?.width ?? p.width ?? 220;
const ph = p.measured?.height ?? p.height ?? 120;
const cx = c.internals.positionAbsolute;
const px = p.internals.positionAbsolute;
const overlapW =
Math.max(0, Math.min(cx.x + cw, px.x + pw) - Math.max(cx.x, px.x));
const overlapH =
Math.max(0, Math.min(cx.y + ch, px.y + ph) - Math.max(cx.y, px.y));
const outsideFractionX = 1 - overlapW / cw;
const outsideFractionY = 1 - overlapH / ch;
return outsideFractionX > DETACH_FRACTION || outsideFractionY > DETACH_FRACTION;
}
function CanvasInner() {
const nodes = useCanvasStore((s) => s.nodes);
const edges = useCanvasStore((s) => s.edges);
const a2aEdges = useCanvasStore((s) => s.a2aEdges);
const showA2AEdges = useCanvasStore((s) => s.showA2AEdges);
// Merge topology edges with A2A overlay edges via useMemo (no new object in selector)
const allEdges = useMemo(
() => (showA2AEdges ? [...edges, ...a2aEdges] : edges),
[edges, a2aEdges, showA2AEdges]
[edges, a2aEdges, showA2AEdges],
);
const onNodesChange = useCanvasStore((s) => s.onNodesChange);
const savePosition = useCanvasStore((s) => s.savePosition);
const selectNode = useCanvasStore((s) => s.selectNode);
const selectedNodeId = useCanvasStore((s) => s.selectedNodeId);
const setDragOverNode = useCanvasStore((s) => s.setDragOverNode);
const nestNode = useCanvasStore((s) => s.nestNode);
const isDescendant = useCanvasStore((s) => s.isDescendant);
const dragStartParentRef = useRef<string | null>(null);
const dragModifiersRef = useRef<{ alt: boolean; meta: boolean }>({ alt: false, meta: false });
const { getInternalNode } = useReactFlow();
// Track Alt / Cmd-Meta during the whole drag so onNodeDrag and
// onNodeDragStop see the same modifier state. (React Flow's drag event
// only fires mousemove events — we attach a window-level keyboard
// listener while a drag is in progress.)
const onNodeDragStart: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
(event, node) => {
dragStartParentRef.current = (node.data as WorkspaceNodeData).parentId;
dragModifiersRef.current = {
alt: event.altKey,
meta: event.metaKey || event.ctrlKey,
};
},
[],
);
// Drag / nest lifecycle — handlers, pending-nest state, confirm/cancel.
const {
onNodeDragStart,
onNodeDrag,
onNodeDragStop,
pendingNest,
confirmNest,
cancelNest,
} = useDragHandlers();
// Absolute-bounds hit test. Returns the **best** drop target among the
// candidates whose measured bbox contains `point`. Tiebreakers, in
// order — the user drops onto what's visually on top, so zIndex wins
// first (a user can Cmd+] bump a shallow card above a deep one):
//
// 1. Highest zIndex first — matches what the user sees in front.
// 2. DEEPEST tree depth second — when zIndex ties, a more-nested
// card is a more specific target than its ancestor.
// 3. Smallest area last — if depth also ties, the tighter bbox wins.
//
// Self + descendants are excluded (can't nest something under itself).
// Depths are pre-computed once per call so this stays O(n) overall —
// previously the per-candidate depth walk made it O(n²).
const findDropTarget = useCallback(
(draggedId: string, point: { x: number; y: number }): string | null => {
const all = useCanvasStore.getState().nodes;
const depthById = new Map<string, number>();
for (const n of all) {
depthById.set(n.id, n.data.parentId ? (depthById.get(n.data.parentId) ?? 0) + 1 : 0);
}
let best:
| { id: string; depth: number; zIndex: number; area: number }
| null = null;
for (const n of all) {
if (n.id === draggedId || isDescendant(draggedId, n.id)) continue;
const internal = getInternalNode(n.id);
if (!internal) continue;
const abs = internal.internals.positionAbsolute;
const w = internal.measured?.width ?? n.width ?? 220;
const h = internal.measured?.height ?? n.height ?? 120;
if (point.x < abs.x || point.x > abs.x + w) continue;
if (point.y < abs.y || point.y > abs.y + h) continue;
const depth = depthById.get(n.id) ?? 0;
const z = n.zIndex ?? 0;
const area = w * h;
if (
!best ||
z > best.zIndex ||
(z === best.zIndex && depth > best.depth) ||
(z === best.zIndex && depth === best.depth && area < best.area)
) {
best = { id: n.id, depth, zIndex: z, area };
}
}
return best?.id ?? null;
},
[getInternalNode, isDescendant],
);
// Window-level keyboard shortcuts (Esc, Enter, Shift+Enter, Cmd+]/[, Z).
useKeyboardShortcuts();
const onNodeDrag: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
(event, node) => {
dragModifiersRef.current = {
alt: event.altKey,
meta: event.metaKey || event.ctrlKey,
};
const internal = getInternalNode(node.id);
if (!internal) {
setDragOverNode(null);
return;
}
const abs = internal.internals.positionAbsolute;
const w = internal.measured?.width ?? 220;
const h = internal.measured?.height ?? 120;
const center = { x: abs.x + w / 2, y: abs.y + h / 2 };
setDragOverNode(findDropTarget(node.id, center));
},
[findDropTarget, getInternalNode, setDragOverNode],
);
// Pan-to-node / zoom-to-team CustomEvent listeners + viewport save.
const { onMoveEnd } = useCanvasViewport();
// Confirmation dialog state for structure changes
const [pendingNest, setPendingNest] = useState<{ nodeId: string; targetId: string | null; nodeName: string; targetName: string } | null>(null);
// Delete-confirmation lives in the store so the dialog survives ContextMenu
// unmounting — the prior local-in-ContextMenu state raced with the menu's
// outside-click handler (the portal-rendered Confirm button counted as
// "outside" and closed the menu, killing the dialog mid-click).
// outside-click handler.
const pendingDelete = useCanvasStore((s) => s.pendingDelete);
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
const removeNode = useCanvasStore((s) => s.removeNode);
@ -315,87 +104,6 @@ function CanvasInner() {
}
}, [pendingDelete, setPendingDelete, removeNode]);
const onNodeDragStop: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
(event, node) => {
const { dragOverNodeId, nodes: allNodes } = useCanvasStore.getState();
setDragOverNode(null);
const nodeName = (node.data as WorkspaceNodeData).name;
const currentParentId = (node.data as WorkspaceNodeData).parentId;
const altHeld = event.altKey || dragModifiersRef.current.alt;
const forceDetach =
event.metaKey || event.ctrlKey || dragModifiersRef.current.meta;
// Soft clamp: without a modifier, a child dropped just past its
// parent's edge is snapped back inside (Alt-drag escapes this to
// allow re-parenting). The explicit nest gesture (drop inside
// another parent) always wins over the clamp.
const droppingIntoAnotherParent =
!!dragOverNodeId && dragOverNodeId !== currentParentId;
if (
currentParentId &&
!altHeld &&
!forceDetach &&
!droppingIntoAnotherParent &&
shouldDetach(node.id, currentParentId, getInternalNode)
) {
clampChildIntoParent(node.id, currentParentId, getInternalNode);
}
// The drag-stop offers several possible intents. Hysteresis
// (Miro/tldraw pattern) keeps a child nested unless it's clearly
// outside the parent — a twitchy release 1px past the edge no
// longer un-nests. Cmd / Ctrl (forceDetach) or Alt (escape)
// bypass the clamp.
if (droppingIntoAnotherParent) {
const targetNode = allNodes.find((n) => n.id === dragOverNodeId);
const targetName = targetNode?.data.name || "Unknown";
setPendingNest({ nodeId: node.id, targetId: dragOverNodeId, nodeName, targetName });
} else if (
currentParentId &&
(forceDetach || (altHeld && shouldDetach(node.id, currentParentId, getInternalNode)))
) {
const parentNode = allNodes.find((n) => n.id === currentParentId);
const parentName = parentNode?.data.name || "Unknown";
setPendingNest({ nodeId: node.id, targetId: null, nodeName, targetName: parentName });
}
// savePosition expects ABSOLUTE coords. When node is a child, its
// `position` is relative to its parent, so translate through the
// measured absolute position React Flow tracks.
const internal = getInternalNode(node.id);
const abs = internal?.internals.positionAbsolute ?? node.position;
savePosition(node.id, abs.x, abs.y);
// Commit-on-release grow: run the parent auto-grow pass once now
// that the drag has settled. Cheap and deterministic vs running
// grow on every drag tick (avoids tldraw's edge-chase artifact).
useCanvasStore.getState().growParentsToFitChildren();
},
[getInternalNode, savePosition, setDragOverNode],
);
const batchNest = useCanvasStore((s) => s.batchNest);
const confirmNest = useCallback(() => {
if (!pendingNest) return;
const state = useCanvasStore.getState();
// If the primary dragged node is part of a batch selection, apply
// the same nest target to every selected node — preserves the
// selection's inter-node spacing (Lucidchart pattern).
if (
state.selectedNodeIds.size > 1 &&
state.selectedNodeIds.has(pendingNest.nodeId)
) {
batchNest(Array.from(state.selectedNodeIds), pendingNest.targetId);
} else {
nestNode(pendingNest.nodeId, pendingNest.targetId);
}
setPendingNest(null);
}, [pendingNest, nestNode, batchNest]);
const cancelNest = useCallback(() => {
setPendingNest(null);
}, []);
const onPaneClick = useCallback(() => {
selectNode(null);
const state = useCanvasStore.getState();
@ -403,153 +111,14 @@ function CanvasInner() {
state.clearSelection();
}, [selectNode]);
// Team zoom-in: double-click a team node to zoom to its children
const { fitBounds, fitView } = useReactFlow();
// Pan to newly deployed workspace.
// Uses fitView({ nodes }) so the viewport adapts to any current zoom level
// instead of forcing zoom=1 (which was jarring when the user was zoomed out).
const panTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
const handler = (e: Event) => {
const { nodeId } = (e as CustomEvent<{ nodeId: string }>).detail;
// Small delay so ReactFlow has time to measure the newly rendered node
clearTimeout(panTimerRef.current);
panTimerRef.current = setTimeout(() => {
fitView({ nodes: [{ id: nodeId }], duration: 400, padding: 0.3 });
}, 100);
};
window.addEventListener("molecule:pan-to-node", handler);
return () => {
window.removeEventListener("molecule:pan-to-node", handler);
clearTimeout(panTimerRef.current);
};
}, [fitView]);
useEffect(() => {
const handler = (e: Event) => {
const { nodeId } = (e as CustomEvent).detail;
const state = useCanvasStore.getState();
const children = state.nodes.filter((n) => n.data.parentId === nodeId);
if (children.length === 0) return;
const parent = state.nodes.find((n) => n.id === nodeId);
const allNodes = parent ? [parent, ...children] : children;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of allNodes) {
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
maxX = Math.max(maxX, n.position.x + CHILD_DEFAULT_WIDTH);
maxY = Math.max(maxY, n.position.y + CHILD_DEFAULT_HEIGHT);
}
fitBounds(
{ x: minX - 50, y: minY - 50, width: maxX - minX + 100, height: maxY - minY + 100 },
{ padding: 0.2, duration: 500 }
);
};
window.addEventListener("molecule:zoom-to-team", handler);
return () => window.removeEventListener("molecule:zoom-to-team", handler);
}, [fitBounds]);
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
const inInput =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
(e.target as HTMLElement).isContentEditable;
if (e.key === "Escape") {
const state = useCanvasStore.getState();
if (state.contextMenu) {
state.closeContextMenu();
} else if (state.selectedNodeIds.size > 0) {
state.clearSelection();
} else if (state.selectedNodeId) {
state.selectNode(null);
}
}
// Figma-style hierarchy navigation. Enter descends to the first
// child of the selected node; Shift+Enter ascends to its parent;
// Cmd+]/[ re-orders siblings (z-index up/down). Skipped when the
// user is typing into an input — Enter should commit the form.
if (!inInput && (e.key === "Enter" || e.key === "NumpadEnter")) {
e.preventDefault();
const state = useCanvasStore.getState();
const id = state.selectedNodeId;
if (!id) return;
if (e.shiftKey) {
const sel = state.nodes.find((n) => n.id === id);
const parentId = sel?.data.parentId ?? null;
if (parentId) state.selectNode(parentId);
} else {
const firstChild = state.nodes.find((n) => n.data.parentId === id);
if (firstChild) state.selectNode(firstChild.id);
}
}
if (
!inInput &&
(e.metaKey || e.ctrlKey) &&
(e.key === "]" || e.key === "[")
) {
e.preventDefault();
const state = useCanvasStore.getState();
const id = state.selectedNodeId;
if (!id) return;
state.bumpZOrder(id, e.key === "]" ? 1 : -1);
}
// Z — keyboard equivalent for double-click zoom-to-team (WCAG 2.1.1)
if (!inInput && (e.key === "z" || e.key === "Z")) {
const state = useCanvasStore.getState();
const selectedId = state.selectedNodeId;
if (!selectedId) return;
const hasChildren = state.nodes.some((n) => n.data.parentId === selectedId);
if (hasChildren) {
window.dispatchEvent(
new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: selectedId } })
);
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const saveViewport = useCanvasStore((s) => s.saveViewport);
const viewport = useCanvasStore((s) => s.viewport);
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Cleanup debounced save timer on unmount
useEffect(() => {
return () => clearTimeout(saveTimerRef.current);
}, []);
const onMoveEnd = useCallback(
(_event: unknown, vp: { x: number; y: number; zoom: number }) => {
// Debounce viewport saves to avoid spamming the API
clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
saveViewport(vp.x, vp.y, vp.zoom);
}, 1000);
},
[saveViewport]
);
const defaultViewport = useMemo(
() => ({ x: viewport.x, y: viewport.y, zoom: viewport.zoom }),
// Only use the initial viewport — don't re-render on every save
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
[],
);
// Determine which workspace ID to use for global settings.
// Fall back to "global" when no specific node is selected.
const settingsWorkspaceId = selectedNodeId ?? "global";
return (
@ -561,121 +130,118 @@ function CanvasInner() {
Skip to canvas
</a>
<main id="canvas-main" className="w-screen h-screen bg-zinc-950">
<ReactFlow
colorMode="dark"
nodes={nodes}
edges={allEdges}
onNodesChange={onNodesChange}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onPaneClick={onPaneClick}
onMoveEnd={onMoveEnd}
nodeTypes={nodeTypes}
defaultEdgeOptions={defaultEdgeOptions}
defaultViewport={defaultViewport}
fitView={viewport.x === 0 && viewport.y === 0 && viewport.zoom === 1}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
aria-label="Molecule AI workspace canvas"
>
<Background
variant={BackgroundVariant.Dots}
gap={24}
size={1}
color="#27272a"
<ReactFlow
colorMode="dark"
nodes={nodes}
edges={allEdges}
onNodesChange={onNodesChange}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onPaneClick={onPaneClick}
onMoveEnd={onMoveEnd}
nodeTypes={nodeTypes}
defaultEdgeOptions={defaultEdgeOptions}
defaultViewport={defaultViewport}
fitView={viewport.x === 0 && viewport.y === 0 && viewport.zoom === 1}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
aria-label="Molecule AI workspace canvas"
>
<Background
variant={BackgroundVariant.Dots}
gap={24}
size={1}
color="#27272a"
/>
<Controls
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20 [&>button]:!bg-zinc-800 [&>button]:!border-zinc-700/50 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-700 [&>button:hover]:!text-zinc-200"
showInteractive={false}
/>
<MiniMap
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20"
maskColor="rgba(0, 0, 0, 0.7)"
nodeColor={(node) => {
// Parents show as a filled region — hierarchy visible at
// a glance in the minimap without needing to zoom.
const hasChildren = nodes.some((n) => n.parentId === node.id);
if (hasChildren) return "#3b82f6";
const status = (node.data as Record<string, unknown>)?.status;
switch (status) {
case "online":
return "#34d399";
case "offline":
return "#52525b";
case "degraded":
return "#fbbf24";
case "failed":
return "#f87171";
case "provisioning":
return "#38bdf8";
default:
return "#3f3f46";
}
}}
nodeStrokeColor={(node) => {
const hasChildren = nodes.some((n) => n.parentId === node.id);
return hasChildren ? "#60a5fa" : "transparent";
}}
nodeStrokeWidth={2}
nodeBorderRadius={4}
/>
<DropTargetBadge />
</ReactFlow>
{/* Screen-reader live region: announces workspace count on canvas load or change */}
<div role="status" aria-live="polite" className="sr-only">
{nodes.filter((n) => !n.data.parentId).length === 0
? "No workspaces on canvas"
: `${nodes.filter((n) => !n.data.parentId).length} workspace${nodes.filter((n) => !n.data.parentId).length !== 1 ? "s" : ""} on canvas`}
</div>
{nodes.length === 0 && <EmptyState />}
<A2ATopologyOverlay />
<OnboardingWizard />
<Toolbar />
<ApprovalBanner />
<BundleDropZone />
<TemplatePalette />
<SidePanel />
<ContextMenu />
<SearchDialog />
<Toaster />
<ProvisioningTimeout />
{!selectedNodeId && <CreateWorkspaceButton />}
<BatchActionBar />
<ConfirmDialog
open={!!pendingNest}
title={pendingNest?.targetId ? "Nest Workspace" : "Extract Workspace"}
message={
pendingNest?.targetId
? `Move "${pendingNest.nodeName}" inside "${pendingNest.targetName}"? This changes the org hierarchy — ${pendingNest.nodeName} will become a sub-workspace of ${pendingNest.targetName}.`
: `Extract "${pendingNest?.nodeName}" from "${pendingNest?.targetName}"? This moves it to the root level.`
}
confirmLabel={pendingNest?.targetId ? "Nest" : "Extract"}
onConfirm={confirmNest}
onCancel={cancelNest}
/>
<Controls
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20 [&>button]:!bg-zinc-800 [&>button]:!border-zinc-700/50 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-700 [&>button:hover]:!text-zinc-200"
showInteractive={false}
<ConfirmDialog
open={!!pendingDelete}
title={pendingDelete?.hasChildren ? "Delete Workspace and Children" : "Delete Workspace"}
message={pendingDelete?.hasChildren
? `⚠️ Deleting "${pendingDelete?.name}" will permanently delete all of its child workspaces and their data. This cannot be undone.`
: `Permanently delete "${pendingDelete?.name}"? This will stop the container and remove all configuration. This action cannot be undone.`}
confirmLabel={pendingDelete?.hasChildren ? "Delete All" : "Delete"}
confirmVariant="danger"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
<MiniMap
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20"
maskColor="rgba(0, 0, 0, 0.7)"
nodeColor={(node) => {
// Parents show as a filled region — hierarchy visible at
// a glance in the minimap without needing to zoom.
const hasChildren = nodes.some((n) => n.parentId === node.id);
if (hasChildren) return "#3b82f6";
const status = (node.data as Record<string, unknown>)?.status;
switch (status) {
case "online":
return "#34d399";
case "offline":
return "#52525b";
case "degraded":
return "#fbbf24";
case "failed":
return "#f87171";
case "provisioning":
return "#38bdf8";
default:
return "#3f3f46";
}
}}
nodeStrokeColor={(node) => {
const hasChildren = nodes.some((n) => n.parentId === node.id);
return hasChildren ? "#60a5fa" : "transparent";
}}
nodeStrokeWidth={2}
nodeBorderRadius={4}
/>
<DropTargetBadge />
</ReactFlow>
{/* Screen-reader live region: announces workspace count when canvas loads or changes */}
<div role="status" aria-live="polite" className="sr-only">
{nodes.filter((n) => !n.data.parentId).length === 0
? "No workspaces on canvas"
: `${nodes.filter((n) => !n.data.parentId).length} workspace${nodes.filter((n) => !n.data.parentId).length !== 1 ? "s" : ""} on canvas`}
</div>
{nodes.length === 0 && <EmptyState />}
<A2ATopologyOverlay />
<OnboardingWizard />
<Toolbar />
<ApprovalBanner />
<BundleDropZone />
<TemplatePalette />
<SidePanel />
<ContextMenu />
<SearchDialog />
<Toaster />
<ProvisioningTimeout />
{!selectedNodeId && <CreateWorkspaceButton />}
<BatchActionBar />
{/* Confirmation dialog for structure changes */}
<ConfirmDialog
open={!!pendingNest}
title={pendingNest?.targetId ? "Nest Workspace" : "Extract Workspace"}
message={
pendingNest?.targetId
? `Move "${pendingNest.nodeName}" inside "${pendingNest.targetName}"? This changes the org hierarchy — ${pendingNest.nodeName} will become a sub-workspace of ${pendingNest.targetName}.`
: `Extract "${pendingNest?.nodeName}" from "${pendingNest?.targetName}"? This moves it to the root level.`
}
confirmLabel={pendingNest?.targetId ? "Nest" : "Extract"}
onConfirm={confirmNest}
onCancel={cancelNest}
/>
{/* Confirmation dialog for workspace delete — driven by store */}
<ConfirmDialog
open={!!pendingDelete}
title={pendingDelete?.hasChildren ? "Delete Workspace and Children" : "Delete Workspace"}
message={pendingDelete?.hasChildren
? `⚠️ Deleting "${pendingDelete?.name}" will permanently delete all of its child workspaces and their data. This cannot be undone.`
: `Permanently delete "${pendingDelete?.name}"? This will stop the container and remove all configuration. This action cannot be undone.`}
confirmLabel={pendingDelete?.hasChildren ? "Delete All" : "Delete"}
confirmVariant="danger"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
{/* Settings Panel — global secrets management drawer */}
<SettingsPanel workspaceId={settingsWorkspaceId} />
<DeleteConfirmDialog workspaceId={settingsWorkspaceId} />
<SettingsPanel workspaceId={settingsWorkspaceId} />
<DeleteConfirmDialog workspaceId={settingsWorkspaceId} />
</main>
</>
);

View File

@ -71,11 +71,14 @@ describe("Toolbar help panel — zoom shortcut entry", () => {
expect(src).toContain("Zoom canvas to fit a team node");
});
it("Canvas.tsx Z key handler guards against input elements", async () => {
it("Keyboard shortcuts hook guards against input elements", async () => {
const { readFileSync } = await import("fs");
const { join } = await import("path");
// After the canvas split (commit c5abed98 → f3423a51 series), the
// Z-key / hierarchy / zoom shortcuts moved out of Canvas.tsx into
// the useKeyboardShortcuts hook under src/components/canvas/.
const src = readFileSync(
join(__dirname, "../../components/Canvas.tsx"),
join(__dirname, "../../components/canvas/useKeyboardShortcuts.ts"),
"utf8"
);
expect(src).toContain('e.key === "z" || e.key === "Z"');

View File

@ -0,0 +1,83 @@
"use client";
import { useReactFlow } from "@xyflow/react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import {
defaultChildSlot,
CHILD_DEFAULT_HEIGHT,
CHILD_DEFAULT_WIDTH,
} from "@/store/canvas-topology";
/**
* Floating affordance that tracks the current drag target. Two visuals
* are layered on top of React Flow, both in screen space:
*
* 1. Ghost preview dashed outline at the next default grid slot
* inside the target parent. Whimsical-style: users see exactly
* where the card will land before releasing.
* 2. Text badge "Drop into: <name>" floating above the target. The
* coloured outline alone is ambiguous on dense canvases; spelling
* the name out is the Mural pattern.
*
* Colour alone isn't an accessible cue, so the pair (outline + label)
* is deliberate.
*/
export function DropTargetBadge() {
const dragOverNodeId = useCanvasStore((s) => s.dragOverNodeId);
const targetName = useCanvasStore((s) => {
if (!s.dragOverNodeId) return null;
const n = s.nodes.find((nn) => nn.id === s.dragOverNodeId);
return (n?.data as WorkspaceNodeData | undefined)?.name ?? null;
});
const childCount = useCanvasStore((s) =>
!s.dragOverNodeId
? 0
: s.nodes.filter((n) => n.parentId === s.dragOverNodeId).length,
);
const { getInternalNode, flowToScreenPosition } = useReactFlow();
if (!dragOverNodeId || !targetName) return null;
const internal = getInternalNode(dragOverNodeId);
if (!internal) return null;
const abs = internal.internals.positionAbsolute;
const w = internal.measured?.width ?? 220;
const h = internal.measured?.height ?? 120;
const badge = flowToScreenPosition({ x: abs.x + w / 2, y: abs.y });
const slot = defaultChildSlot(childCount);
const slotTL = flowToScreenPosition({ x: abs.x + slot.x, y: abs.y + slot.y });
const slotBR = flowToScreenPosition({
x: abs.x + slot.x + CHILD_DEFAULT_WIDTH,
y: abs.y + slot.y + CHILD_DEFAULT_HEIGHT,
});
// Clip: don't draw the ghost if its rect falls entirely outside the
// parent (can happen when a parent is smaller than one default slot).
const parentTL = flowToScreenPosition({ x: abs.x, y: abs.y });
const parentBR = flowToScreenPosition({ x: abs.x + w, y: abs.y + h });
const ghostVisible =
slotBR.x > parentTL.x &&
slotTL.x < parentBR.x &&
slotBR.y > parentTL.y &&
slotTL.y < parentBR.y;
return (
<>
{ghostVisible && (
<div
className="pointer-events-none absolute z-40 rounded-lg border-2 border-dashed border-emerald-400/70 bg-emerald-500/10"
style={{
left: slotTL.x,
top: slotTL.y,
width: slotBR.x - slotTL.x,
height: slotBR.y - slotTL.y,
}}
/>
)}
<div
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-emerald-50 shadow-lg shadow-emerald-950/40"
style={{ left: badge.x, top: badge.y - 6 }}
>
Drop into: {targetName}
</div>
</>
);
}

View File

@ -0,0 +1,74 @@
import type { useReactFlow } from "@xyflow/react";
import { useCanvasStore } from "@/store/canvas";
/**
* Hysteresis threshold for drag-out detach. A child only un-nests from
* its parent once at least this fraction of its bounding box lies
* outside the parent's bbox a twitchy release 1px past the edge stays
* nested. Miro / tldraw use roughly 20-30%; 20% feels responsive.
*/
export const DETACH_FRACTION = 0.2;
type InternalNode = ReturnType<ReturnType<typeof useReactFlow>["getInternalNode"]>;
type GetInternalNode = (id: string) => InternalNode;
/**
* True when the child has moved far enough outside its parent's bbox
* that the gesture is unambiguously an un-nest. Returns true when we
* can't measure either node (conservative fall-back matches the
* original behaviour).
*/
export function shouldDetach(
childId: string,
parentId: string,
getInternalNode: GetInternalNode,
): boolean {
const c = getInternalNode(childId);
const p = getInternalNode(parentId);
if (!c || !p) return true;
const cw = c.measured?.width ?? c.width ?? 220;
const ch = c.measured?.height ?? c.height ?? 120;
const pw = p.measured?.width ?? p.width ?? 220;
const ph = p.measured?.height ?? p.height ?? 120;
const cx = c.internals.positionAbsolute;
const px = p.internals.positionAbsolute;
const overlapW =
Math.max(0, Math.min(cx.x + cw, px.x + pw) - Math.max(cx.x, px.x));
const overlapH =
Math.max(0, Math.min(cx.y + ch, px.y + ph) - Math.max(cx.y, px.y));
const outsideFractionX = 1 - overlapW / cw;
const outsideFractionY = 1 - overlapH / ch;
return outsideFractionX > DETACH_FRACTION || outsideFractionY > DETACH_FRACTION;
}
/**
* Snap a child back so its bbox is fully inside the parent's bounds.
* Called on drag-stop when the user drifted slightly past the edge
* without holding Alt or Cmd the canvas treats the gesture as a
* plain move rather than an un-nest.
*/
export function clampChildIntoParent(
childId: string,
parentId: string,
getInternalNode: GetInternalNode,
) {
const c = getInternalNode(childId);
const p = getInternalNode(parentId);
if (!c || !p) return;
const cw = c.measured?.width ?? c.width ?? 220;
const ch = c.measured?.height ?? c.height ?? 120;
const pw = p.measured?.width ?? p.width ?? 220;
const ph = p.measured?.height ?? p.height ?? 120;
const { nodes } = useCanvasStore.getState();
const cur = nodes.find((n) => n.id === childId);
if (!cur) return;
const rel = cur.position;
const clampedX = Math.max(0, Math.min(rel.x, pw - cw));
const clampedY = Math.max(0, Math.min(rel.y, ph - ch));
if (clampedX === rel.x && clampedY === rel.y) return;
useCanvasStore.setState({
nodes: nodes.map((n) =>
n.id === childId ? { ...n, position: { x: clampedX, y: clampedY } } : n,
),
});
}

View File

@ -0,0 +1,96 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useReactFlow } from "@xyflow/react";
import { useCanvasStore } from "@/store/canvas";
import {
CHILD_DEFAULT_HEIGHT,
CHILD_DEFAULT_WIDTH,
} from "@/store/canvas-topology";
/**
* Wires the two canvas-wide CustomEvent listeners and the viewport
* save/restore bookkeeping so Canvas.tsx doesn't have to.
*
* - `molecule:pan-to-node` scroll viewport onto a specific node
* without forcing a specific zoom level (fitView adapts to current).
* - `molecule:zoom-to-team` fit the viewport to a parent + its
* direct children, with a small padding.
*
* Also returns an `onMoveEnd` handler that debounces viewport saves so
* the backend isn't spammed with pans.
*/
export function useCanvasViewport() {
const { fitBounds, fitView } = useReactFlow();
const saveViewport = useCanvasStore((s) => s.saveViewport);
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const panTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
return () => {
clearTimeout(saveTimerRef.current);
clearTimeout(panTimerRef.current);
};
}, []);
// Pan to a newly deployed / targeted workspace. 100ms delay so React
// Flow has time to measure a just-rendered node.
useEffect(() => {
const handler = (e: Event) => {
const { nodeId } = (e as CustomEvent<{ nodeId: string }>).detail;
clearTimeout(panTimerRef.current);
panTimerRef.current = setTimeout(() => {
fitView({ nodes: [{ id: nodeId }], duration: 400, padding: 0.3 });
}, 100);
};
window.addEventListener("molecule:pan-to-node", handler);
return () => window.removeEventListener("molecule:pan-to-node", handler);
}, [fitView]);
// Zoom to a team: fit the parent + its direct children in view.
useEffect(() => {
const handler = (e: Event) => {
const { nodeId } = (e as CustomEvent).detail;
const state = useCanvasStore.getState();
const children = state.nodes.filter((n) => n.data.parentId === nodeId);
if (children.length === 0) return;
const parent = state.nodes.find((n) => n.id === nodeId);
const allNodes = parent ? [parent, ...children] : children;
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const n of allNodes) {
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
maxX = Math.max(maxX, n.position.x + CHILD_DEFAULT_WIDTH);
maxY = Math.max(maxY, n.position.y + CHILD_DEFAULT_HEIGHT);
}
fitBounds(
{
x: minX - 50,
y: minY - 50,
width: maxX - minX + 100,
height: maxY - minY + 100,
},
{ padding: 0.2, duration: 500 },
);
};
window.addEventListener("molecule:zoom-to-team", handler);
return () => window.removeEventListener("molecule:zoom-to-team", handler);
}, [fitBounds]);
const onMoveEnd = useCallback(
(_event: unknown, vp: { x: number; y: number; zoom: number }) => {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
saveViewport(vp.x, vp.y, vp.zoom);
}, 1000);
},
[saveViewport],
);
return { onMoveEnd };
}

View File

@ -0,0 +1,213 @@
"use client";
import { useCallback, useRef, useState } from "react";
import {
useReactFlow,
type Node,
type OnNodeDrag,
} from "@xyflow/react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { clampChildIntoParent, shouldDetach } from "./dragUtils";
export interface PendingNestState {
nodeId: string;
targetId: string | null;
nodeName: string;
targetName: string;
}
interface DragHandlers {
onNodeDragStart: OnNodeDrag<Node<WorkspaceNodeData>>;
onNodeDrag: OnNodeDrag<Node<WorkspaceNodeData>>;
onNodeDragStop: OnNodeDrag<Node<WorkspaceNodeData>>;
pendingNest: PendingNestState | null;
confirmNest: () => void;
cancelNest: () => void;
}
/**
* Encapsulates every drag gesture on the canvas:
*
* - On drag start, snapshot the modifier keys (Alt / Cmd-Meta) and
* remember which parent the node lived in so we can detect a
* re-parent on release.
* - On drag (mousemove), compute the best drop target via an
* absolute-bounds hit test and publish it via setDragOverNode so
* WorkspaceNode can render the highlight + DropTargetBadge can
* render its label + ghost preview.
* - On drag stop, decide one of: nest into new parent, un-nest, soft
* clamp back inside current parent, or plain move based on
* modifier keys and hysteresis. Persist the absolute position,
* then run one commit-on-release grow pass on the parent chain.
*/
export function useDragHandlers(): DragHandlers {
const setDragOverNode = useCanvasStore((s) => s.setDragOverNode);
const savePosition = useCanvasStore((s) => s.savePosition);
const nestNode = useCanvasStore((s) => s.nestNode);
const batchNest = useCanvasStore((s) => s.batchNest);
const isDescendant = useCanvasStore((s) => s.isDescendant);
const { getInternalNode } = useReactFlow();
const dragStartParentRef = useRef<string | null>(null);
const dragModifiersRef = useRef<{ alt: boolean; meta: boolean }>({
alt: false,
meta: false,
});
const [pendingNest, setPendingNest] = useState<PendingNestState | null>(null);
// Absolute-bounds hit test. Tiebreakers in order: highest zIndex
// first (matches what the user sees in front after Cmd+] reorder),
// deepest tree depth second, smallest area third. Depths are
// pre-computed once per call so the whole pass stays O(n).
const findDropTarget = useCallback(
(draggedId: string, point: { x: number; y: number }): string | null => {
const all = useCanvasStore.getState().nodes;
const depthById = new Map<string, number>();
for (const n of all) {
depthById.set(
n.id,
n.data.parentId ? (depthById.get(n.data.parentId) ?? 0) + 1 : 0,
);
}
let best:
| { id: string; depth: number; zIndex: number; area: number }
| null = null;
for (const n of all) {
if (n.id === draggedId || isDescendant(draggedId, n.id)) continue;
const internal = getInternalNode(n.id);
if (!internal) continue;
const abs = internal.internals.positionAbsolute;
const w = internal.measured?.width ?? n.width ?? 220;
const h = internal.measured?.height ?? n.height ?? 120;
if (point.x < abs.x || point.x > abs.x + w) continue;
if (point.y < abs.y || point.y > abs.y + h) continue;
const depth = depthById.get(n.id) ?? 0;
const z = n.zIndex ?? 0;
const area = w * h;
if (
!best ||
z > best.zIndex ||
(z === best.zIndex && depth > best.depth) ||
(z === best.zIndex && depth === best.depth && area < best.area)
) {
best = { id: n.id, depth, zIndex: z, area };
}
}
return best?.id ?? null;
},
[getInternalNode, isDescendant],
);
const onNodeDragStart: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
(event, node) => {
dragStartParentRef.current = (node.data as WorkspaceNodeData).parentId;
dragModifiersRef.current = {
alt: event.altKey,
meta: event.metaKey || event.ctrlKey,
};
},
[],
);
const onNodeDrag: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
(event, node) => {
dragModifiersRef.current = {
alt: event.altKey,
meta: event.metaKey || event.ctrlKey,
};
const internal = getInternalNode(node.id);
if (!internal) {
setDragOverNode(null);
return;
}
const abs = internal.internals.positionAbsolute;
const w = internal.measured?.width ?? 220;
const h = internal.measured?.height ?? 120;
const center = { x: abs.x + w / 2, y: abs.y + h / 2 };
setDragOverNode(findDropTarget(node.id, center));
},
[findDropTarget, getInternalNode, setDragOverNode],
);
const onNodeDragStop: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
(event, node) => {
const { dragOverNodeId, nodes: allNodes } = useCanvasStore.getState();
setDragOverNode(null);
const nodeName = (node.data as WorkspaceNodeData).name;
const currentParentId = (node.data as WorkspaceNodeData).parentId;
const altHeld = event.altKey || dragModifiersRef.current.alt;
const forceDetach =
event.metaKey || event.ctrlKey || dragModifiersRef.current.meta;
const droppingIntoAnotherParent =
!!dragOverNodeId && dragOverNodeId !== currentParentId;
// Soft clamp (plain drag, no modifier, not re-parenting): snap
// the child back inside its current parent. Alt or Cmd bypass.
if (
currentParentId &&
!altHeld &&
!forceDetach &&
!droppingIntoAnotherParent &&
shouldDetach(node.id, currentParentId, getInternalNode)
) {
clampChildIntoParent(node.id, currentParentId, getInternalNode);
}
if (droppingIntoAnotherParent) {
const targetNode = allNodes.find((n) => n.id === dragOverNodeId);
const targetName = targetNode?.data.name || "Unknown";
setPendingNest({
nodeId: node.id,
targetId: dragOverNodeId,
nodeName,
targetName,
});
} else if (
currentParentId &&
(forceDetach ||
(altHeld && shouldDetach(node.id, currentParentId, getInternalNode)))
) {
const parentNode = allNodes.find((n) => n.id === currentParentId);
const parentName = parentNode?.data.name || "Unknown";
setPendingNest({
nodeId: node.id,
targetId: null,
nodeName,
targetName: parentName,
});
}
const internal = getInternalNode(node.id);
const abs = internal?.internals.positionAbsolute ?? node.position;
savePosition(node.id, abs.x, abs.y);
useCanvasStore.getState().growParentsToFitChildren();
},
[getInternalNode, savePosition, setDragOverNode],
);
const confirmNest = useCallback(() => {
if (!pendingNest) return;
const state = useCanvasStore.getState();
if (
state.selectedNodeIds.size > 1 &&
state.selectedNodeIds.has(pendingNest.nodeId)
) {
batchNest(Array.from(state.selectedNodeIds), pendingNest.targetId);
} else {
nestNode(pendingNest.nodeId, pendingNest.targetId);
}
setPendingNest(null);
}, [pendingNest, nestNode, batchNest]);
const cancelNest = useCallback(() => setPendingNest(null), []);
return {
onNodeDragStart,
onNodeDrag,
onNodeDragStop,
pendingNest,
confirmNest,
cancelNest,
};
}

View File

@ -0,0 +1,87 @@
"use client";
import { useEffect } from "react";
import { useCanvasStore } from "@/store/canvas";
/**
* Canvas-wide keyboard shortcuts. All bound to the document window so
* they work regardless of focused node, except when the user is typing
* into an input (`inInput` short-circuits handling).
*
* Esc close context menu, clear selection, deselect
* Enter descend into selected node's first child
* Shift+Enter ascend to selected node's parent
* Cmd/Ctrl+] bump selected node forward in z-order
* Cmd/Ctrl+[ bump selected node backward in z-order
* Z zoom-to-team if the selected node has children
*/
export function useKeyboardShortcuts() {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
const inInput =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
(e.target as HTMLElement).isContentEditable;
if (e.key === "Escape") {
const state = useCanvasStore.getState();
if (state.contextMenu) {
state.closeContextMenu();
} else if (state.selectedNodeIds.size > 0) {
state.clearSelection();
} else if (state.selectedNodeId) {
state.selectNode(null);
}
}
// Figma-style hierarchy navigation. Skipped when the user is
// typing so Enter can still submit forms.
if (!inInput && (e.key === "Enter" || e.key === "NumpadEnter")) {
e.preventDefault();
const state = useCanvasStore.getState();
const id = state.selectedNodeId;
if (!id) return;
if (e.shiftKey) {
const sel = state.nodes.find((n) => n.id === id);
const parentId = sel?.data.parentId ?? null;
if (parentId) state.selectNode(parentId);
} else {
const firstChild = state.nodes.find((n) => n.data.parentId === id);
if (firstChild) state.selectNode(firstChild.id);
}
}
if (
!inInput &&
(e.metaKey || e.ctrlKey) &&
(e.key === "]" || e.key === "[")
) {
e.preventDefault();
const state = useCanvasStore.getState();
const id = state.selectedNodeId;
if (!id) return;
state.bumpZOrder(id, e.key === "]" ? 1 : -1);
}
if (!inInput && (e.key === "z" || e.key === "Z")) {
const state = useCanvasStore.getState();
const selectedId = state.selectedNodeId;
if (!selectedId) return;
const hasChildren = state.nodes.some(
(n) => n.data.parentId === selectedId,
);
if (hasChildren) {
window.dispatchEvent(
new CustomEvent("molecule:zoom-to-team", {
detail: { nodeId: selectedId },
}),
);
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
}

View File

@ -252,16 +252,22 @@ export function buildNodesAndEdges(
const pa = absPos.get(ws.parent_id!)!;
position = { x: abs.x - pa.x, y: abs.y - pa.y };
// Trust-the-data default: keep the stored position even if it
// falls outside the parent bbox (matches Figma's "don't move my
// shapes" rule). The one exception is a child still at
// origin (0,0) in the absolute frame — that's almost certainly
// an unlaid-out org-import row and would stack every child on
// the same point. Drop those into the grid so the first paint
// isn't a useless pile. Users can always trigger "Arrange
// children" to rescue the rest.
const atOrigin = abs.x === 0 && abs.y === 0;
if (atOrigin) {
// Auto-rescue on load: if the child's stored relative position
// would render it outside the parent's current bounding box, drop
// it into the next default grid slot. This fixes three real
// failure modes at once: (1) legacy rows written before nesting
// existed, whose absolute coords have no relation to the parent;
// (2) org-imports at (0, 0); (3) a child whose parent was later
// resized smaller. Dragging a child past the edge after load is
// still the way to un-nest — that's handled separately in
// Canvas.onNodeDragStop with the hysteresis check.
const psize = parentSize.get(ws.parent_id!)!;
const outside =
position.x < 0 ||
position.y < 0 ||
position.x + CHILD_DEFAULT_WIDTH > psize.width ||
position.y + CHILD_DEFAULT_HEIGHT > psize.height;
if (outside) {
const idx = nextChildIndex.get(ws.parent_id!) ?? 0;
nextChildIndex.set(ws.parent_id!, idx + 1);
position = defaultChildSlot(idx);

View File

@ -330,11 +330,163 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
await get().nestNode(nodeIds[0], targetId);
return;
}
// Run sequentially so each nestNode's absolute-position calc sees
// the previous update committed. Not a hot path — multi-select
// re-parents rarely touch more than a handful of nodes.
// Batch path: do all state math against one snapshot so every
// selected node sees the same "before" world, commit one set(),
// then fire every PATCH in parallel. Previously this called
// nestNode sequentially, which cost 2N round-trips (parent_id +
// x/y) strictly serialized; now it's 1 round-trip per node, all
// in flight at once. For a typical 3-5 node selection on a
// ~200ms link this drops the perceived re-parent latency from
// ~2s to ~200ms.
const { nodes: before, edges: beforeEdges } = get();
const byId = new Map(before.map((n) => [n.id, n]));
const absOf = (id: string | null | undefined): { x: number; y: number } => {
let sum = { x: 0, y: 0 };
let cursor: string | null | undefined = id;
while (cursor) {
const n = byId.get(cursor);
if (!n) break;
sum = { x: sum.x + n.position.x, y: sum.y + n.position.y };
cursor = n.data.parentId;
}
return sum;
};
const depthOf = (id: string | null | undefined): number => {
let d = 0;
let cursor: string | null | undefined = id;
while (cursor) {
const n = byId.get(cursor);
if (!n) break;
cursor = n.data.parentId;
d += 1;
}
return d;
};
const newParentAbs = absOf(targetId);
const newOwnDepth = targetId ? depthOf(targetId) + 1 : 0;
interface Plan {
id: string;
newRelative: { x: number; y: number };
draggedAbs: { x: number; y: number };
depthDelta: number;
}
const plan: Plan[] = [];
const movedIds = new Set<string>();
// Filter out nodes that would be invalid targets / no-ops.
for (const id of nodeIds) {
await get().nestNode(id, targetId);
const dragged = byId.get(id);
if (!dragged) continue;
const currentParentId = dragged.data.parentId;
if (currentParentId === targetId) continue;
// Can't nest into yourself or your own descendant.
if (targetId && get().isDescendant(id, targetId)) continue;
const oldParentAbs = absOf(currentParentId);
const draggedAbs = {
x: dragged.position.x + oldParentAbs.x,
y: dragged.position.y + oldParentAbs.y,
};
const newRelative = {
x: draggedAbs.x - newParentAbs.x,
y: draggedAbs.y - newParentAbs.y,
};
const oldOwnDepth =
dragged.zIndex ?? depthOf(currentParentId) + (currentParentId ? 1 : 0);
plan.push({
id,
newRelative,
draggedAbs,
depthDelta: newOwnDepth - oldOwnDepth,
});
movedIds.add(id);
// Every descendant of a moved node also shifts by the same delta
// so grandchildren don't fall behind their re-parented ancestor.
const bfs = [id];
while (bfs.length) {
const head = bfs.shift()!;
for (const n of before) {
if (n.data.parentId === head && !movedIds.has(n.id)) {
movedIds.add(n.id);
bfs.push(n.id);
}
}
}
}
if (plan.length === 0) return;
const planById = new Map(plan.map((p) => [p.id, p]));
// One optimistic set() covers every re-parent + every descendant
// zIndex shift; no further state mutations before the PATCHes come
// back (failed PATCHes roll back individual nodes below).
set({
nodes: before.map((n) => {
const p = planById.get(n.id);
if (p) {
return {
...n,
position: p.newRelative,
parentId: targetId ?? undefined,
zIndex: newOwnDepth,
data: { ...n.data, parentId: targetId },
};
}
// Descendant of a moved node — shift zIndex only. Find the
// nearest ancestor in `plan` (walking up parents) to know
// which depthDelta applies.
if (movedIds.has(n.id)) {
let cursor: string | null | undefined = n.data.parentId;
while (cursor) {
const anc = planById.get(cursor);
if (anc) {
if (anc.depthDelta === 0) break;
return { ...n, zIndex: (n.zIndex ?? 0) + anc.depthDelta };
}
cursor = byId.get(cursor)?.data.parentId ?? null;
}
return n;
}
return n;
}),
edges: beforeEdges.filter(
(e) => !movedIds.has(e.source) && !movedIds.has(e.target),
),
});
// Fire every PATCH in parallel. Individual failures roll back just
// that node (others remain committed, matching the single-node
// rollback behaviour in nestNode).
const results = await Promise.allSettled(
plan.map((p) =>
api.patch(`/workspaces/${p.id}`, {
parent_id: targetId,
x: p.draggedAbs.x,
y: p.draggedAbs.y,
}),
),
);
const rolledBack: string[] = [];
for (let i = 0; i < results.length; i++) {
if (results[i].status === "rejected") rolledBack.push(plan[i].id);
}
if (rolledBack.length > 0) {
const rollbackSet = new Set(rolledBack);
set({
nodes: get().nodes.map((n) => {
if (!rollbackSet.has(n.id)) return n;
const original = byId.get(n.id);
if (!original) return n;
return {
...n,
position: original.position,
parentId: original.parentId,
zIndex: original.zIndex,
data: { ...n.data, parentId: original.data.parentId },
};
}),
});
}
},
@ -474,11 +626,16 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
});
try {
await api.patch(`/workspaces/${draggedId}`, { parent_id: targetId });
// Persist absolute position as DB canonical (matches what
// savePosition writes elsewhere); keeps reloads stable regardless
// of which parent the child was under at save time.
await api.patch(`/workspaces/${draggedId}`, { x: draggedAbs.x, y: draggedAbs.y });
// One round-trip per nest: the /workspaces/:id PATCH handler
// accepts parent_id + x + y in a single body. The absolute x/y
// is what the DB stores as canonical (matches savePosition
// elsewhere), so reload renders the same place regardless of
// which parent the child was under at save time.
await api.patch(`/workspaces/${draggedId}`, {
parent_id: targetId,
x: draggedAbs.x,
y: draggedAbs.y,
});
} catch {
set({
nodes: get().nodes.map((n) =>