feat(canvas): frame-based layer separation UI
Render and manage the separated layers produced by the edit-elements action as a manual-frame container with attached image and text children. - canvasNodeOps: frame container/child helpers, render ordering, auto-attach nodes to containing frames, and layer reordering - CanvasWorkspace wires frame-aware selection, drag, resize, hide/lock, and layer reorder through the new ops - CanvasPanels: expandable layer tree with rename/reorder - designGateway.submitGeneratorTask + GeneratorTaskSubmitResponse and bbox/generator_name artifact metadata in the domain - ArtboardPreview shows a working state for generating frame layers - i18n: expandLayer / renameLayer / layerTitlePlaceholder; styles Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,13 @@ import { nodeBounds, normalizeRotation, type NodeAlignment, type NodeDistributio
|
||||
|
||||
export type NodeReorderAction = "forward" | "backward" | "front" | "back";
|
||||
export type NodePosition = { id: string; x: number; y: number };
|
||||
export type LayerReorderItem = { kind: "node" | "group"; id: string };
|
||||
export type LayerReorderPlacement = "before" | "after";
|
||||
export type LayerReorderRequest = {
|
||||
active: LayerReorderItem;
|
||||
over: LayerReorderItem;
|
||||
placement: LayerReorderPlacement;
|
||||
};
|
||||
|
||||
export function visibleCanvasNodes(nodes: CanvasNode[], hiddenNodeIds: Set<string>) {
|
||||
return nodes.filter((node) => !hiddenNodeIds.has(node.id) && !isBooleanShapeOperandNode(node, nodes));
|
||||
@@ -22,6 +29,129 @@ export function moveNodesByDelta(nodes: CanvasNode[], startPositions: NodePositi
|
||||
});
|
||||
}
|
||||
|
||||
export function isFrameContainerNode(node: CanvasNode) {
|
||||
return node.type === "frame" && node.layerRole === "manual-frame";
|
||||
}
|
||||
|
||||
export function isFrameChildNode(node: CanvasNode, nodes: CanvasNode[]) {
|
||||
if (!node.parentId) return false;
|
||||
const parent = nodes.find((item) => item.id === node.parentId);
|
||||
return Boolean(parent && isFrameContainerNode(parent));
|
||||
}
|
||||
|
||||
export function nodeIdsWithFrameChildren(nodes: CanvasNode[], nodeIds: Set<string>) {
|
||||
const next = new Set(nodeIds);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
nodes.forEach((node) => {
|
||||
if (!node.parentId || next.has(node.id) || !next.has(node.parentId)) return;
|
||||
const parent = nodes.find((item) => item.id === node.parentId);
|
||||
if (!parent || !isFrameContainerNode(parent)) return;
|
||||
next.add(node.id);
|
||||
changed = true;
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function orderedCanvasNodesForRender(nodes: CanvasNode[]) {
|
||||
const emitted = new Set<string>();
|
||||
const childrenByParentId = childNodesByParentId(nodes);
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const ordered: CanvasNode[] = [];
|
||||
|
||||
const emitNode = (node: CanvasNode) => {
|
||||
if (emitted.has(node.id)) return;
|
||||
emitted.add(node.id);
|
||||
ordered.push(node);
|
||||
if (!isFrameContainerNode(node)) return;
|
||||
(childrenByParentId.get(node.id) ?? []).forEach(emitNode);
|
||||
};
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (emitted.has(node.id)) return;
|
||||
const parent = node.parentId ? nodeById.get(node.parentId) : null;
|
||||
if (parent && isFrameContainerNode(parent)) return;
|
||||
emitNode(node);
|
||||
});
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function attachNodesToContainingFrames(nodes: CanvasNode[], candidateIds: Set<string>) {
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const frames = nodes.filter(isFrameContainerNode);
|
||||
if (frames.length === 0 || candidateIds.size === 0) return nodes;
|
||||
|
||||
return nodes.map((node) => {
|
||||
if (!candidateIds.has(node.id) || node.layerRole === "clean-image" || isFrameContainerNode(node) || isBooleanShapeGroupNode(node) || isBooleanShapeOperandNode(node, nodes)) return node;
|
||||
const parent = node.parentId ? nodeById.get(node.parentId) : null;
|
||||
if (parent && !isFrameContainerNode(parent)) return node;
|
||||
if (parent && candidateIds.has(parent.id) && nodeCenterInsideFrame(node, parent)) return node;
|
||||
const containingFrame = [...frames].reverse().find((frame) => frame.id !== node.id && nodeCenterInsideFrame(node, frame));
|
||||
const nextParentId = containingFrame?.id;
|
||||
if ((node.parentId || undefined) === nextParentId) return node;
|
||||
return { ...node, parentId: nextParentId };
|
||||
});
|
||||
}
|
||||
|
||||
function nodeCenterInsideFrame(node: CanvasNode, frame: CanvasNode) {
|
||||
const centerX = node.x + node.width / 2;
|
||||
const centerY = node.y + node.height / 2;
|
||||
return centerX >= frame.x && centerX <= frame.x + frame.width && centerY >= frame.y && centerY <= frame.y + frame.height;
|
||||
}
|
||||
|
||||
function childNodesByParentId(nodes: CanvasNode[]) {
|
||||
const children = new Map<string, CanvasNode[]>();
|
||||
nodes.forEach((node) => {
|
||||
if (!node.parentId) return;
|
||||
children.set(node.parentId, [...(children.get(node.parentId) ?? []), node]);
|
||||
});
|
||||
return children;
|
||||
}
|
||||
|
||||
function descendantNodeIds(nodes: CanvasNode[], parentId: string) {
|
||||
const children = childNodesByParentId(nodes);
|
||||
const ids = new Set<string>();
|
||||
const visit = (id: string) => {
|
||||
(children.get(id) ?? []).forEach((child) => {
|
||||
if (ids.has(child.id)) return;
|
||||
ids.add(child.id);
|
||||
visit(child.id);
|
||||
});
|
||||
};
|
||||
visit(parentId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function resizeFrameWithChildren(nodes: CanvasNode[], frameId: string, nextFrame: CanvasNode) {
|
||||
const frame = nodes.find((node) => node.id === frameId);
|
||||
if (!frame || !isFrameContainerNode(frame)) return nodes.map((node) => (node.id === frameId ? nextFrame : node));
|
||||
const childIds = descendantNodeIds(nodes, frameId);
|
||||
const scaleX = nextFrame.width / Math.max(frame.width, 1);
|
||||
const scaleY = nextFrame.height / Math.max(frame.height, 1);
|
||||
const textScale = Math.max(0.01, Math.sqrt(Math.max(scaleX, 0.01) * Math.max(scaleY, 0.01)));
|
||||
|
||||
return nodes.map((node) => {
|
||||
if (node.id === frameId) return nextFrame;
|
||||
if (!childIds.has(node.id)) return node;
|
||||
const nextNode: CanvasNode = {
|
||||
...node,
|
||||
x: nextFrame.x + (node.x - frame.x) * scaleX,
|
||||
y: nextFrame.y + (node.y - frame.y) * scaleY,
|
||||
width: Math.max(1, node.width * scaleX),
|
||||
height: Math.max(1, node.height * scaleY)
|
||||
};
|
||||
if (node.type === "text") {
|
||||
nextNode.fontSize = node.fontSize ? Math.max(8, node.fontSize * textScale) : node.fontSize;
|
||||
nextNode.letterSpacing = node.letterSpacing ? node.letterSpacing * textScale : node.letterSpacing;
|
||||
nextNode.strokeWidth = node.strokeWidth ? node.strokeWidth * textScale : node.strokeWidth;
|
||||
}
|
||||
return nextNode;
|
||||
});
|
||||
}
|
||||
|
||||
export function resizeVisualNodeByHandle(node: CanvasNode, handle: ResizeHandle, pointerDelta: { dx: number; dy: number }) {
|
||||
const aspect = node.width / Math.max(node.height, 1);
|
||||
const horizontalDirection = handle.includes("right") ? 1 : handle.includes("left") ? -1 : 0;
|
||||
@@ -90,6 +220,44 @@ export function reorderNode(nodes: CanvasNode[], nodeId: string, action: NodeReo
|
||||
return next;
|
||||
}
|
||||
|
||||
export function reorderLayer(nodes: CanvasNode[], request: LayerReorderRequest) {
|
||||
const activeContext = layerReorderContext(nodes, request.active);
|
||||
const overContext = layerReorderContext(nodes, request.over);
|
||||
if (!activeContext || !overContext || activeContext !== overContext) return nodes;
|
||||
|
||||
const activeIds = layerReorderItemNodeIds(nodes, request.active);
|
||||
const overIds = layerReorderItemNodeIds(nodes, request.over);
|
||||
if (activeIds.size === 0 || overIds.size === 0) return nodes;
|
||||
if ([...overIds].some((id) => activeIds.has(id))) return nodes;
|
||||
|
||||
const movingNodes = nodes.filter((node) => activeIds.has(node.id));
|
||||
const remainingNodes = nodes.filter((node) => !activeIds.has(node.id));
|
||||
const overIndexes = remainingNodes.map((node, index) => (overIds.has(node.id) ? index : -1)).filter((index) => index >= 0);
|
||||
if (overIndexes.length === 0) return nodes;
|
||||
|
||||
const insertIndex = request.placement === "before" ? Math.max(...overIndexes) + 1 : Math.min(...overIndexes);
|
||||
return [...remainingNodes.slice(0, insertIndex), ...movingNodes, ...remainingNodes.slice(insertIndex)];
|
||||
}
|
||||
|
||||
function layerReorderContext(nodes: CanvasNode[], item: LayerReorderItem) {
|
||||
if (item.kind === "group") return "root";
|
||||
const node = nodes.find((candidate) => candidate.id === item.id);
|
||||
if (!node) return null;
|
||||
if (!node.parentId) return "root";
|
||||
return node.parentId;
|
||||
}
|
||||
|
||||
function layerReorderItemNodeIds(nodes: CanvasNode[], item: LayerReorderItem) {
|
||||
if (item.kind === "group") {
|
||||
return new Set(nodes.filter((node) => node.parentId === item.id).map((node) => node.id));
|
||||
}
|
||||
const node = nodes.find((candidate) => candidate.id === item.id);
|
||||
if (!node) return new Set<string>();
|
||||
const ids = new Set([node.id]);
|
||||
descendantNodeIds(nodes, node.id).forEach((id) => ids.add(id));
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function toggleSetValue(current: Set<string>, value: string) {
|
||||
const next = new Set(current);
|
||||
if (next.has(value)) {
|
||||
|
||||
Reference in New Issue
Block a user