fix(canvas): parent auto-fit sizing + rescue out-of-bounds children

Two playability bugs in the new flat-cards layout:

1. On first load or fresh org import a parent had no explicit width or
   height, so children whose stored position sat inside their (eventual)
   parent's rectangle rendered visually outside the smaller default
   parent box. Compute a parent starting size in canvas-topology:
     • 2-column grid of child-default footprints + header/side padding
     • Grows per child count (2→1 row, 3-4→2 rows, etc.)
   and stamp it onto the Node's width/height so the first paint already
   contains every child.

2. If a child's stored relative position actually falls outside the
   parent's computed bounds (legacy org-imports at 0,0, pre-refactor
   absolute coordinates, manually-nudged rows), assign that child a
   deterministic default grid slot inside the parent instead.

Runtime cascade: added growParentsToFitChildren to onNodesChange so when
the user drags or resizes a child past the parent's current bounds, the
parent grows to contain it (+padding). Miro/FigJam-style frame auto-fit
— grow-only, never shrinks under the user's manual resize.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-23 18:29:04 -07:00
parent cc194f0b7e
commit d359390f83
2 changed files with 159 additions and 4 deletions

View File

@ -5,6 +5,55 @@ import type { WorkspaceNodeData } from "./canvas";
const H_SPACING = 320;
const V_SPACING = 200;
// Default card footprint we use when we don't yet have a measured size
// (first render, before React Flow reports dimensions). These match the
// min-width / min-height that WorkspaceNode.tsx sets, so a parent built
// from them will never start too small for its children on first paint.
export const CHILD_DEFAULT_WIDTH = 260;
export const CHILD_DEFAULT_HEIGHT = 140;
export const PARENT_HEADER_PADDING = 60; // room for the parent's own header
export const PARENT_SIDE_PADDING = 20;
export const PARENT_BOTTOM_PADDING = 20;
export const CHILD_GUTTER = 20;
/**
* A deterministic grid slot for the n-th child inside a parent, counted
* left-to-right then top-to-bottom. Used to lay out org-imported teams
* and to rescue children whose stored position puts them outside the
* parent's bounding box. 2-column grid is wide enough to read but
* narrow enough to keep the parent card from becoming a widescreen.
*/
export function defaultChildSlot(index: number): { x: number; y: number } {
const col = index % 2;
const row = Math.floor(index / 2);
const x = PARENT_SIDE_PADDING + col * (CHILD_DEFAULT_WIDTH + CHILD_GUTTER);
const y =
PARENT_HEADER_PADDING + row * (CHILD_DEFAULT_HEIGHT + CHILD_GUTTER);
return { x, y };
}
/**
* Minimum parent size that still fits `childCount` children laid out via
* defaultChildSlot. Never shrinks below the leaf-card min.
*/
export function parentMinSize(childCount: number): { width: number; height: number } {
if (childCount <= 0) {
return { width: 210, height: 120 };
}
const cols = Math.min(2, childCount);
const rows = Math.ceil(childCount / 2);
const width =
PARENT_SIDE_PADDING * 2 +
cols * CHILD_DEFAULT_WIDTH +
(cols - 1) * CHILD_GUTTER;
const height =
PARENT_HEADER_PADDING +
rows * CHILD_DEFAULT_HEIGHT +
(rows - 1) * CHILD_GUTTER +
PARENT_BOTTOM_PADDING;
return { width, height };
}
/**
* Computes auto-layout positions for workspaces that have no persisted position
* (x === 0 AND y === 0). Workspaces with an existing non-zero position are used
@ -148,6 +197,29 @@ export function buildNodesAndEdges(
absPos.set(ws.id, { x: o?.x ?? ws.x, y: o?.y ?? ws.y });
}
// Count children per parent so we can size parents to fit their team
// before any runtime measurement comes back.
const childCounts = new Map<string, number>();
for (const ws of workspaces) {
if (ws.parent_id) {
childCounts.set(ws.parent_id, (childCounts.get(ws.parent_id) ?? 0) + 1);
}
}
// Track each parent's initial size so we can reset children that land
// outside those bounds. Parents without children fall back to the leaf
// default; parents with children get the grid-derived minimum.
const parentSize = new Map<string, { width: number; height: number }>();
for (const ws of workspaces) {
const n = childCounts.get(ws.id) ?? 0;
parentSize.set(ws.id, n > 0 ? parentMinSize(n) : { width: 260, height: 140 });
}
// Running index of children already placed per parent — used to hand
// out default grid slots for children whose stored position is outside
// the parent's computed box.
const nextChildIndex = new Map<string, number>();
const nodes: Node<WorkspaceNodeData>[] = sorted.map((ws) => {
const abs = absPos.get(ws.id)!;
const hasParent = !!ws.parent_id && byId.has(ws.parent_id);
@ -155,6 +227,24 @@ export function buildNodesAndEdges(
if (hasParent) {
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) {
const idx = nextChildIndex.get(ws.parent_id!) ?? 0;
nextChildIndex.set(ws.parent_id!, idx + 1);
position = defaultChildSlot(idx);
}
}
const node: Node<WorkspaceNodeData> = {
id: ws.id,
@ -186,6 +276,16 @@ export function buildNodesAndEdges(
// onNodeDragStop with a bbox hit test).
node.parentId = ws.parent_id!;
}
// 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
// single child) and children render visually outside their parent
// until the next resize measurement settles.
if ((childCounts.get(ws.id) ?? 0) > 0) {
const size = parentSize.get(ws.id)!;
node.width = size.width;
node.height = size.height;
}
return node;
});

View File

@ -8,7 +8,58 @@ import {
import { api } from "@/lib/api";
import type { WorkspaceData, WSMessage } from "./socket";
import { handleCanvasEvent } from "./canvas-events";
import { buildNodesAndEdges, computeAutoLayout } from "./canvas-topology";
import {
buildNodesAndEdges,
computeAutoLayout,
CHILD_DEFAULT_HEIGHT,
CHILD_DEFAULT_WIDTH,
PARENT_BOTTOM_PADDING,
PARENT_SIDE_PADDING,
} from "./canvas-topology";
/**
* Walk every parent node and bump its width/height (if explicitly set)
* so the union of its children's relative bboxes plus padding fits. A
* parent's size never shrinks via this path only grows because
* shrinking on resize would fight the user's own NodeResizer drag.
*/
function growParentsToFitChildren<T extends Record<string, unknown>>(
nodes: Node<T>[],
): Node<T>[] {
// Index children by parentId so the scan is O(n).
const childrenByParent = new Map<string, Node<T>[]>();
for (const n of nodes) {
if (!n.parentId) continue;
const arr = childrenByParent.get(n.parentId) ?? [];
arr.push(n);
childrenByParent.set(n.parentId, arr);
}
let changed = false;
const out = nodes.map((n) => {
const kids = childrenByParent.get(n.id);
if (!kids || kids.length === 0) return n;
let maxRight = 0;
let maxBottom = 0;
for (const k of kids) {
const w = (k.measured?.width ?? k.width ?? CHILD_DEFAULT_WIDTH) as number;
const h = (k.measured?.height ?? k.height ?? CHILD_DEFAULT_HEIGHT) as number;
maxRight = Math.max(maxRight, k.position.x + w);
maxBottom = Math.max(maxBottom, k.position.y + h);
}
const requiredW = maxRight + PARENT_SIDE_PADDING;
const requiredH = maxBottom + PARENT_BOTTOM_PADDING;
const currentW = (n.measured?.width ?? n.width ?? 0) as number;
const currentH = (n.measured?.height ?? n.height ?? 0) as number;
if (requiredW <= currentW && requiredH <= currentH) return n;
changed = true;
return {
...n,
width: Math.max(currentW, requiredW),
height: Math.max(currentH, requiredH),
};
});
return changed ? out : nodes;
}
// Re-export extracted types and functions so existing imports from "@/store/canvas" keep working
export { summarizeWorkspaceCapabilities } from "./canvas-capabilities";
@ -389,9 +440,13 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
},
onNodesChange: (changes) => {
set({
nodes: applyNodeChanges(changes, get().nodes),
});
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 });
},
savePosition: async (nodeId: string, x: number, y: number) => {