diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index 1934bad1..5d13f00a 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -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: " 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 && ( -
- )} -
- Drop into: {targetName} -
- - ); -} - -/** 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["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["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(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> = 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(); - 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> = 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> = 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>(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>(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
- - + + + { + // 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)?.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} + /> + + + + {/* Screen-reader live region: announces workspace count on canvas load or change */} +
+ {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`} +
+ + {nodes.length === 0 && } + + + + + + + + + + + + {!selectedNodeId && } + + + - setPendingDelete(null)} /> - { - // 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)?.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} - /> - - - {/* Screen-reader live region: announces workspace count when canvas loads or changes */} -
- {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`} -
- - {nodes.length === 0 && } - - - - - - - - - - - - {!selectedNodeId && } - - - {/* Confirmation dialog for structure changes */} - - - {/* Confirmation dialog for workspace delete — driven by store */} - setPendingDelete(null)} - /> - - {/* Settings Panel — global secrets management drawer */} - - + +
); diff --git a/canvas/src/components/__tests__/ZoomShortcut.test.tsx b/canvas/src/components/__tests__/ZoomShortcut.test.tsx index 6b227c0f..85858de9 100644 --- a/canvas/src/components/__tests__/ZoomShortcut.test.tsx +++ b/canvas/src/components/__tests__/ZoomShortcut.test.tsx @@ -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"'); diff --git a/canvas/src/components/canvas/DropTargetBadge.tsx b/canvas/src/components/canvas/DropTargetBadge.tsx new file mode 100644 index 00000000..13c0f7d4 --- /dev/null +++ b/canvas/src/components/canvas/DropTargetBadge.tsx @@ -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: " 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 && ( +
+ )} +
+ Drop into: {targetName} +
+ + ); +} diff --git a/canvas/src/components/canvas/dragUtils.ts b/canvas/src/components/canvas/dragUtils.ts new file mode 100644 index 00000000..a0e5959a --- /dev/null +++ b/canvas/src/components/canvas/dragUtils.ts @@ -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["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, + ), + }); +} diff --git a/canvas/src/components/canvas/useCanvasViewport.ts b/canvas/src/components/canvas/useCanvasViewport.ts new file mode 100644 index 00000000..c7ce9169 --- /dev/null +++ b/canvas/src/components/canvas/useCanvasViewport.ts @@ -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>(undefined); + const panTimerRef = useRef>(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 }; +} diff --git a/canvas/src/components/canvas/useDragHandlers.ts b/canvas/src/components/canvas/useDragHandlers.ts new file mode 100644 index 00000000..ee2b711d --- /dev/null +++ b/canvas/src/components/canvas/useDragHandlers.ts @@ -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>; + onNodeDrag: OnNodeDrag>; + onNodeDragStop: OnNodeDrag>; + 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(null); + const dragModifiersRef = useRef<{ alt: boolean; meta: boolean }>({ + alt: false, + meta: false, + }); + const [pendingNest, setPendingNest] = useState(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(); + 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> = useCallback( + (event, node) => { + dragStartParentRef.current = (node.data as WorkspaceNodeData).parentId; + dragModifiersRef.current = { + alt: event.altKey, + meta: event.metaKey || event.ctrlKey, + }; + }, + [], + ); + + const onNodeDrag: OnNodeDrag> = 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> = 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, + }; +} diff --git a/canvas/src/components/canvas/useKeyboardShortcuts.ts b/canvas/src/components/canvas/useKeyboardShortcuts.ts new file mode 100644 index 00000000..f9f67fd8 --- /dev/null +++ b/canvas/src/components/canvas/useKeyboardShortcuts.ts @@ -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); + }, []); +} diff --git a/canvas/src/store/canvas-topology.ts b/canvas/src/store/canvas-topology.ts index b2841e4e..e66ac438 100644 --- a/canvas/src/store/canvas-topology.ts +++ b/canvas/src/store/canvas-topology.ts @@ -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); diff --git a/canvas/src/store/canvas.ts b/canvas/src/store/canvas.ts index 23098ee1..10213b3a 100644 --- a/canvas/src/store/canvas.ts +++ b/canvas/src/store/canvas.ts @@ -330,11 +330,163 @@ export const useCanvasStore = create((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(); + // 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((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) =>