forked from molecule-ai/molecule-core
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:
parent
d359390f83
commit
f3423a513d
@ -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 */}
|
||||
|
||||
@ -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 }]),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user