Skip to content

Commit 8dc4ae7

Browse files
committed
feat: 可重复的外部节点
1 parent 7cf968c commit 8dc4ae7

12 files changed

Lines changed: 328 additions & 33 deletions

File tree

dev/design/TODO.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,4 @@
1313
- 按键工具
1414
- 导入图片
1515
- 自定义分辨率
16-
- 可重复的外部/重定向节点 #21
1716
- 日志分析

src/components/flow/nodes/AnchorNode.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@ const ANodeContent = memo(
3232
data,
3333
referenceNodes,
3434
onNavigateToNode,
35+
replicaCount,
3536
}: {
3637
data: AnchorNodeDataType;
3738
referenceNodes?: ReferenceNodeInfo[];
3839
onNavigateToNode?: (node: ReferenceNodeInfo) => void;
40+
replicaCount: number;
3941
}) => {
4042
const [popoverOpen, setPopoverOpen] = useState(false);
4143

@@ -51,6 +53,14 @@ const ANodeContent = memo(
5153
<>
5254
<div className={style.title}>
5355
<span className={style["title-text"]}>{data.label}</span>
56+
{replicaCount > 0 && (
57+
<span
58+
className={style["replica-badge"]}
59+
title={`此重定向节点共有 ${replicaCount + 1} 个视觉副本`}
60+
>
61+
+{replicaCount}
62+
</span>
63+
)}
5464
{referenceNodes && referenceNodes.length > 0 && (
5565
<Popover
5666
open={popoverOpen}
@@ -135,6 +145,21 @@ export function AnchorNode(props: NodeProps<AnchorNodeData>) {
135145
(state) => state.getNodesUsingAnchor,
136146
);
137147

148+
// 视觉副本数量(同 label 的其他 Anchor 节点)
149+
const replicaCount = useMemo(() => {
150+
let count = 0;
151+
for (const n of nodes) {
152+
if (
153+
n.type === NodeTypeEnum.Anchor &&
154+
n.id !== props.id &&
155+
n.data.label === props.data.label
156+
) {
157+
count++;
158+
}
159+
}
160+
return count;
161+
}, [nodes, props.id, props.data.label]);
162+
138163
// 获取引用此 anchor 的节点列表(支持跨文件)
139164
const referenceNodes = useMemo((): ReferenceNodeInfo[] => {
140165
const result: ReferenceNodeInfo[] = [];
@@ -299,6 +324,7 @@ export function AnchorNode(props: NodeProps<AnchorNodeData>) {
299324
data={props.data}
300325
referenceNodes={referenceNodes}
301326
onNavigateToNode={handleNavigateToNode}
327+
replicaCount={replicaCount}
302328
/>
303329
</div>
304330
);
@@ -315,6 +341,7 @@ export function AnchorNode(props: NodeProps<AnchorNodeData>) {
315341
data={props.data}
316342
referenceNodes={referenceNodes}
317343
onNavigateToNode={handleNavigateToNode}
344+
replicaCount={replicaCount}
318345
/>
319346
</div>
320347
</NodeContextMenu>

src/components/flow/nodes/ExternalNode.tsx

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,32 @@ import { NodeContextMenu } from "./components/NodeContextMenu";
1212
import { ExternalNodeHandles } from "./components/NodeHandles";
1313

1414
/**外部节点内容 */
15-
const ENodeContent = memo(({ data }: { data: ExternalNodeDataType }) => {
16-
return (
17-
<>
18-
<div className={style.title}>
19-
<span className={style["title-text"]}>{data.label}</span>
20-
</div>
21-
<ExternalNodeHandles direction={data.handleDirection} />
22-
</>
23-
);
24-
});
15+
const ENodeContent = memo(
16+
({
17+
data,
18+
replicaCount,
19+
}: {
20+
data: ExternalNodeDataType;
21+
replicaCount: number;
22+
}) => {
23+
return (
24+
<>
25+
<div className={style.title}>
26+
<span className={style["title-text"]}>{data.label}</span>
27+
{replicaCount > 0 && (
28+
<span
29+
className={style["replica-badge"]}
30+
title={`此外部引用共有 ${replicaCount + 1} 个视觉副本`}
31+
>
32+
+{replicaCount}
33+
</span>
34+
)}
35+
</div>
36+
<ExternalNodeHandles direction={data.handleDirection} />
37+
</>
38+
);
39+
},
40+
);
2541

2642
type ExternalNodeData = Node<ExternalNodeDataType, NodeTypeEnum.External>;
2743

@@ -48,6 +64,22 @@ export function ExternalNode(props: NodeProps<ExternalNodeData>) {
4864
})),
4965
);
5066
const edges = useFlowStore((state) => state.edges);
67+
const nodes = useFlowStore((state) => state.nodes);
68+
69+
// 视觉副本数量(同 label 的其他 External 节点)
70+
const replicaCount = useMemo(() => {
71+
let count = 0;
72+
for (const n of nodes) {
73+
if (
74+
n.type === NodeTypeEnum.External &&
75+
n.id !== props.id &&
76+
n.data.label === props.data.label
77+
) {
78+
count++;
79+
}
80+
}
81+
return count;
82+
}, [nodes, props.id, props.data.label]);
5183

5284
// 计算是否与选中元素相关联
5385
const isRelated = useMemo(() => {
@@ -130,7 +162,7 @@ export function ExternalNode(props: NodeProps<ExternalNodeData>) {
130162
if (!node) {
131163
return (
132164
<div className={nodeClass} style={opacityStyle}>
133-
<ENodeContent data={props.data} />
165+
<ENodeContent data={props.data} replicaCount={replicaCount} />
134166
</div>
135167
);
136168
}
@@ -142,7 +174,7 @@ export function ExternalNode(props: NodeProps<ExternalNodeData>) {
142174
onOpenChange={setContextMenuOpen}
143175
>
144176
<div className={nodeClass} style={opacityStyle}>
145-
<ENodeContent data={props.data} />
177+
<ENodeContent data={props.data} replicaCount={replicaCount} />
146178
</div>
147179
</NodeContextMenu>
148180
);

src/core/parser/configSplitter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ export function splitPipelineAndConfig(pipelineObj: PipelineObjType): {
7070
if (mpeCode.handleDirection) {
7171
nodeConfig.handleDirection = mpeCode.handleDirection;
7272
}
73+
if (Array.isArray(mpeCode.extra_positions)) {
74+
nodeConfig.extra_positions = mpeCode.extra_positions;
75+
}
7376
return nodeConfig;
7477
};
7578

@@ -173,6 +176,9 @@ export function mergePipelineAndConfig(
173176
if (nodeData.handleDirection) {
174177
mpeCode.handleDirection = nodeData.handleDirection;
175178
}
179+
if (Array.isArray(nodeData.extra_positions) && nodeData.extra_positions.length > 0) {
180+
mpeCode.extra_positions = nodeData.extra_positions;
181+
}
176182
return mpeCode;
177183
}
178184
// 旧格式:包含 $__mpe_code 包装层

src/core/parser/edgeRerouter.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { NodeType, EdgeType } from "./types";
2+
import { NodeTypeEnum } from "../../components/flow/nodes";
3+
import { getNodeAbsolutePosition } from "../../stores/flow/utils/coordinateUtils";
4+
5+
/**
6+
* 视觉副本就近匹配
7+
*
8+
* 同 label 的 External / Anchor 节点视为同一逻辑目标的视觉副本。
9+
* 导入阶段的 linkEdge 仅按 label 找到首个匹配作为目标,导致后续副本上没有任何边相连。
10+
* 本函数遍历每条以 External / Anchor 为 target 的边,按 source 与各副本的欧氏距离,
11+
* 改写 target 为最近的副本 id。
12+
*
13+
* 局限:用户故意"放歪"副本(让一条边走远路)会被错位。属于已知限制。
14+
*/
15+
export function rerouteEdgesToNearestReplica(
16+
nodes: NodeType[],
17+
edges: EdgeType[],
18+
): EdgeType[] {
19+
// 按 (type, label) 分桶收集副本
20+
const replicaBuckets = new Map<string, NodeType[]>();
21+
for (const node of nodes) {
22+
if (
23+
node.type !== NodeTypeEnum.External &&
24+
node.type !== NodeTypeEnum.Anchor
25+
) {
26+
continue;
27+
}
28+
const key = `${node.type}::${node.data.label}`;
29+
let bucket = replicaBuckets.get(key);
30+
if (!bucket) {
31+
bucket = [];
32+
replicaBuckets.set(key, bucket);
33+
}
34+
bucket.push(node);
35+
}
36+
37+
// 没有任何副本(每个 label 仅 1 个)→ 直接返回
38+
let hasReplica = false;
39+
for (const bucket of replicaBuckets.values()) {
40+
if (bucket.length > 1) {
41+
hasReplica = true;
42+
break;
43+
}
44+
}
45+
if (!hasReplica) return edges;
46+
47+
const nodeMap = new Map<string, NodeType>();
48+
for (const node of nodes) nodeMap.set(node.id, node);
49+
50+
return edges.map((edge) => {
51+
const target = nodeMap.get(edge.target);
52+
if (!target) return edge;
53+
if (
54+
target.type !== NodeTypeEnum.External &&
55+
target.type !== NodeTypeEnum.Anchor
56+
) {
57+
return edge;
58+
}
59+
60+
const bucket = replicaBuckets.get(`${target.type}::${target.data.label}`);
61+
if (!bucket || bucket.length <= 1) return edge;
62+
63+
const source = nodeMap.get(edge.source);
64+
if (!source) return edge;
65+
66+
const sourcePos = getNodeAbsolutePosition(source, nodes);
67+
let bestId = target.id;
68+
let bestDist = Infinity;
69+
for (const replica of bucket) {
70+
const replicaPos = getNodeAbsolutePosition(replica, nodes);
71+
const dx = replicaPos.x - sourcePos.x;
72+
const dy = replicaPos.y - sourcePos.y;
73+
const dist = dx * dx + dy * dy;
74+
if (dist < bestDist) {
75+
bestDist = dist;
76+
bestId = replica.id;
77+
}
78+
}
79+
80+
if (bestId === edge.target) return edge;
81+
82+
return {
83+
...edge,
84+
target: bestId,
85+
id: `${edge.source}_${edge.sourceHandle}_${bestId}`,
86+
};
87+
});
88+
}

src/core/parser/exporter.ts

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import { normalizeViewport } from "../../stores/flow/utils/viewportUtils";
3535
import { splitPipelineAndConfig } from "./configSplitter";
3636
import { isMpeField } from "../sorting";
37+
import { serializeNodePosition } from "../../stores/flow/utils/coordinateUtils";
3738

3839
/**
3940
* 将Flow转换为Pipeline对象
@@ -85,6 +86,17 @@ export function flowToPipeline(datas?: FlowToOptions): PipelineObjType {
8586
const prefix = config.prefix ? config.prefix + "_" : "";
8687
const pipelineObj: PipelineObjType = {};
8788

89+
// 视觉副本:同 label 的 External / Anchor 主副本写入 entry(保持向后兼容),
90+
// 第二个起的副本位置追加到该 entry 的 $__mpe_code.extra_positions 数组中。
91+
const externalEntryByKey = new Map<
92+
string,
93+
{ mpeCode: Record<string, any> }
94+
>();
95+
const anchorEntryByKey = new Map<
96+
string,
97+
{ mpeCode: Record<string, any> }
98+
>();
99+
88100
sortedNodes.forEach((node) => {
89101
switch (node.type) {
90102
case NodeTypeEnum.Pipeline:
@@ -93,16 +105,53 @@ export function flowToPipeline(datas?: FlowToOptions): PipelineObjType {
93105
sortedNodes,
94106
);
95107
break;
96-
case NodeTypeEnum.External:
108+
case NodeTypeEnum.External: {
97109
if (!shouldExportConfig) break;
98-
pipelineObj[externalMarkPrefix + node.data.label + "_" + fileName] =
99-
parseExternalNodeForExport(node as PipelineNodeType, sortedNodes);
110+
const key = externalMarkPrefix + node.data.label + "_" + fileName;
111+
const existing = externalEntryByKey.get(key);
112+
if (existing) {
113+
// 追加到 extra_positions
114+
const pos = serializeNodePosition(node, sortedNodes);
115+
existing.mpeCode.extra_positions ??= [];
116+
existing.mpeCode.extra_positions.push({
117+
x: Math.round(pos.x),
118+
y: Math.round(pos.y),
119+
});
120+
break;
121+
}
122+
const entry = parseExternalNodeForExport(
123+
node as PipelineNodeType,
124+
sortedNodes,
125+
);
126+
pipelineObj[key] = entry;
127+
externalEntryByKey.set(key, {
128+
mpeCode: entry[configMark] as Record<string, any>,
129+
});
100130
break;
101-
case NodeTypeEnum.Anchor:
131+
}
132+
case NodeTypeEnum.Anchor: {
102133
if (!shouldExportConfig) break;
103-
pipelineObj[anchorMarkPrefix + node.data.label + "_" + fileName] =
104-
parseAnchorNodeForExport(node as PipelineNodeType, sortedNodes);
134+
const key = anchorMarkPrefix + node.data.label + "_" + fileName;
135+
const existing = anchorEntryByKey.get(key);
136+
if (existing) {
137+
const pos = serializeNodePosition(node, sortedNodes);
138+
existing.mpeCode.extra_positions ??= [];
139+
existing.mpeCode.extra_positions.push({
140+
x: Math.round(pos.x),
141+
y: Math.round(pos.y),
142+
});
143+
break;
144+
}
145+
const entry = parseAnchorNodeForExport(
146+
node as PipelineNodeType,
147+
sortedNodes,
148+
);
149+
pipelineObj[key] = entry;
150+
anchorEntryByKey.set(key, {
151+
mpeCode: entry[configMark] as Record<string, any>,
152+
});
105153
break;
154+
}
106155
case NodeTypeEnum.Sticker:
107156
if (!shouldExportConfig) break;
108157
pipelineObj[stickerMarkPrefix + node.data.label + "_" + fileName] =
@@ -135,6 +184,9 @@ export function flowToPipeline(datas?: FlowToOptions): PipelineObjType {
135184
sortedEdges.push(...sorted);
136185
});
137186

187+
// 多个副本指向同一 source 时会产生重复 ref,按 (source, linkType, targetLabel+attrs) 去重
188+
const linkSeen = new Set<string>();
189+
138190
sortedEdges.forEach((edge) => {
139191
// 获取节点数据
140192
const sourceKey = findNodeLabelById(nodes, edge.source);
@@ -178,6 +230,9 @@ export function flowToPipeline(datas?: FlowToOptions): PipelineObjType {
178230

179231
// 添加链接
180232
const linkType = edge.sourceHandle as SourceHandleTypeEnum;
233+
const dedupKey = `${edge.source}|${linkType}|${nodeName}|${isAnchor ? "a" : ""}${hasJumpBack ? "j" : ""}`;
234+
if (linkSeen.has(dedupKey)) return;
235+
linkSeen.add(dedupKey);
181236
if (!(linkType in pSourceNode)) pSourceNode[linkType] = [];
182237
pSourceNode[linkType].push(toPNodeRef);
183238
});

0 commit comments

Comments
 (0)