a1503a1687
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>
432 lines
18 KiB
TypeScript
432 lines
18 KiB
TypeScript
import type { CanvasNode } from "@/domain/design";
|
|
import { isBooleanShapeGroupNode, isBooleanShapeOperandNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
|
|
import type { FrameStylePatch } from "./types";
|
|
import { nodeBounds, normalizeRotation, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle } from "./canvasGeometry";
|
|
|
|
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));
|
|
}
|
|
|
|
export function updateNodeContent(nodes: CanvasNode[], nodeId: string, content: string) {
|
|
return nodes.map((node) => (node.id === nodeId ? { ...node, content } : node));
|
|
}
|
|
|
|
export function moveNodesByDelta(nodes: CanvasNode[], startPositions: NodePosition[], dx: number, dy: number) {
|
|
const positions = new Map(startPositions.map((position) => [position.id, position]));
|
|
return nodes.map((node) => {
|
|
const position = positions.get(node.id);
|
|
return position ? { ...node, x: position.x + dx, y: position.y + dy } : node;
|
|
});
|
|
}
|
|
|
|
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;
|
|
const verticalDirection = handle.includes("bottom") ? 1 : handle.includes("top") ? -1 : 0;
|
|
const visual = node.type === "image" || node.type === "frame";
|
|
const penStroke = isCanvasPenStrokeNode(node);
|
|
const mockupPrint = node.layerRole === "mockup-print";
|
|
const minWidth = penStroke || mockupPrint ? 8 : visual ? 96 : 140;
|
|
const minHeight = penStroke || mockupPrint ? 8 : visual ? Math.max(96, minWidth / aspect) : 80;
|
|
const widthFromPointer = horizontalDirection === 0 ? node.width : node.width + pointerDelta.dx * horizontalDirection;
|
|
const heightFromPointer = verticalDirection === 0 ? node.height : node.height + pointerDelta.dy * verticalDirection;
|
|
let nextWidth = Math.max(minWidth, widthFromPointer);
|
|
let nextHeight = Math.max(minHeight, heightFromPointer);
|
|
let nextX = horizontalDirection < 0 ? node.x + node.width - nextWidth : node.x;
|
|
let nextY = verticalDirection < 0 ? node.y + node.height - nextHeight : node.y;
|
|
|
|
if (visual) {
|
|
const widthScale = widthFromPointer / node.width;
|
|
const heightScale = heightFromPointer / node.height;
|
|
const chosenScale =
|
|
horizontalDirection !== 0 && verticalDirection !== 0
|
|
? Math.abs(widthScale - 1) > Math.abs(heightScale - 1)
|
|
? widthScale
|
|
: heightScale
|
|
: horizontalDirection !== 0
|
|
? widthScale
|
|
: heightScale;
|
|
const nextScale = Math.max(minWidth / node.width, minHeight / node.height, chosenScale);
|
|
nextWidth = node.width * nextScale;
|
|
nextHeight = node.height * nextScale;
|
|
nextX = horizontalDirection < 0 ? node.x + node.width - nextWidth : horizontalDirection > 0 ? node.x : node.x + (node.width - nextWidth) / 2;
|
|
nextY = verticalDirection < 0 ? node.y + node.height - nextHeight : verticalDirection > 0 ? node.y : node.y + (node.height - nextHeight) / 2;
|
|
}
|
|
|
|
return { ...node, x: nextX, y: nextY, width: nextWidth, height: nextHeight };
|
|
}
|
|
|
|
export function removeNodeById(nodes: CanvasNode[], nodeId: string) {
|
|
return nodes.filter((node) => node.id !== nodeId);
|
|
}
|
|
|
|
export function removeNodesById(nodes: CanvasNode[], nodeIds: Set<string>) {
|
|
return nodes.filter((node) => !nodeIds.has(node.id));
|
|
}
|
|
|
|
export function cloneCanvasNode(node: CanvasNode, id: string, offset = 38): CanvasNode {
|
|
return {
|
|
...node,
|
|
id,
|
|
title: `${node.title} copy`,
|
|
x: node.x + offset,
|
|
y: node.y + offset,
|
|
status: node.status === "generating" ? "draft" : node.status
|
|
};
|
|
}
|
|
|
|
export function reorderNode(nodes: CanvasNode[], nodeId: string, action: NodeReorderAction) {
|
|
const index = nodes.findIndex((node) => node.id === nodeId);
|
|
if (index < 0) return nodes;
|
|
const next = [...nodes];
|
|
const [node] = next.splice(index, 1);
|
|
if (action === "front") next.push(node);
|
|
if (action === "back") next.unshift(node);
|
|
if (action === "forward") next.splice(Math.min(index + 1, next.length), 0, node);
|
|
if (action === "backward") next.splice(Math.max(index - 1, 0), 0, node);
|
|
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)) {
|
|
next.delete(value);
|
|
} else {
|
|
next.add(value);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
export function toggleSetValues(current: Set<string>, values: string[]) {
|
|
const next = new Set(current);
|
|
const allEnabled = values.every((value) => next.has(value));
|
|
values.forEach((value) => {
|
|
if (allEnabled) {
|
|
next.delete(value);
|
|
} else {
|
|
next.add(value);
|
|
}
|
|
});
|
|
return next;
|
|
}
|
|
|
|
export function patchFrameStyleNode(node: CanvasNode, patch: FrameStylePatch): CanvasNode {
|
|
if (node.type !== "frame") return node;
|
|
const width = patch.width ?? node.width;
|
|
const height = patch.height ?? node.height;
|
|
return {
|
|
...node,
|
|
x: patch.width === undefined ? node.x : node.x + (node.width - width) / 2,
|
|
y: patch.height === undefined ? node.y : node.y + (node.height - height) / 2,
|
|
fillColor: patch.fillColor ?? node.fillColor,
|
|
strokeColor: patch.strokeColor ?? node.strokeColor,
|
|
strokeWidth: patch.strokeWidth ?? node.strokeWidth,
|
|
strokeStyle: patch.strokeStyle ?? node.strokeStyle,
|
|
opacity: patch.opacity === undefined ? node.opacity : patch.opacity / 100,
|
|
width,
|
|
height
|
|
};
|
|
}
|
|
|
|
export function updateNodeTransform(nodes: CanvasNode[], nodeId: string, patch: Partial<NodeTransform> | ((transform: NodeTransform) => Partial<NodeTransform>)) {
|
|
return nodes.map((node) => {
|
|
if (node.id !== nodeId) return node;
|
|
const transform: NodeTransform = {
|
|
flipX: Boolean(node.flipX),
|
|
flipY: Boolean(node.flipY),
|
|
rotation: node.rotation ?? 0
|
|
};
|
|
const nextPatch = typeof patch === "function" ? patch(transform) : patch;
|
|
return {
|
|
...node,
|
|
flipX: nextPatch.flipX ?? transform.flipX,
|
|
flipY: nextPatch.flipY ?? transform.flipY,
|
|
rotation: normalizeRotation(nextPatch.rotation ?? transform.rotation)
|
|
};
|
|
});
|
|
}
|
|
|
|
export function autoLayoutAroundNode(nodes: CanvasNode[], anchorNode: CanvasNode) {
|
|
const gap = 34;
|
|
const others = nodes.filter((node) => node.id !== anchorNode.id);
|
|
return [
|
|
anchorNode,
|
|
...others.map((node, index) => ({
|
|
...node,
|
|
x: anchorNode.x + anchorNode.width + gap + (index % 2) * (node.width + gap),
|
|
y: anchorNode.y + Math.floor(index / 2) * (node.height + gap)
|
|
}))
|
|
];
|
|
}
|
|
|
|
export function autoLayoutNodes(nodes: CanvasNode[], selectedIds: Set<string>) {
|
|
const selected = nodes.filter((node) => selectedIds.has(node.id));
|
|
if (selected.length < 2) return nodes;
|
|
const bounds = nodeBounds(selected);
|
|
const gap = 28;
|
|
const columns = Math.ceil(Math.sqrt(selected.length));
|
|
const maxWidth = Math.max(...selected.map((node) => node.width));
|
|
const maxHeight = Math.max(...selected.map((node) => node.height));
|
|
return nodes.map((node) => {
|
|
const index = selected.findIndex((item) => item.id === node.id);
|
|
if (index < 0) return node;
|
|
return {
|
|
...node,
|
|
x: bounds.x + (index % columns) * (maxWidth + gap),
|
|
y: bounds.y + Math.floor(index / columns) * (maxHeight + gap)
|
|
};
|
|
});
|
|
}
|
|
|
|
export function alignNodes(nodes: CanvasNode[], selectedIds: Set<string>, alignment: NodeAlignment) {
|
|
const selected = nodes.filter((node) => selectedIds.has(node.id));
|
|
if (selected.length < 2) return nodes;
|
|
const bounds = nodeBounds(selected);
|
|
return nodes.map((node) => {
|
|
if (!selectedIds.has(node.id)) return node;
|
|
if (alignment === "left") return { ...node, x: bounds.x };
|
|
if (alignment === "center") return { ...node, x: bounds.x + bounds.width / 2 - node.width / 2 };
|
|
if (alignment === "right") return { ...node, x: bounds.x + bounds.width - node.width };
|
|
if (alignment === "top") return { ...node, y: bounds.y };
|
|
if (alignment === "middle") return { ...node, y: bounds.y + bounds.height / 2 - node.height / 2 };
|
|
return { ...node, y: bounds.y + bounds.height - node.height };
|
|
});
|
|
}
|
|
|
|
export function distributeNodes(nodes: CanvasNode[], selectedIds: Set<string>, direction: NodeDistribution) {
|
|
const selected = nodes.filter((node) => selectedIds.has(node.id));
|
|
if (selected.length < 3) return nodes;
|
|
const bounds = nodeBounds(selected);
|
|
const sorted = [...selected].sort((a, b) => (direction === "horizontal" ? a.x - b.x : a.y - b.y));
|
|
const positions = new Map<string, number>();
|
|
|
|
if (direction === "horizontal") {
|
|
const totalWidth = sorted.reduce((sum, node) => sum + node.width, 0);
|
|
const gap = Math.max(0, (bounds.width - totalWidth) / (sorted.length - 1));
|
|
let nextX = bounds.x;
|
|
sorted.forEach((node) => {
|
|
positions.set(node.id, nextX);
|
|
nextX += node.width + gap;
|
|
});
|
|
} else {
|
|
const totalHeight = sorted.reduce((sum, node) => sum + node.height, 0);
|
|
const gap = Math.max(0, (bounds.height - totalHeight) / (sorted.length - 1));
|
|
let nextY = bounds.y;
|
|
sorted.forEach((node) => {
|
|
positions.set(node.id, nextY);
|
|
nextY += node.height + gap;
|
|
});
|
|
}
|
|
|
|
return nodes.map((node) => {
|
|
const position = positions.get(node.id);
|
|
if (position === undefined) return node;
|
|
return direction === "horizontal" ? { ...node, x: position } : { ...node, y: position };
|
|
});
|
|
}
|
|
|
|
export function groupNodes(nodes: CanvasNode[], selectedIds: Set<string>, groupId: string) {
|
|
return nodes.map((node) => (selectedIds.has(node.id) ? { ...node, parentId: groupId } : node));
|
|
}
|
|
|
|
export function ungroupNodes(nodes: CanvasNode[], groupId: string) {
|
|
return nodes.map((node) => (node.parentId === groupId ? { ...node, parentId: undefined } : node));
|
|
}
|
|
|
|
export function isImageUrl(value: string) {
|
|
return /^(https?:\/\/|data:image\/|blob:|\/)/.test(value.trim());
|
|
}
|
|
|
|
export function isRealCanvasImageNode(node: CanvasNode) {
|
|
return node.type === "image" && node.layerRole !== "pen-stroke" && node.layerRole !== "image-generator" && node.status !== "generating" && node.status !== "error" && isImageUrl(node.content);
|
|
}
|
|
|
|
export function isCanvasFrameNode(node: CanvasNode) {
|
|
return node.type === "frame" && node.status !== "generating" && node.status !== "error";
|
|
}
|
|
|
|
export function isCanvasPenStrokeNode(node: CanvasNode) {
|
|
return node.type === "image" && node.layerRole === "pen-stroke" && node.status !== "generating" && node.status !== "error" && isImageUrl(node.content);
|
|
}
|
|
|
|
export function canUseNodeContextMenu(node: CanvasNode) {
|
|
return isRealCanvasImageNode(node) || isCanvasPenStrokeNode(node) || node.type === "text" || isCanvasFrameNode(node);
|
|
}
|
|
|
|
export function canResizeCanvasNode(node: CanvasNode) {
|
|
if (isBooleanShapeGroupNode(node)) return true;
|
|
if (node.type !== "image") return true;
|
|
return isRealCanvasImageNode(node) || isCanvasPenStrokeNode(node);
|
|
}
|