382 lines
10 KiB
TypeScript
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,
|
|
};
|
|
}
|