diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index 4608eb80..d62e3ffe 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -16,6 +16,11 @@ import { 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 { A2ATopologyOverlay } from "./A2ATopologyOverlay"; import { WorkspaceNode } from "./WorkspaceNode"; import { SidePanel } from "./SidePanel"; @@ -58,6 +63,132 @@ 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); @@ -76,25 +207,53 @@ function CanvasInner() { 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) => { + (event, node) => { dragStartParentRef.current = (node.data as WorkspaceNodeData).parentId; + dragModifiersRef.current = { + alt: event.altKey, + meta: event.metaKey || event.ctrlKey, + }; }, - [] + [], ); - // Absolute-bounds hit test: find the deepest workspace whose measured - // bounding box contains `point`, excluding the dragged node itself and - // its descendants. Works regardless of nesting depth because React Flow - // exposes each node's absolute position + measured size on the internal - // node record. "Deepest" wins so dropping a card onto a grand-child lands - // there rather than on the outermost ancestor. + // Absolute-bounds hit test. Returns the **best** drop target among the + // candidates whose measured bbox contains `point`. Tiebreakers, in + // order (matches Figma / tldraw / xyflow issue #2827 community fix): + // + // 1. DEEPEST tree depth first — dropping onto a nested grandchild + // lands on the grandchild, not its outermost ancestor. + // 2. Highest zIndex second — when nested parents overlap with equal + // depth (siblings of each other), the one rendered above wins. + // 3. Smallest area last — visually-tightest match otherwise. + // + // Self + descendants are excluded (can't nest something under itself). const findDropTarget = useCallback( (draggedId: string, point: { x: number; y: number }): string | null => { const all = useCanvasStore.getState().nodes; - let best: { id: string; area: number } | null = null; + // Tree depth for each node — depth = ancestor count. + const depthOf = (id: string | null | undefined): number => { + let d = 0; + let cursor: string | null | undefined = id; + while (cursor) { + const n = all.find((nn) => nn.id === cursor); + if (!n) break; + cursor = n.data.parentId; + d += 1; + } + return d; + }; + 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); @@ -104,17 +263,29 @@ function CanvasInner() { 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 = depthOf(n.id); + const z = n.zIndex ?? 0; const area = w * h; - // Smaller area = deeper/more specific match wins. - if (!best || area < best.area) best = { id: n.id, area }; + if ( + !best || + depth > best.depth || + (depth === best.depth && z > best.zIndex) || + (depth === best.depth && z === best.zIndex && area < best.area) + ) { + best = { id: n.id, depth, zIndex: z, area }; + } } return best?.id ?? null; }, - [getInternalNode, isDescendant] + [getInternalNode, isDescendant], ); const onNodeDrag: OnNodeDrag> = useCallback( - (_event, node) => { + (event, node) => { + dragModifiersRef.current = { + alt: event.altKey, + meta: event.metaKey || event.ctrlKey, + }; const internal = getInternalNode(node.id); if (!internal) { setDragOverNode(null); @@ -126,7 +297,7 @@ function CanvasInner() { const center = { x: abs.x + w / 2, y: abs.y + h / 2 }; setDragOverNode(findDropTarget(node.id, center)); }, - [findDropTarget, getInternalNode, setDragOverNode] + [findDropTarget, getInternalNode, setDragOverNode], ); // Confirmation dialog state for structure changes @@ -157,22 +328,45 @@ function CanvasInner() { : null; const onNodeDragStop: OnNodeDrag> = useCallback( - (_event, node) => { + (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; - // The drag-stop offers three possible intents: - // 1. Drop inside a different parent → nest into that parent. - // 2. Drop onto empty canvas while I was nested → un-nest. - // 3. Drop inside my current parent (or no parent) → just a move. - if (dragOverNodeId && dragOverNodeId !== currentParentId) { + // 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 (!dragOverNodeId && currentParentId) { + } 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 }); @@ -184,16 +378,31 @@ function CanvasInner() { 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] + [getInternalNode, savePosition, setDragOverNode], ); + const batchNest = useCanvasStore((s) => s.batchNest); const confirmNest = useCallback(() => { - if (pendingNest) { + 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]); + setPendingNest(null); + }, [pendingNest, nestNode, batchNest]); const cancelNest = useCallback(() => { setPendingNest(null); @@ -258,6 +467,13 @@ function CanvasInner() { // 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) { @@ -269,16 +485,39 @@ function CanvasInner() { } } + // 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 (e.key === "z" || e.key === "Z") { - const tag = (e.target as HTMLElement).tagName; - if ( - tag === "INPUT" || - tag === "TEXTAREA" || - tag === "SELECT" || - (e.target as HTMLElement).isContentEditable - ) - return; + if (!inInput && (e.key === "z" || e.key === "Z")) { const state = useCanvasStore.getState(); const selectedId = state.selectedNodeId; if (!selectedId) return; @@ -367,6 +606,10 @@ function CanvasInner() { className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20" maskColor="rgba(0, 0, 0, 0.7)" nodeColor={(node) => { + // Parents show as a filled region — hierarchy visible at + // a glance in the minimap without needing to zoom. + const hasChildren = nodes.some((n) => n.parentId === node.id); + if (hasChildren) return "#3b82f6"; const status = (node.data as Record)?.status; switch (status) { case "online": @@ -383,9 +626,14 @@ function CanvasInner() { return "#3f3f46"; } }} - nodeStrokeWidth={0} + 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 */} diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx index d87e62b3..475e8319 100644 --- a/canvas/src/components/ContextMenu.tsx +++ b/canvas/src/components/ContextMenu.tsx @@ -202,15 +202,22 @@ export function ContextMenu() { closeContextMenu(); }, [contextMenu, closeContextMenu]); + const setCollapsed = useCanvasStore((s) => s.setCollapsed); const handleCollapse = useCallback(async () => { if (!contextMenu) return; + const nodeId = contextMenu.nodeId; + const wasCollapsed = !!contextMenu.nodeData.collapsed; + // Optimistic local flip so the card shrinks/expands immediately. + // Descendants' hidden flags are toggled atomically by the store. + setCollapsed(nodeId, !wasCollapsed); try { - await api.post(`/workspaces/${contextMenu.nodeId}/collapse`, {}); + await api.patch(`/workspaces/${nodeId}`, { collapsed: !wasCollapsed }); } catch (e) { + setCollapsed(nodeId, wasCollapsed); showToast("Collapse failed", "error"); } closeContextMenu(); - }, [contextMenu, closeContextMenu]); + }, [contextMenu, setCollapsed, closeContextMenu]); const handleRemoveFromTeam = useCallback(async () => { if (!contextMenu) return; @@ -223,6 +230,13 @@ export function ContextMenu() { closeContextMenu(); }, [contextMenu, nestNode, closeContextMenu]); + const arrangeChildren = useCanvasStore((s) => s.arrangeChildren); + const handleArrangeChildren = useCallback(() => { + if (!contextMenu) return; + arrangeChildren(contextMenu.nodeId); + closeContextMenu(); + }, [contextMenu, arrangeChildren, closeContextMenu]); + const handleZoomToTeam = useCallback(() => { if (!contextMenu) return; window.dispatchEvent( @@ -250,7 +264,12 @@ export function ContextMenu() { : []), ...(hasChildren ? [ - { label: "Collapse Team", icon: "◁", action: handleCollapse }, + { label: "Arrange Children", icon: "▦", action: handleArrangeChildren }, + { + label: contextMenu.nodeData.collapsed ? "Expand Team" : "Collapse Team", + icon: contextMenu.nodeData.collapsed ? "▽" : "◁", + action: handleCollapse, + }, { label: "Zoom to Team", icon: "⊕", action: handleZoomToTeam }, ] : [{ label: "Expand to Team", icon: "▷", action: handleExpand }]), diff --git a/canvas/src/components/WorkspaceNode.tsx b/canvas/src/components/WorkspaceNode.tsx index 9f11cf5d..e915e17d 100644 --- a/canvas/src/components/WorkspaceNode.tsx +++ b/canvas/src/components/WorkspaceNode.tsx @@ -111,7 +111,7 @@ export function WorkspaceNode({ id, data }: NodeProps>) }} className={` group relative rounded-xl h-full w-full - ${hasChildren ? "min-w-[360px] min-h-[200px]" : "min-w-[210px]"} + ${hasChildren && !data.collapsed ? "min-w-[360px] min-h-[200px]" : "min-w-[210px]"} cursor-pointer overflow-hidden transition-all duration-200 ease-out ${isDragTarget diff --git a/canvas/src/store/canvas-topology.ts b/canvas/src/store/canvas-topology.ts index d3367d03..b2841e4e 100644 --- a/canvas/src/store/canvas-topology.ts +++ b/canvas/src/store/canvas-topology.ts @@ -220,6 +220,30 @@ export function buildNodesAndEdges( // the parent's computed box. const nextChildIndex = new Map(); + // Depth per node so children always render above parents (and above + // parent's root-level siblings). React Flow uses a flat zIndex, so a + // child inherits zIndex = parent.zIndex + 1 — xyflow issue #4012. + const depthById = new Map(); + for (const ws of sorted) { + const d = ws.parent_id ? (depthById.get(ws.parent_id) ?? 0) + 1 : 0; + depthById.set(ws.id, d); + } + + // Mark each node as hidden if any ancestor is collapsed. Walk from + // the root so children inherit the flag efficiently. (Parents stay + // visible; only descendants are hidden so the parent renders as a + // compact header-only card.) + const hiddenById = new Map(); + for (const ws of sorted) { + if (!ws.parent_id) { + hiddenById.set(ws.id, false); + continue; + } + const parent = byId.get(ws.parent_id); + const parentHidden = hiddenById.get(ws.parent_id) ?? false; + hiddenById.set(ws.id, parentHidden || !!parent?.collapsed); + } + const nodes: Node[] = sorted.map((ws) => { const abs = absPos.get(ws.id)!; const hasParent = !!ws.parent_id && byId.has(ws.parent_id); @@ -228,19 +252,16 @@ export function buildNodesAndEdges( const pa = absPos.get(ws.parent_id!)!; position = { x: abs.x - pa.x, y: abs.y - pa.y }; - // If the stored relative position falls outside the parent's - // current bounds (or landed at exactly the origin before any - // layout pass), assign a deterministic grid slot instead. This - // rescues org-imported children that ended up at (0,0) and - // legacy rows whose absolute coords were far from the parent. - 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; - const atOrigin = position.x === -abs.x + abs.x && abs.x === 0 && abs.y === 0; - if (outside || atOrigin) { + // 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) { const idx = nextChildIndex.get(ws.parent_id!) ?? 0; nextChildIndex.set(ws.parent_id!, idx + 1); position = defaultChildSlot(idx); @@ -276,6 +297,13 @@ export function buildNodesAndEdges( // onNodeDragStop with a bbox hit test). node.parentId = ws.parent_id!; } + // Stack children above their ancestors (xyflow #4012). + node.zIndex = depthById.get(ws.id) ?? 0; + // Collapse: descendants of a collapsed parent get hidden so the + // parent renders as a compact header-only card. + if (hiddenById.get(ws.id)) { + node.hidden = true; + } // Give parents a measured-ish starting size so NodeResizer has a // baseline and child positions have somewhere to live. Without this, // parents start at React Flow's default min size (well under a diff --git a/canvas/src/store/canvas.ts b/canvas/src/store/canvas.ts index 5107edca..6f843c1c 100644 --- a/canvas/src/store/canvas.ts +++ b/canvas/src/store/canvas.ts @@ -11,6 +11,7 @@ import { handleCanvasEvent } from "./canvas-events"; import { buildNodesAndEdges, computeAutoLayout, + defaultChildSlot, CHILD_DEFAULT_HEIGHT, CHILD_DEFAULT_WIDTH, PARENT_BOTTOM_PADDING, @@ -38,6 +39,10 @@ function growParentsToFitChildren>( const out = nodes.map((n) => { const kids = childrenByParent.get(n.id); if (!kids || kids.length === 0) return n; + // Collapsed parents intentionally render compact — skip the grow + // pass so their size isn't pushed back out by their hidden kids. + const nData = n.data as unknown as WorkspaceNodeData | undefined; + if (nData?.collapsed) return n; let maxRight = 0; let maxBottom = 0; for (const k of kids) { @@ -127,6 +132,28 @@ interface CanvasState { setDragOverNode: (id: string | null) => void; nestNode: (draggedId: string, targetId: string | null) => Promise; isDescendant: (ancestorId: string, nodeId: string) => boolean; + /** Re-order siblings in z-index space. `direction = +1` sends the node + * one step forward among its parent's children (or among canvas + * roots); -1 sends it one step back. Figma Cmd+]/[ parity. */ + bumpZOrder: (nodeId: string, direction: 1 | -1) => void; + /** Re-parent many nodes at once, preserving each node's absolute + * position. Lucidchart pattern: drag a selection into a frame and + * the inter-node layout stays intact. Used when the primary dragged + * node of a multi-select drag triggers a nest confirmation. */ + batchNest: (nodeIds: string[], targetId: string | null) => Promise; + /** Run the parent auto-grow pass once. Canvas.onNodeDragStop calls + * this so a drag that pushed a child past the parent edge commits + * the parent grow on release (commit-on-release pattern). */ + growParentsToFitChildren: () => void; + /** Re-layout a parent's children to the default 2-column grid. Used + * by the "Arrange children" context-menu command so users can rescue + * out-of-bounds children on demand — topology no longer does it + * automatically (P3.12 opt-in rescue). */ + arrangeChildren: (parentId: string) => void; + /** Toggle the collapsed flag on a parent and hide/show every + * descendant so the card renders as a compact header-only frame. + * Miro "frame outline view" analog. */ + setCollapsed: (parentId: string, collapsed: boolean) => void; openContextMenu: (menu: ContextMenuState) => void; closeContextMenu: () => void; // Pending delete confirmation — lives in the store (not inside ContextMenu's @@ -297,6 +324,41 @@ export const useCanvasStore = create((set, get) => ({ setPanelTab: (tab) => set({ panelTab: tab }), setDragOverNode: (id) => set({ dragOverNodeId: id }), + batchNest: async (nodeIds, targetId) => { + if (nodeIds.length === 0) return; + if (nodeIds.length === 1) { + 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. + for (const id of nodeIds) { + await get().nestNode(id, targetId); + } + }, + + bumpZOrder: (nodeId, direction) => { + const { nodes } = get(); + const target = nodes.find((n) => n.id === nodeId); + if (!target) return; + // Siblings = nodes sharing the same parent (null for roots). + const siblings = nodes.filter( + (n) => n.data.parentId === target.data.parentId, + ); + if (siblings.length < 2) return; + // React Flow uses a flat zIndex; we keep children above parents + // (+1 per depth) so any nudge here stays within the sibling tier. + // Reorder in zIndex space by adjusting the target +/- 1. + const current = target.zIndex ?? 0; + const newZ = current + direction; + set({ + nodes: nodes.map((n) => + n.id === nodeId ? { ...n, zIndex: newZ } : n, + ), + }); + }, + isDescendant: (ancestorId, nodeId) => { const { nodes } = get(); let current = nodes.find((n) => n.id === nodeId); @@ -345,6 +407,21 @@ export const useCanvasStore = create((set, get) => ({ (e) => e.source !== draggedId && e.target !== draggedId, ); + // Depth walk so zIndex gets bumped correctly on nest/unnest + // (children render above their new ancestor chain). + const depthOf = (id: string | null | undefined): number => { + let d = 0; + let cursor: string | null | undefined = id; + while (cursor) { + const n = nodes.find((nn) => nn.id === cursor); + if (!n) break; + cursor = n.data.parentId; + d += 1; + } + return d; + }; + const newDepth = depthOf(targetId) + (targetId ? 1 : 0); + set({ nodes: nodes.map((n) => n.id === draggedId @@ -352,6 +429,7 @@ export const useCanvasStore = create((set, get) => ({ ...n, position: newRelative, parentId: targetId ?? undefined, + zIndex: newDepth, data: { ...n.data, parentId: targetId }, } : n, @@ -441,12 +519,74 @@ export const useCanvasStore = create((set, get) => ({ onNodesChange: (changes) => { const next = applyNodeChanges(changes, get().nodes); - // Auto-grow parents to fit their children: if any child's - // (position + size) extends beyond the parent's current dimensions, - // the parent's explicit width/height is bumped so it stays the - // visual container (Miro/FigJam-style frame auto-fit). - const grown = growParentsToFitChildren(next); - set({ nodes: grown }); + // Parent auto-grow is intentionally conservative. Running + // growParentsToFitChildren on every change (including the dozens of + // position updates emitted during a single drag) caused the + // "edge-chase" artifact tldraw documented — as the parent grows in + // response to the child near its edge, the child's relative + // position becomes valid again and the grow stops mid-drag, only to + // resume on the next tick. Commit-on-release: only run grow when a + // change set contains a `dimensions` change (NodeResizer commit), + // not on pure `position` changes. Drag-stop grow is handled + // explicitly in Canvas.onNodeDragStop via growOnce(). + const hasDimensionChange = changes.some((c) => c.type === "dimensions"); + set({ nodes: hasDimensionChange ? growParentsToFitChildren(next) : next }); + }, + + growParentsToFitChildren: () => { + set({ nodes: growParentsToFitChildren(get().nodes) }); + }, + + setCollapsed: (parentId, collapsed) => { + const { nodes } = get(); + // Find all descendant ids via BFS. + const descendantIds = new Set(); + const queue = [parentId]; + while (queue.length) { + const id = queue.shift()!; + for (const n of nodes) { + if (n.data.parentId === id && !descendantIds.has(n.id)) { + descendantIds.add(n.id); + queue.push(n.id); + } + } + } + set({ + nodes: nodes.map((n) => { + if (n.id === parentId) { + return { ...n, data: { ...n.data, collapsed } }; + } + if (descendantIds.has(n.id)) { + return { ...n, hidden: collapsed }; + } + return n; + }), + }); + }, + + arrangeChildren: (parentId) => { + const { nodes } = get(); + const kids = nodes + .filter((n) => n.parentId === parentId) + .sort((a, b) => (a.data.name || "").localeCompare(b.data.name || "")); + if (kids.length === 0) return; + const slotByKid = new Map(); + kids.forEach((k, i) => slotByKid.set(k.id, defaultChildSlot(i))); + set({ + nodes: nodes.map((n) => { + const slot = slotByKid.get(n.id); + return slot ? { ...n, position: slot } : n; + }), + }); + // Persist the new positions so they survive reload. + for (const k of kids) { + const slot = slotByKid.get(k.id)!; + const parent = nodes.find((nn) => nn.id === parentId); + if (!parent) continue; + const absX = slot.x + parent.position.x; + const absY = slot.y + parent.position.y; + api.patch(`/workspaces/${k.id}`, { x: absX, y: absY }).catch(() => {}); + } }, savePosition: async (nodeId: string, x: number, y: number) => {