export type CanvasGroupingNode = { draftValues?: Record; height: number; id: string; title?: string; width: number; x: number; y: number; }; export type CanvasGroupingEdge = { id: string; source: string; target: string; }; export const CANVAS_GROUP_PADDING = { bottom: 20, left: 20, right: 20, top: 56, } as const; function readStringArray(value: unknown) { if (!Array.isArray(value)) return []; return value .map((item) => (typeof item === "string" ? item.trim() : "")) .filter(Boolean); } export function isCanvasGroupNode(node: CanvasGroupingNode | null | undefined) { const action = node?.draftValues?.action; return ( node?.draftValues?.nodeType === "group" || action === "CANVAS_GROUP" || action === "CANVAS_STORYBOARD_GROUP" || action === "TOOLBOX_TEMPLATE_GROUP" ); } export function isCanvasStoryboardGroupNode( node: CanvasGroupingNode | null | undefined, ) { if (!isCanvasGroupNode(node)) return false; const draftValues = node?.draftValues; const title = node?.title?.trim() || ""; return ( draftValues?.action === "CANVAS_STORYBOARD_GROUP" || draftValues?.groupType === "storyboard" || draftValues?.storyboardGroup === true || /^分镜图(?:\s*·|\s|$)/u.test(title) ); } export function getCanvasGroupChildIds(node: CanvasGroupingNode | null | undefined) { return readStringArray(node?.draftValues?.childNodeIds); } export function getCanvasGroupBounds(nodes: CanvasGroupingNode[]) { if (nodes.length === 0) return null; const minX = Math.min(...nodes.map((node) => node.x)); const minY = Math.min(...nodes.map((node) => node.y)); const maxX = Math.max(...nodes.map((node) => node.x + node.width)); const maxY = Math.max(...nodes.map((node) => node.y + node.height)); return { height: maxY - minY + CANVAS_GROUP_PADDING.top + CANVAS_GROUP_PADDING.bottom, width: maxX - minX + CANVAS_GROUP_PADDING.left + CANVAS_GROUP_PADDING.right, x: minX - CANVAS_GROUP_PADDING.left, y: minY - CANVAS_GROUP_PADDING.top, }; } export function attachCanvasNodesToGroup( nodes: T[], groupId: string, childNodeIds: string[], ) { const childIds = new Set(childNodeIds); return nodes.map((node) => { if (node.id === groupId) { return { ...node, draftValues: { ...(node.draftValues || {}), childNodeIds: [...childIds], }, }; } if (!childIds.has(node.id)) return node; return { ...node, draftValues: { ...(node.draftValues || {}), parentGroupId: groupId, }, }; }); } export function collectCanvasGroupDescendantIds( nodes: CanvasGroupingNode[], groupIds: string[], ) { const nodeById = new Map(nodes.map((node) => [node.id, node])); const descendants = new Set(); const pending = [...groupIds]; while (pending.length > 0) { const groupId = pending.pop(); if (!groupId) continue; const group = nodeById.get(groupId); for (const childId of getCanvasGroupChildIds(group)) { if (descendants.has(childId)) continue; descendants.add(childId); if (isCanvasGroupNode(nodeById.get(childId))) pending.push(childId); } } return [...descendants]; } export function expandCanvasGroupSelection( nodes: CanvasGroupingNode[], selectedNodeIds: string[], ) { const expanded = new Set(selectedNodeIds); const selectedGroupIds = nodes .filter((node) => expanded.has(node.id) && isCanvasGroupNode(node)) .map((node) => node.id); for (const childId of collectCanvasGroupDescendantIds(nodes, selectedGroupIds)) { expanded.add(childId); } return [...expanded]; } function getCanvasCopyTitle(title: string) { return title.endsWith(" - 副本") ? title : `${title} - 副本`; } export function duplicateCanvasNodeSelection< TNode extends CanvasGroupingNode & { title: string }, TEdge extends CanvasGroupingEdge, >({ createEdgeId, createNodeId, edges, includeInternalEdges, nodes, offsetX = 0, offsetY = 0, selectedNodeIds, }: { createEdgeId: () => string; createNodeId: () => string; edges: TEdge[]; includeInternalEdges: boolean; nodes: TNode[]; offsetX?: number; offsetY?: number; selectedNodeIds: string[]; }) { const selectedNodeIdSet = new Set(selectedNodeIds); const selectedGroupIds = nodes .filter( (node) => selectedNodeIdSet.has(node.id) && isCanvasGroupNode(node), ) .map((node) => node.id); const selectedGroupDescendantIds = new Set( collectCanvasGroupDescendantIds(nodes, selectedGroupIds), ); const sourceNodeIdSet = new Set( expandCanvasGroupSelection(nodes, selectedNodeIds), ); const sourceNodes = nodes.filter((node) => sourceNodeIdSet.has(node.id)); if (sourceNodes.length === 0) return null; const rootNodeIds = selectedNodeIds.filter( (nodeId) => sourceNodeIdSet.has(nodeId) && !selectedGroupDescendantIds.has(nodeId), ); const rootNodeIdSet = new Set(rootNodeIds); const idMap = new Map( sourceNodes.map((sourceNode) => [sourceNode.id, createNodeId()]), ); const duplicateNodes = sourceNodes.map((node) => { const draftValues = { ...(node.draftValues || {}) }; const childNodeIds = getCanvasGroupChildIds(node); if (childNodeIds.length > 0) { draftValues.childNodeIds = childNodeIds .map((childNodeId) => idMap.get(childNodeId)) .filter((childNodeId): childNodeId is string => Boolean(childNodeId)); } const parentGroupId = typeof draftValues.parentGroupId === "string" ? draftValues.parentGroupId : ""; if (parentGroupId && idMap.has(parentGroupId)) { draftValues.parentGroupId = idMap.get(parentGroupId); } else { delete draftValues.parentGroupId; } return { ...node, draftValues, id: idMap.get(node.id) || createNodeId(), title: rootNodeIdSet.has(node.id) ? getCanvasCopyTitle(node.title) : node.title, x: node.x + offsetX, y: node.y + offsetY, }; }); const duplicateEdges = includeInternalEdges ? edges .filter( (edge) => sourceNodeIdSet.has(edge.source) && sourceNodeIdSet.has(edge.target), ) .map((edge) => ({ ...edge, id: createEdgeId(), source: idMap.get(edge.source) || edge.source, target: idMap.get(edge.target) || edge.target, })) : []; return { duplicateEdges, duplicateNodes, rootNodeIds: rootNodeIds .map((nodeId) => idMap.get(nodeId)) .filter((nodeId): nodeId is string => Boolean(nodeId)), }; } export function duplicateCanvasNodeTree< TNode extends CanvasGroupingNode & { title: string }, TEdge extends CanvasGroupingEdge, >({ createEdgeId, createNodeId, edges, nodes, offsetX = 0, offsetY = 0, rootNodeId, }: { createEdgeId: () => string; createNodeId: () => string; edges: TEdge[]; nodes: TNode[]; offsetX?: number; offsetY?: number; rootNodeId: string; }) { const rootNode = nodes.find((node) => node.id === rootNodeId); if (!rootNode) return null; const result = duplicateCanvasNodeSelection({ createEdgeId, createNodeId, edges, includeInternalEdges: true, nodes, offsetX, offsetY, selectedNodeIds: [rootNodeId], }); if (!result) return null; return { duplicateEdges: result.duplicateEdges, duplicateNodes: result.duplicateNodes, rootNodeId: result.rootNodeIds[0] || result.duplicateNodes[0]?.id || "", }; } export function detachCanvasGroups( nodes: T[], groupIds: string[], ) { const groups = new Set(groupIds); const detachedChildIds = new Set(); for (const groupId of groups) { const group = nodes.find((node) => node.id === groupId); for (const childId of getCanvasGroupChildIds(group)) { detachedChildIds.add(childId); } } const nextNodes = nodes .filter((node) => !groups.has(node.id)) .map((node) => { if (!detachedChildIds.has(node.id)) return node; const draftValues = { ...(node.draftValues || {}) }; delete draftValues.parentGroupId; return { ...node, draftValues }; }); return { detachedChildIds: [...detachedChildIds], nodes: nextNodes, }; } export function mergeCanvasStoryboardGroups< TNode extends CanvasGroupingNode, TEdge extends CanvasGroupingEdge, >({ createGroup, edges, nodes, selectedNodeIds, }: { createGroup: (input: { bounds: NonNullable>; childNodeIds: string[]; sourceGroupIds: string[]; }) => TNode; edges: TEdge[]; nodes: TNode[]; selectedNodeIds: string[]; }) { const nodeById = new Map(nodes.map((node) => [node.id, node])); const sourceGroupIds = selectedNodeIds.filter((nodeId) => isCanvasStoryboardGroupNode(nodeById.get(nodeId)), ); if (sourceGroupIds.length < 2) return null; const sourceGroupIdSet = new Set(sourceGroupIds); const childNodeIds = [ ...new Set( sourceGroupIds.flatMap((groupId) => getCanvasGroupChildIds(nodeById.get(groupId)), ), ), ].filter( (nodeId) => nodeById.has(nodeId) && !sourceGroupIdSet.has(nodeId), ); const childNodeIdSet = new Set(childNodeIds); const childNodes = childNodeIds.flatMap((nodeId) => { const node = nodeById.get(nodeId); return node ? [node] : []; }); const bounds = getCanvasGroupBounds(childNodes); if (!bounds || childNodes.length === 0) return null; const detachedNodes = nodes .filter((node) => !sourceGroupIdSet.has(node.id)) .map((node) => { const draftValues = { ...(node.draftValues || {}) }; let changed = false; if (childNodeIdSet.has(node.id)) { delete draftValues.parentGroupId; changed = true; } const groupChildIds = getCanvasGroupChildIds(node); if (groupChildIds.some((childId) => sourceGroupIdSet.has(childId))) { draftValues.childNodeIds = groupChildIds.filter( (childId) => !sourceGroupIdSet.has(childId), ); changed = true; } return changed ? { ...node, draftValues } : node; }); const group = createGroup({ bounds, childNodeIds, sourceGroupIds }); const mergedNodes = attachCanvasNodesToGroup( [...detachedNodes, group], group.id, childNodeIds, ); const mergedEdges = edges.filter( (edge) => !sourceGroupIdSet.has(edge.source) && !sourceGroupIdSet.has(edge.target), ); return { childNodeIds, edges: mergedEdges, group, nodes: mergedNodes, sourceGroupIds, }; }