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) { 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) { 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(); 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) { 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(); 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(); 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) { 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(); const ids = new Set([node.id]); descendantNodeIds(nodes, node.id).forEach((id) => ids.add(id)); return ids; } export function toggleSetValue(current: Set, 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, 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 | ((transform: NodeTransform) => Partial)) { 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) { 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, 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, 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(); 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, 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); }