feat(canvas): industry-pattern playability pass (P1+P2+P3)

Ships the full prioritized improvement list from the canvas research
report — aligns our nesting/resize UX with Miro / FigJam / tldraw / Figma
conventions. Organized by priority below.

## P1 — baseline playability

* Hysteresis on drag-out detach (Miro): a child only un-nests when >=20%
  of its bbox is outside the parent on release. Prevents accidental
  un-nesting from twitchy drags.
* Drop-target now uses tree-depth DESC, then zIndex DESC, then area ASC
  to pick targets when nested parents overlap (xyflow #2827).
* Children render above ancestors by inheriting zIndex = parent + 1 in
  topology and on every nest/unnest (xyflow #4012).
* Live drop-target outline (existing) plus a Mural-style "Drop into:
  <name>" floating badge so colour isn't the only cue.
* growParentsToFitChildren now fires only on dimension-type changes
  inside onNodesChange (NodeResizer commits) and once on drag-stop —
  avoids tldraw's edge-chase artifact (P3.11 commit-on-release).

## P2 — polish

* Whimsical-style ghost preview: dashed outline at the next default
  grid slot inside the drop-target parent during drag.
* Alt-drag escape with soft clamp: dropping slightly outside a parent
  without Alt/Cmd snaps the child back inside (clampChildIntoParent);
  Alt releases the clamp to allow un-nest; Cmd/Ctrl force-detaches.
* Figma-style keyboard hierarchy nav: Enter descends to first child,
  Shift+Enter ascends to parent, Cmd+]/[ re-orders siblings via the
  new bumpZOrder store action.
* Multi-select re-parent preserves offsets: confirmNest routes through
  a new batchNest action when the primary drag is part of a batch
  selection (Lucidchart pattern).

## P3 — long-tail

* Minimap now shows parent cards as filled regions with a blue stroke,
  so hierarchy reads at a glance without zooming.
* Out-of-bounds rescue is opt-in: topology no longer silently re-lays
  children whose stored position is outside the parent bbox (Figma
  trust-the-data). The new Arrange Children context menu item runs the
  rescue on demand via arrangeChildren.
* Cmd-drag force-detach regardless of hysteresis.
* Collapse workspace: the existing Collapse Team action now toggles a
  local setCollapsed store action that hides every descendant and
  shrinks the parent card to header-only (Miro frame outline view).
  Growth pass skips collapsed parents so they don't push back out.

All 910 canvas tests green. Backend untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-23 19:03:02 -07:00
parent d359390f83
commit f3423a513d
5 changed files with 493 additions and 58 deletions

View File

@ -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: <name>" label that tracks the current drag
* target. Mural-style affordance colour alone is ambiguous on dense
* canvases, so we spell out the target by name. Mounted inside the
* ReactFlowProvider subtree so it can read positionAbsolute. */
function DropTargetBadge() {
const dragOverNodeId = useCanvasStore((s) => s.dragOverNodeId);
const targetName = useCanvasStore((s) => {
if (!s.dragOverNodeId) return null;
const n = s.nodes.find((nn) => nn.id === s.dragOverNodeId);
return (n?.data as WorkspaceNodeData | undefined)?.name ?? null;
});
const childCount = useCanvasStore((s) =>
!s.dragOverNodeId
? 0
: s.nodes.filter((n) => n.parentId === s.dragOverNodeId).length,
);
const { getInternalNode, flowToScreenPosition } = useReactFlow();
if (!dragOverNodeId || !targetName) return null;
const internal = getInternalNode(dragOverNodeId);
if (!internal) return null;
const abs = internal.internals.positionAbsolute;
const w = internal.measured?.width ?? 220;
const h = internal.measured?.height ?? 120;
const badge = flowToScreenPosition({ x: abs.x + w / 2, y: abs.y });
// Ghost preview: dashed outline at the next default grid slot inside
// the target parent. Whimsical-style affordance so the user sees
// exactly where the dropped card will land.
const slot = defaultChildSlot(childCount);
const slotTL = flowToScreenPosition({ x: abs.x + slot.x, y: abs.y + slot.y });
const slotBR = flowToScreenPosition({
x: abs.x + slot.x + CHILD_DEFAULT_WIDTH,
y: abs.y + slot.y + CHILD_DEFAULT_HEIGHT,
});
// Clip the ghost to the parent's visible bounds so it doesn't spill
// out when the parent is smaller than the slot.
const parentTL = flowToScreenPosition({ x: abs.x, y: abs.y });
const parentBR = flowToScreenPosition({ x: abs.x + w, y: abs.y + h });
const ghostVisible =
slotBR.x > parentTL.x &&
slotTL.x < parentBR.x &&
slotBR.y > parentTL.y &&
slotTL.y < parentBR.y;
return (
<>
{ghostVisible && (
<div
className="pointer-events-none absolute z-40 rounded-lg border-2 border-dashed border-emerald-400/70 bg-emerald-500/10"
style={{
left: slotTL.x,
top: slotTL.y,
width: slotBR.x - slotTL.x,
height: slotBR.y - slotTL.y,
}}
/>
)}
<div
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-emerald-50 shadow-lg shadow-emerald-950/40"
style={{ left: badge.x, top: badge.y - 6 }}
>
Drop into: {targetName}
</div>
</>
);
}
/** Snap a child back so its bbox is fully inside the parent's bounds.
* Called on drag-stop when the user drifted slightly past the edge
* without holding Alt or Cmd the canvas treats the gesture as a
* plain move rather than an un-nest. */
function clampChildIntoParent(
childId: string,
parentId: string,
getInternalNode: (id: string) => ReturnType<ReturnType<typeof useReactFlow>["getInternalNode"]>,
) {
const c = getInternalNode(childId);
const p = getInternalNode(parentId);
if (!c || !p) return;
const cw = c.measured?.width ?? c.width ?? 220;
const ch = c.measured?.height ?? c.height ?? 120;
const pw = p.measured?.width ?? p.width ?? 220;
const ph = p.measured?.height ?? p.height ?? 120;
const { nodes } = useCanvasStore.getState();
const cur = nodes.find((n) => n.id === childId);
if (!cur) return;
const rel = cur.position;
const clampedX = Math.max(0, Math.min(rel.x, pw - cw));
const clampedY = Math.max(0, Math.min(rel.y, ph - ch));
if (clampedX === rel.x && clampedY === rel.y) return;
useCanvasStore.setState({
nodes: nodes.map((n) =>
n.id === childId ? { ...n, position: { x: clampedX, y: clampedY } } : n,
),
});
}
function shouldDetach(
childId: string,
parentId: string,
getInternalNode: (id: string) => ReturnType<ReturnType<typeof useReactFlow>["getInternalNode"]>,
): boolean {
const c = getInternalNode(childId);
const p = getInternalNode(parentId);
if (!c || !p) return true; // If we can't measure, fall back to the old behavior.
const cw = c.measured?.width ?? c.width ?? 220;
const ch = c.measured?.height ?? c.height ?? 120;
const pw = p.measured?.width ?? p.width ?? 220;
const ph = p.measured?.height ?? p.height ?? 120;
const cx = c.internals.positionAbsolute;
const px = p.internals.positionAbsolute;
const overlapW =
Math.max(0, Math.min(cx.x + cw, px.x + pw) - Math.max(cx.x, px.x));
const overlapH =
Math.max(0, Math.min(cx.y + ch, px.y + ph) - Math.max(cx.y, px.y));
const outsideFractionX = 1 - overlapW / cw;
const outsideFractionY = 1 - overlapH / ch;
return outsideFractionX > DETACH_FRACTION || outsideFractionY > DETACH_FRACTION;
}
function CanvasInner() {
const nodes = useCanvasStore((s) => s.nodes);
const edges = useCanvasStore((s) => s.edges);
@ -76,25 +207,53 @@ function CanvasInner() {
const nestNode = useCanvasStore((s) => s.nestNode);
const isDescendant = useCanvasStore((s) => s.isDescendant);
const dragStartParentRef = useRef<string | null>(null);
const dragModifiersRef = useRef<{ alt: boolean; meta: boolean }>({ alt: false, meta: false });
const { getInternalNode } = useReactFlow();
// Track Alt / Cmd-Meta during the whole drag so onNodeDrag and
// onNodeDragStop see the same modifier state. (React Flow's drag event
// only fires mousemove events — we attach a window-level keyboard
// listener while a drag is in progress.)
const onNodeDragStart: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
(_event, node) => {
(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<Node<WorkspaceNodeData>> = 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<Node<WorkspaceNodeData>> = 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<string, unknown>)?.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}
/>
<DropTargetBadge />
</ReactFlow>
{/* Screen-reader live region: announces workspace count when canvas loads or changes */}

View File

@ -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 }]),

View File

@ -111,7 +111,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
}}
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

View File

@ -220,6 +220,30 @@ export function buildNodesAndEdges(
// the parent's computed box.
const nextChildIndex = new Map<string, number>();
// 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<string, number>();
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<string, boolean>();
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<WorkspaceNodeData>[] = 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

View File

@ -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<T extends Record<string, unknown>>(
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<void>;
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<void>;
/** 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<CanvasState>((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<CanvasState>((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<CanvasState>((set, get) => ({
...n,
position: newRelative,
parentId: targetId ?? undefined,
zIndex: newDepth,
data: { ...n.data, parentId: targetId },
}
: n,
@ -441,12 +519,74 @@ export const useCanvasStore = create<CanvasState>((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<string>();
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<string, { x: number; y: number }>();
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) => {