From 50b537849ab53f2212b631c25aa0c5d30c7a4f2c Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 23 Apr 2026 19:43:18 -0700 Subject: [PATCH] refactor(canvas): split Canvas.tsx into hooks; parallelize batchNest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: " 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) --- canvas/src/components/Canvas.tsx | 700 ++++-------------- .../__tests__/ZoomShortcut.test.tsx | 7 +- .../src/components/canvas/DropTargetBadge.tsx | 83 +++ canvas/src/components/canvas/dragUtils.ts | 74 ++ .../components/canvas/useCanvasViewport.ts | 96 +++ .../src/components/canvas/useDragHandlers.ts | 213 ++++++ .../components/canvas/useKeyboardShortcuts.ts | 87 +++ canvas/src/store/canvas-topology.ts | 26 +- canvas/src/store/canvas.ts | 175 ++++- 9 files changed, 873 insertions(+), 588 deletions(-) create mode 100644 canvas/src/components/canvas/DropTargetBadge.tsx create mode 100644 canvas/src/components/canvas/dragUtils.ts create mode 100644 canvas/src/components/canvas/useCanvasViewport.ts create mode 100644 canvas/src/components/canvas/useDragHandlers.ts create mode 100644 canvas/src/components/canvas/useKeyboardShortcuts.ts 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) =>