Skip to content

Commit 8ec9130

Browse files
committed
feat: 增加导出mermaid功能
1 parent 83e727b commit 8ec9130

File tree

7 files changed

+298
-42
lines changed

7 files changed

+298
-42
lines changed

app/src/core/service/GlobalMenu.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,13 @@ import {
101101
Search,
102102
SettingsIcon,
103103
SquareDashedMousePointer,
104+
SquareSquare,
104105
Tag,
105106
TestTube2,
106107
TextQuote,
107108
Tv,
108109
Undo,
110+
VectorSquare,
109111
VenetianMask,
110112
View,
111113
Workflow,
@@ -436,7 +438,7 @@ export function GlobalMenu() {
436438
Dialog.copy(t("file.exportSuccess"), "", result);
437439
}}
438440
>
439-
<FileDigit />
441+
<VectorSquare />
440442
{t("file.plainTextType.exportAllNodeGraph")}
441443
</Item>
442444
{/* 导出 选中 网状关系 */}
@@ -452,7 +454,7 @@ export function GlobalMenu() {
452454
Dialog.copy(t("file.exportSuccess"), "", result);
453455
}}
454456
>
455-
<MousePointer2 />
457+
<VectorSquare />
456458
{t("file.plainTextType.exportSelectedNodeGraph")}
457459
</Item>
458460
{/* 导出 选中 树状关系 (纯文本缩进) */}
@@ -465,7 +467,7 @@ export function GlobalMenu() {
465467
}
466468
}}
467469
>
468-
<MousePointer2 />
470+
<Network />
469471
{t("file.plainTextType.exportSelectedNodeTree")}
470472
</Item>
471473
{/* 导出 选中 树状关系 (Markdown格式) */}
@@ -478,9 +480,20 @@ export function GlobalMenu() {
478480
}
479481
}}
480482
>
481-
<MousePointer2 />
483+
<Network />
482484
{t("file.plainTextType.exportSelectedNodeTreeMarkdown")}
483485
</Item>
486+
{/* 导出 选中 网状嵌套关系 (mermaid格式) */}
487+
<Item
488+
onClick={() => {
489+
const selectedEntities = activeProject!.stageManager.getSelectedEntities();
490+
const result = activeProject!.stageExport.getMermaidTextByEntites(selectedEntities);
491+
Dialog.copy(t("file.exportSuccess"), "", result);
492+
}}
493+
>
494+
<SquareSquare />
495+
{t("file.plainTextType.exportSelectedNodeGraphMermaid")}
496+
</Item>
484497
</SubContent>
485498
</Sub>
486499
</SubContent>

app/src/core/service/dataGenerateService/stageExportEngine/stageExportEngine.tsx

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { ConnectableEntity } from "@/core/stage/stageObject/abstract/Connectable
33
import { Entity } from "@/core/stage/stageObject/abstract/StageEntity";
44
import { TextNode } from "@/core/stage/stageObject/entity/TextNode";
55
import { EntityDetailsTool } from "../../dataManageService/entityDetailsService/entityDetailsTool";
6+
import { CopyEngineUtils } from "../../dataManageService/copyEngine/copyEngineUtils";
7+
import { Section } from "@/core/stage/stageObject/entity/Section";
8+
import { LineEdge } from "@/core/stage/stageObject/association/LineEdge";
69

