ai-chatbot/lib/canvas/grouping.ts

382 lines
10 KiB
TypeScript

export type CanvasGroupingNode = {
draftValues?: Record<string, unknown>;
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<T extends CanvasGroupingNode>(
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<string>();
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<T extends CanvasGroupingNode>(
nodes: T[],
groupIds: string[],
) {
const groups = new Set(groupIds);
const detachedChildIds = new Set<string>();
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<ReturnType<typeof getCanvasGroupBounds>>;
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,
};
}