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