710
/**
811
* 专注于导出各种格式内容的引擎
@@ -63,6 +66,223 @@ export class StageExport {
6366
return this.getTreeTypeString(textNode, this.getTabText);
6467
}
6568

69+
/**
70+
* 格式:
71+
* ```mermaid
72+
* graph TD
73+
* A --> B
74+
* A --> C
75+
* B -- 连线文字 --> C
76+
* ```
77+
*
78+
* (TD)表示自上而下,LR表示自左而右
79+
* 使用 subgraph ... end 来定义子图。
80+
*/
81+
public getMermaidTextByEntites(entities: Entity[]): string {
82+
const stageObjects = CopyEngineUtils.getAllStageObjectFromEntities(this.project, entities);
83+
const allNodes = stageObjects.filter((v) => v instanceof TextNode || v instanceof Section) as (
84+
| TextNode
85+
| Section
86+
)[];
87+
const allLinks = stageObjects.filter((v) => v instanceof LineEdge) as LineEdge[];
88+
89+
// 创建节点集合,用于快速查找
90+
const nodeSet = new Set(allNodes.map((n) => n.uuid));
91+
92+
// 过滤出有效的连线(source 和 target 都在节点集合中)
93+
const validLinks = allLinks.filter((link) => nodeSet.has(link.source.uuid) && nodeSet.has(link.target.uuid));
94+
95+
// 生成节点 ID 映射(uuid -> mermaid ID)
96+
const nodeIdMap = new Map<string, string>();
97+
const nodeIdCounter = new Map<string, number>();
98+
99+
// 生成有效的 Mermaid 节点 ID
100+
const getNodeId = (node: TextNode | Section): string => {
101+
if (nodeIdMap.has(node.uuid)) {
102+
return nodeIdMap.get(node.uuid)!;
103+
}
104+
105+
// 基于文本生成 ID,Mermaid 支持中文字符作为节点 ID
106+
let baseId = node.text.trim();
107+
108+
// 如果文本为空,使用 uuid 的前8位
109+
if (!baseId) {
110+
baseId = "node_" + node.uuid.substring(0, 8);
111+
} else {
112+
// 如果以数字开头,在前面加下划线
113+
if (/^[0-9]/.test(baseId)) {
114+
baseId = "_" + baseId;
115+
}
116+
// Mermaid 支持 Unicode 字符(包括中文),所以不需要过滤中文字符
117+
// 只需要确保不以数字开头即可
118+
}
119+
120+
// 处理重复的 ID
121+
let finalId = baseId;
122+
if (nodeIdCounter.has(baseId)) {
123+
const count = nodeIdCounter.get(baseId)! + 1;
124+
nodeIdCounter.set(baseId, count);
125+
finalId = baseId + "_" + count;
126+
} else {
127+
nodeIdCounter.set(baseId, 0);
128+
}
129+
130+
nodeIdMap.set(node.uuid, finalId);
131+
return finalId;
132+
};
133+
134+
// 转义 Mermaid 文本中的特殊字符
135+
const escapeMermaidText = (text: string): string => {
136+
// Mermaid 中的特殊字符需要转义或使用引号
137+
return text.replace(/"/g, "&quot;").replace(/\n/g, "<br>");
138+
};
139+
140+
// 找出所有 Section
141+
const sections = allNodes.filter((n) => n instanceof Section) as Section[];
142+
143+
// 找出每个节点所在的 Section(只考虑最内层的 Section,即直接包含它的 Section)
144+
const nodeToSectionMap = new Map<string, Section>();
145+
146+
// 找出每个 TextNode 所在的最内层 Section(直接包含它的 Section)
147+
for (const node of allNodes) {
148+
if (node instanceof Section) continue;
149+
150+
// 找出所有包含该节点的 Section
151+
const containingSections: Section[] = [];
152+
for (const section of sections) {
153+
if (this.project.sectionMethods.isEntityInSection(node, section)) {
154+
containingSections.push(section);
155+
}
156+
}
157+
158+
// 找出最内层的 Section(不被其他包含该节点的 Section 包含的 Section)
159+
if (containingSections.length > 0) {
160+
let innermostSection = containingSections[0];
161+
for (const section of containingSections) {
162+
// 如果 innermostSection 包含 section,则 section 是更内层的
163+
if (this.project.sectionMethods.isEntityInSection(section, innermostSection)) {
164+
innermostSection = section;
165+
}
166+
}
167+
nodeToSectionMap.set(node.uuid, innermostSection);
168+
}
169+
}
170+
171+
// 找出每个 Section 所在的父 Section(最内层的父 Section,即直接包含它的 Section)
172+
const sectionToParentMap = new Map<Section, Section>();
173+
for (const section of sections) {
174+
// 找出所有包含该 Section 的父 Section
175+
const parentSections: Section[] = [];
176+
for (const s of sections) {
177+
if (s !== section && this.project.sectionMethods.isEntityInSection(section, s)) {
178+
parentSections.push(s);
179+
}
180+
}
181+
182+
if (parentSections.length > 0) {
183+
// 找出最内层的父 Section(不被其他父 Section 包含的父 Section)
184+
let innermostParent = parentSections[0];
185+
for (const s of parentSections) {
186+
// 如果 innermostParent 包含 s,则 s 是更内层的
187+
if (this.project.sectionMethods.isEntityInSection(s, innermostParent)) {
188+
innermostParent = s;
189+
}
190+
}
191+
sectionToParentMap.set(section, innermostParent);
192+
}
193+
}
194+
195+
// 按 Section 分组节点
196+
const sectionToNodesMap = new Map<Section, (TextNode | Section)[]>();
197+
const nodesWithoutSection: (TextNode | Section)[] = [];
198+
199+
for (const node of allNodes) {
200+
if (node instanceof Section) {
201+
// Section 本身:如果它在其他 Section 内,则放在父 Section 中
202+
const parentSection = sectionToParentMap.get(node);
203+
if (parentSection) {
204+
if (!sectionToNodesMap.has(parentSection)) {
205+
sectionToNodesMap.set(parentSection, []);
206+
}
207+
sectionToNodesMap.get(parentSection)!.push(node);
208+
} else {
209+
// 最外层的 Section,直接添加到根节点列表
210+
nodesWithoutSection.push(node);
211+
}
212+
} else {
213+
// TextNode:如果它在 Section 内,则放在对应的 Section 中
214+
const section = nodeToSectionMap.get(node.uuid);
215+
if (section) {
216+
if (!sectionToNodesMap.has(section)) {
217+
sectionToNodesMap.set(section, []);
218+
}
219+
sectionToNodesMap.get(section)!.push(node);
220+
} else {
221+
// 不在任何 Section 中的节点
222+
nodesWithoutSection.push(node);
223+
}
224+
}
225+
}
226+
227+
// 生成 Mermaid 字符串
228+
let result = "graph TD\n";
229+
230+
// 递归生成节点和子图
231+
const generateNodes = (nodes: (TextNode | Section)[], indent: string = ""): void => {
232+
for (const node of nodes) {
233+
if (node instanceof Section) {
234+
// 生成子图
235+
const sectionId = getNodeId(node);
236+
const sectionTitle = escapeMermaidText(node.text || "Section");
237+
// 如果 ID 和显示文本相同,就不使用别名
238+
if (sectionId === sectionTitle) {
239+
result += `${indent}subgraph ${sectionId}\n`;
240+
} else {
241+
result += `${indent}subgraph ${sectionId}["${sectionTitle}"]\n`;
242+
}
243+
244+
// 生成子图内的节点
245+
const innerNodes = sectionToNodesMap.get(node) || [];
246+
generateNodes(innerNodes, indent + " ");
247+
248+
result += `${indent}end\n`;
249+
} else {
250+
// 生成普通节点
251+
const nodeId = getNodeId(node);
252+
const nodeText = escapeMermaidText(node.text || "");
253+
if (nodeText) {
254+
// 如果 ID 和显示文本相同,就不使用别名
255+
if (nodeId === nodeText) {
256+
result += `${indent}${nodeId}\n`;
257+
} else {
258+
result += `${indent}${nodeId}["${nodeText}"]\n`;
259+
}
260+
} else {
261+
result += `${indent}${nodeId}\n`;
262+
}
263+
}
264+
}
265+
};
266+
267+
// 生成所有节点(包括不在 Section 中的节点和 Section 本身)
268+
generateNodes(nodesWithoutSection);
269+
270+
// 生成连线
271+
for (const link of validLinks) {
272+
const sourceId = getNodeId(link.source as TextNode | Section);
273+
const targetId = getNodeId(link.target as TextNode | Section);
274+
275+
if (link.text && link.text.trim()) {
276+
const linkText = escapeMermaidText(link.text.trim());
277+
result += `${sourceId} -- "${linkText}" --> ${targetId}\n`;
278+
} else {
279+
result += `${sourceId} --> ${targetId}\n`;
280+
}
281+
}
282+
283+
return result.trim();
284+
}
285+
66286
/**
67287
* 树形遍历节点
68288
* @param textNode

app/src/core/service/dataManageService/copyEngine/copyEngine.tsx

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { Project, service } from "@/core/Project";
2-
import { SetFunctions } from "@/core/algorithm/setFunctions";
32
import { ConnectableAssociation } from "@/core/stage/stageObject/abstract/Association";
43
import { Entity } from "@/core/stage/stageObject/abstract/StageEntity";
54
import { StageObject } from "@/core/stage/stageObject/abstract/StageObject";
65
import { Edge } from "@/core/stage/stageObject/association/Edge";
76
import { MultiTargetUndirectedEdge } from "@/core/stage/stageObject/association/MutiTargetUndirectedEdge";
87
import { ImageNode } from "@/core/stage/stageObject/entity/ImageNode";
9-
import { Section } from "@/core/stage/stageObject/entity/Section";
108
import { TextNode } from "@/core/stage/stageObject/entity/TextNode";
119
import { Serialized } from "@/types/node";
1210
import { Color, ProgressNumber, Vector } from "@graphif/data-structures";
@@ -21,6 +19,7 @@ import { RectangleNoteReversedEffect } from "../../feedbackService/effectEngine/
2119
import { VirtualClipboard } from "./VirtualClipboard";
2220
import { CopyEngineImage } from "./copyEngineImage";
2321
import { CopyEngineText } from "./copyEngineText";
22+
import { CopyEngineUtils } from "./copyEngineUtils";
2423

2524
/**
2625
* 专门用来管理节点复制的引擎
@@ -49,42 +48,7 @@ export class CopyEngine {
4948
toast.info("当前没有选中任何实体,已清空了虚拟剪贴板");
5049
return;
5150
}
52-
53-
// 更新虚拟剪贴板
54-
const selectedUUIDs = new Set(selectedEntities.map((it) => it.uuid));
55-
// ===== 开始构建 copyedStageObjects
56-
const copiedStageObjects: StageObject[] = [...selectedEntities]; // 准备复制后的数据
57-
// 处理Section框内部的实体
58-
// 先检测一下选中的内容中是否有框
59-
const isHaveSection = selectedEntities.some((it) => it instanceof Section);
60-
if (isHaveSection) {
61-
// 如果有框,则获取框内的实体
62-
const innerEntities = this.project.sectionMethods.getAllEntitiesInSelectedSectionsOrEntities(selectedEntities);
63-
// 根据 selectedUUIDs 过滤
64-
const filteredInnerEntities = innerEntities.filter((it) => !selectedUUIDs.has(it.uuid));
65-
copiedStageObjects.push(...filteredInnerEntities);
66-
// 补充 selectedUUIDs
67-
for (const entity of filteredInnerEntities) {
68-
selectedUUIDs.add(entity.uuid);
69-
}
70-
}
71-
// O(N), N 为当前舞台对象数量
72-
for (const association of this.project.stageManager.getAssociations()) {
73-
if (association instanceof ConnectableAssociation) {
74-
if (association instanceof Edge) {
75-
if (selectedUUIDs.has(association.source.uuid) && selectedUUIDs.has(association.target.uuid)) {
76-
copiedStageObjects.push(association);
77-
}
78-
} else if (association instanceof MultiTargetUndirectedEdge) {
79-
// 无向边
80-
const associationUUIDs = new Set(association.associationList.map((it) => it.uuid));
81-
if (SetFunctions.isSubset(associationUUIDs, selectedUUIDs)) {
82-
copiedStageObjects.push(association);
83-
}
84-
}
85-
}
86-
}
87-
// ===== copyedStageObjects 构建完毕
51+
const copiedStageObjects = CopyEngineUtils.getAllStageObjectFromEntities(this.project, selectedEntities);
8852

8953
// 深拷贝一下数据,只有在粘贴的时候才刷新uuid
9054
const serializedCopiedStageObjects = serialize(copiedStageObjects);

0 commit comments

Comments
 (0)