feat: add architecture diagram notes
This commit is contained in:
1 parent
533a9bf638
commit
28659e6848
14 files changed
+599
-82
No files matched your search
@@ -60,6 +60,7 @@ The iOS app requires an Apple ID from outside mainland China.
|
||||
- **Seamless Dual-View Editor**: Switch effortlessly between intuitive rich text editing and Markdown source code on desktop.
|
||||
- **Convenient Single-Note Export**: Export the current note directly as Markdown, HTML, or PDF for standalone storage, sharing, or publishing.
|
||||
- **Native Mermaid Diagram Rendering**: Render clear flowcharts, sequence diagrams, and mind maps directly in notes, preserving clean, editable source code across Markdown and rich text views.
|
||||
- **Visual Diagram Notes**: Create editable mind maps, flowcharts, and architecture diagrams with semantic components, system boundaries, labeled connections, automatic layout, revision history, and PNG/SVG export.
|
||||
- **Revision History**: Inspect and restore previous iterations of your notes with built-in version tracking.
|
||||
- **Public Note Sharing**: Share a note publicly and stop sharing it at any time.
|
||||
- **WeChat Article Clipping on Mobile**: Share a WeChat Official Account article to EdgeEver on your phone to extract its content and save it as an editable note.
|
||||
|
||||
@@ -60,6 +60,7 @@ iOS 客户端需要使用非中国大陆区 Apple ID 下载。
|
||||
- **优雅的双视图编辑**:桌面端支持在富文本与 Markdown 源码视图之间自由切换。
|
||||
- **单篇笔记便捷导出**:可将当前笔记直接导出为 Markdown、HTML 或 PDF,方便独立保存、分享与发布。
|
||||
- **Mermaid 架构图与流程图渲染**:原生支持 Mermaid 代码块渲染,视图切换时完整保留可编辑源码,让绘制逻辑图表更直观。
|
||||
- **可视化图表笔记**:支持创建可编辑的思维导图、流程图和架构图,提供语义组件、系统边界、连线说明、自动布局、历史版本及 PNG/SVG 导出。
|
||||
- **笔记历史版本回溯**:自动记录修改历史,随时查阅与还原过往版本。
|
||||
- **公开笔记分享**:支持公开分享笔记,并可随时取消分享。
|
||||
- **移动 App 微信公众号文章剪藏**:在手机上将微信公众号文章分享至 EdgeEver,即可提取正文并保存为可继续编辑的笔记。
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("diagram editor keyboard workflow", () => {
|
||||
});
|
||||
|
||||
test("continues from text editing into the flowchart node picker", () => {
|
||||
expect(source).toContain('else openFlowQuickCreateRef.current(editedNode)');
|
||||
expect(source).toContain('else if (document.kind === "flowchart") openFlowQuickCreateRef.current(editedNode)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,16 +68,16 @@ describe("diagram editor canvas surface", () => {
|
||||
expect(source).not.toContain("graph.drawGrid");
|
||||
});
|
||||
|
||||
test("uses straight flowchart edges and presents the content smaller at the left", () => {
|
||||
expect(source).toContain('connector: { name: kind === "mind-map" ? "smooth" : "normal" }');
|
||||
test("uses restrained rounded edges and presents connected diagrams smaller at the left", () => {
|
||||
expect(source).toContain('connector: { name: kind === "mind-map" ? "smooth" : "rounded"');
|
||||
expect(source).toContain('router: "normal"');
|
||||
expect(source).not.toContain('name: "manhattan"');
|
||||
expect(source).toContain('maxScale: document.kind === "flowchart" ? 0.84 : 1');
|
||||
expect(source).toContain('maxScale: document.kind === "mind-map" ? 1 : 0.84');
|
||||
expect(source).toContain("fitDiagramContent(graph, document, containerRef.current);");
|
||||
});
|
||||
|
||||
test("exposes flowchart-only connection handles with safe connection rules", () => {
|
||||
expect(source).toContain('kind === "flowchart" ? { ports:');
|
||||
test("exposes connection handles on flowcharts and architecture components with safe connection rules", () => {
|
||||
expect(source).toContain("const hasPorts = isConnectableDiagram(kind) && node.shape !== \"boundary\"");
|
||||
expect(source).toContain("const FLOW_PORT_HIT_RADIUS = 14");
|
||||
expect(source).toContain("const FLOW_PORT_DOT_RADIUS = 7");
|
||||
expect(source).toContain('selector: "hitArea"');
|
||||
@@ -87,7 +87,7 @@ describe("diagram editor canvas surface", () => {
|
||||
expect(source).toContain('pointerEvents: "none"');
|
||||
expect(globalStyles).toContain('[data-diagram-kind="flowchart"] .x6-widget-selection-box-node');
|
||||
expect(globalStyles).toContain("pointer-events: none !important");
|
||||
expect(source).toContain('allowPort: document.kind === "flowchart"');
|
||||
expect(source).toContain("allowPort: isConnectableDiagram(document.kind)");
|
||||
expect(source).toContain('allowBlank: document.kind === "flowchart"');
|
||||
expect(source).toContain("allowNode: false");
|
||||
expect(source).toContain("allowLoop: false");
|
||||
@@ -96,6 +96,22 @@ describe("diagram editor canvas surface", () => {
|
||||
expect(source).toContain("data-diagram-kind={document.kind}");
|
||||
});
|
||||
|
||||
test("provides architecture components, boundaries, semantic edges, and editable labels", () => {
|
||||
expect(source).toContain('document.kind === "architecture"');
|
||||
expect(source).toContain('addNode("service")');
|
||||
expect(source).toContain('addNode("database")');
|
||||
expect(source).toContain('addNode("boundary")');
|
||||
expect(source).toContain('t("diagram.architectureConnectHint")');
|
||||
expect(source).toContain("fitArchitectureBoundaries(graph)");
|
||||
expect(source).toContain("parent.addChild(node)");
|
||||
expect(source).toContain("updateSelectedEdgeLabel");
|
||||
expect(source).toContain('t("diagram.edgeText")');
|
||||
expect(source).toContain("ARCHITECTURE_NODE_ICONS");
|
||||
expect(source).toContain('{ tagName: "path", selector: "architectureIcon" }');
|
||||
expect(source).toContain('shape === "external" ? "7 5"');
|
||||
expect(globalStyles).toContain('[data-diagram-kind="architecture"] .x6-port-body');
|
||||
});
|
||||
|
||||
test("shows connection handles only on selected flowchart nodes", () => {
|
||||
expect(globalStyles).toContain('.x6-node.edgeever-flow-node-active .edgeever-flow-port-dot');
|
||||
expect(globalStyles).toContain('.x6-port-body:hover .edgeever-flow-port-dot');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Export, Graph, History, Keyboard, Selection, type Edge, type Node } from "@antv/x6";
|
||||
import {
|
||||
Box,
|
||||
Boxes,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -9,10 +10,13 @@ import {
|
||||
CircleAlert,
|
||||
Copy,
|
||||
Diamond,
|
||||
Database,
|
||||
Download,
|
||||
FileCode2,
|
||||
FileImage,
|
||||
GitBranch,
|
||||
Globe2,
|
||||
HardDrive,
|
||||
History as HistoryIcon,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
@@ -20,10 +24,13 @@ import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
Pencil,
|
||||
Redo2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Undo2,
|
||||
ZoomIn,
|
||||
@@ -31,12 +38,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ARCHITECTURE_DIAGRAM_SCHEMA_VERSION,
|
||||
DIAGRAM_SCHEMA_VERSION,
|
||||
diagramFallbackMarkdown,
|
||||
markdownToDoc,
|
||||
parseDiagramDocument,
|
||||
serializeDiagramDocument,
|
||||
type DiagramDocument,
|
||||
type DiagramEdgeKind,
|
||||
type DiagramNodeShape,
|
||||
type DiagramTheme,
|
||||
type MemoDetail,
|
||||
@@ -56,7 +65,7 @@ import { useAppearanceTheme } from "@/components/ThemeProvider";
|
||||
import { api } from "@/lib/api";
|
||||
import { EDITOR_LOCAL_SAVE_DELAY_MS } from "@/lib/app-helpers";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { compactFlowchartNodeSize, compactMindMapNodeSize, computeDiagramLayout } from "@/lib/diagram-layout";
|
||||
import { compactArchitectureNodeSize, compactFlowchartNodeSize, compactMindMapNodeSize, computeDiagramLayout } from "@/lib/diagram-layout";
|
||||
import { resolveDiagramPalette, type DiagramAppearance } from "@/lib/diagram-theme";
|
||||
import { isLocalMemoId } from "@/lib/local-mirror";
|
||||
import { isBrowserOffline } from "@/lib/network-status";
|
||||
@@ -82,6 +91,7 @@ type DiagramEditorPaneProps = {
|
||||
};
|
||||
|
||||
type NodeData = { label: string; shape: DiagramNodeShape; parentId?: string };
|
||||
type EdgeData = { kind?: DiagramEdgeKind; bidirectional?: boolean };
|
||||
type MindMapInsertRelation = "child" | "sibling";
|
||||
type FlowPort = "top" | "right" | "bottom" | "left";
|
||||
type FlowQuickCreateState = {
|
||||
@@ -121,6 +131,21 @@ const FLOW_PORT_DOT_RADIUS = 7;
|
||||
const FLOW_QUICK_CREATE_WIDTH = 330;
|
||||
const FLOW_QUICK_CREATE_HEIGHT = 132;
|
||||
const createId = (prefix: string) => `${prefix}-${crypto.randomUUID()}`;
|
||||
const isConnectableDiagram = (kind: DiagramDocument["kind"]) => kind !== "mind-map";
|
||||
const architectureNodeLabel = (shape: DiagramNodeShape, t: (key: string) => string) => {
|
||||
const labels: Partial<Record<DiagramNodeShape, string>> = {
|
||||
client: t("diagram.newClient"),
|
||||
frontend: t("diagram.newFrontend"),
|
||||
service: t("diagram.newService"),
|
||||
database: t("diagram.newDatabase"),
|
||||
storage: t("diagram.newStorage"),
|
||||
queue: t("diagram.newQueue"),
|
||||
security: t("diagram.newSecurity"),
|
||||
external: t("diagram.newExternal"),
|
||||
boundary: t("diagram.newBoundary"),
|
||||
};
|
||||
return labels[shape] ?? t("diagram.newService");
|
||||
};
|
||||
const oppositeFlowPort = (port?: FlowPort): FlowPort | undefined => port ? ({
|
||||
top: "bottom",
|
||||
right: "left",
|
||||
@@ -232,20 +257,111 @@ const nodeAttrs = (
|
||||
) => {
|
||||
const palette = resolveDiagramPalette(theme, appearance);
|
||||
const isTerminator = shape === "terminator";
|
||||
const isBoundary = shape === "boundary";
|
||||
const architectureAccent = ARCHITECTURE_NODE_ACCENTS[shape];
|
||||
const isAccent = isRootTopic || isTerminator;
|
||||
const architectureFill = appearance === "dark" ? palette.nodeFill : `${architectureAccent}12`;
|
||||
const architectureRadius: Partial<Record<DiagramNodeShape, number>> = {
|
||||
client: 6,
|
||||
frontend: 12,
|
||||
service: 8,
|
||||
database: 24,
|
||||
storage: 6,
|
||||
queue: 18,
|
||||
security: 16,
|
||||
external: 28,
|
||||
};
|
||||
return {
|
||||
body: {
|
||||
fill: isAccent ? palette.topicFill : palette.nodeFill,
|
||||
stroke: isAccent ? palette.topicStroke : palette.nodeStroke,
|
||||
strokeWidth: isAccent ? 1.5 : 1,
|
||||
rx: isTerminator ? 24 : 11,
|
||||
ry: isTerminator ? 24 : 11,
|
||||
fill: isBoundary ? "transparent" : architectureAccent ? architectureFill : isAccent ? palette.topicFill : palette.nodeFill,
|
||||
stroke: isBoundary ? palette.nodeStroke : architectureAccent ?? (isAccent ? palette.topicStroke : palette.nodeStroke),
|
||||
strokeWidth: isBoundary ? 1.5 : isAccent || architectureAccent ? 1.5 : 1,
|
||||
strokeDasharray: isBoundary || shape === "external" ? "7 5" : undefined,
|
||||
rx: isTerminator ? 24 : architectureRadius[shape] ?? 11,
|
||||
ry: isTerminator ? 24 : architectureRadius[shape] ?? 11,
|
||||
...(shape === "decision" ? { refPoints: "0,10 10,0 20,10 10,20" } : {}),
|
||||
},
|
||||
label: { fill: isAccent ? palette.topicText : palette.nodeText, fontSize: shape === "topic" ? 14 : 13, fontWeight: isAccent ? 650 : 500 },
|
||||
label: {
|
||||
fill: isAccent ? palette.topicText : palette.nodeText,
|
||||
fontSize: shape === "topic" ? 14 : isBoundary ? 12 : 13,
|
||||
fontWeight: isAccent || isBoundary || architectureAccent ? 650 : 500,
|
||||
...(isBoundary ? { refX: 18, refY: 22, textAnchor: "start", textVerticalAnchor: "middle" } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const ARCHITECTURE_NODE_ACCENTS: Partial<Record<DiagramNodeShape, string>> = {
|
||||
client: "#0891B2",
|
||||
frontend: "#2563EB",
|
||||
service: "#16A06E",
|
||||
database: "#7C3AED",
|
||||
storage: "#D97706",
|
||||
queue: "#EA580C",
|
||||
security: "#E11D48",
|
||||
external: "#64748B",
|
||||
};
|
||||
|
||||
// Simple 24px pictograms are embedded in the X6 SVG so exports remain self-contained.
|
||||
const ARCHITECTURE_NODE_ICONS: Partial<Record<DiagramNodeShape, string>> = {
|
||||
client: "M3 4h18v13H3z M8 21h8 M12 17v4",
|
||||
frontend: "M3 4h18v16H3z M3 9h18 M7 6.5h.01 M10 6.5h.01",
|
||||
service: "M4 3h16v7H4z M4 14h16v7H4z M7 6.5h.01 M7 17.5h.01 M16 6.5h2 M16 17.5h2",
|
||||
database: "M20 6c0 2.2-3.6 4-8 4S4 8.2 4 6s3.6-4 8-4 8 1.8 8 4Z M4 6v6c0 2.2 3.6 4 8 4s8-1.8 8-4V6 M4 12v6c0 2.2 3.6 4 8 4s8-1.8 8-4v-6",
|
||||
storage: "M4 4h16l2 6v10H2V10z M2 10h20 M17 15h.01",
|
||||
queue: "M5 6h14 M5 12h14 M5 18h14 M3 6h.01 M3 12h.01 M3 18h.01",
|
||||
security: "M12 2 20 5v6c0 5.2-3.4 9.2-8 11-4.6-1.8-8-5.8-8-11V5z M9 12l2 2 4-5",
|
||||
external: "M16 16h3a4 4 0 0 0 .6-8A7 7 0 0 0 6.3 6.4 4.5 4.5 0 0 0 7.5 16H10 M14 4h6v6 M20 4l-8 8",
|
||||
};
|
||||
|
||||
const architectureNodeVisuals = (
|
||||
shape: DiagramNodeShape,
|
||||
size: { width: number; height: number },
|
||||
appearance: DiagramAppearance,
|
||||
) => {
|
||||
const accent = ARCHITECTURE_NODE_ACCENTS[shape] ?? "#64748B";
|
||||
const iconY = Math.round((size.height - 34) / 2);
|
||||
return {
|
||||
markup: [
|
||||
{ tagName: "rect", selector: "body" },
|
||||
{ tagName: "rect", selector: "iconFrame" },
|
||||
{ tagName: "path", selector: "architectureIcon" },
|
||||
{ tagName: "text", selector: "label" },
|
||||
],
|
||||
attrs: {
|
||||
iconFrame: {
|
||||
x: 10,
|
||||
y: iconY,
|
||||
width: 34,
|
||||
height: 34,
|
||||
rx: shape === "database" ? 17 : shape === "security" ? 12 : 8,
|
||||
ry: shape === "database" ? 17 : shape === "security" ? 12 : 8,
|
||||
fill: appearance === "dark" ? `${accent}30` : `${accent}18`,
|
||||
stroke: "none",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
architectureIcon: {
|
||||
d: ARCHITECTURE_NODE_ICONS[shape],
|
||||
transform: `translate(15 ${iconY + 5})`,
|
||||
fill: "none",
|
||||
stroke: accent,
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const diagramNodeSize = (
|
||||
node: DiagramDocument["nodes"][number],
|
||||
kind: DiagramDocument["kind"],
|
||||
) => kind === "mind-map"
|
||||
? compactMindMapNodeSize(node.label, !node.parentId)
|
||||
: kind === "architecture"
|
||||
? compactArchitectureNodeSize(node.shape, node)
|
||||
: compactFlowchartNodeSize(node.shape);
|
||||
|
||||
const flowPortGroup = (
|
||||
position: FlowPort,
|
||||
palette: ReturnType<typeof resolveDiagramPalette>,
|
||||
@@ -283,9 +399,11 @@ const nodeMetadata = (
|
||||
const isRootTopic = node.shape === "topic" && !node.parentId;
|
||||
const visualAttrs = nodeAttrs(node.shape, theme, appearance, isRootTopic);
|
||||
const palette = resolveDiagramPalette(theme, appearance);
|
||||
const size = node.shape === "topic"
|
||||
? compactMindMapNodeSize(node.label, isRootTopic)
|
||||
: compactFlowchartNodeSize(node.shape);
|
||||
const size = diagramNodeSize(node, kind);
|
||||
const hasPorts = isConnectableDiagram(kind) && node.shape !== "boundary";
|
||||
const architectureVisuals = kind === "architecture" && node.shape !== "boundary"
|
||||
? architectureNodeVisuals(node.shape, size, appearance)
|
||||
: null;
|
||||
return {
|
||||
id: node.id,
|
||||
shape: isDecision ? "polygon" : "rect",
|
||||
@@ -293,12 +411,19 @@ const nodeMetadata = (
|
||||
y: node.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
zIndex: node.shape === "boundary" ? 0 : 2,
|
||||
data: { label: node.label, shape: node.shape, ...(node.parentId ? { parentId: node.parentId } : {}) } satisfies NodeData,
|
||||
...(architectureVisuals ? { markup: architectureVisuals.markup } : {}),
|
||||
attrs: {
|
||||
body: visualAttrs.body,
|
||||
label: { ...visualAttrs.label, text: node.label },
|
||||
label: {
|
||||
...visualAttrs.label,
|
||||
text: node.label,
|
||||
...(architectureVisuals ? { refX: 54, refY: "50%", textAnchor: "start", textVerticalAnchor: "middle" } : {}),
|
||||
},
|
||||
...(architectureVisuals?.attrs ?? {}),
|
||||
},
|
||||
...(kind === "flowchart" ? { ports: {
|
||||
...(hasPorts ? { ports: {
|
||||
groups: {
|
||||
top: flowPortGroup("top", palette),
|
||||
right: flowPortGroup("right", palette),
|
||||
@@ -317,16 +442,25 @@ const edgeMetadata = (
|
||||
appearance: DiagramAppearance,
|
||||
) => {
|
||||
const palette = resolveDiagramPalette(theme, appearance);
|
||||
const edgeKind = edge.kind ?? (kind === "architecture" ? "dependency" : undefined);
|
||||
const edgeStroke = edgeKind === "data"
|
||||
? "#7C3AED"
|
||||
: edgeKind === "async"
|
||||
? "#EA580C"
|
||||
: kind === "mind-map" ? palette.mindMapEdge : palette.flowEdge;
|
||||
return {
|
||||
id: edge.id,
|
||||
source: { cell: edge.source },
|
||||
target: { cell: edge.target },
|
||||
router: undefined,
|
||||
connector: { name: kind === "mind-map" ? "smooth" : "normal" },
|
||||
connector: { name: kind === "mind-map" ? "smooth" : "rounded", args: { radius: 10 } },
|
||||
data: { ...(edgeKind ? { kind: edgeKind } : {}), ...(edge.bidirectional ? { bidirectional: true } : {}) } satisfies EdgeData,
|
||||
attrs: {
|
||||
line: {
|
||||
stroke: kind === "mind-map" ? palette.mindMapEdge : palette.flowEdge,
|
||||
stroke: edgeStroke,
|
||||
strokeWidth: kind === "mind-map" ? 2 : 1.5,
|
||||
strokeDasharray: edgeKind === "async" ? "7 5" : undefined,
|
||||
sourceMarker: edge.bidirectional ? { name: "block", width: 8, height: 6 } : null,
|
||||
targetMarker: kind === "mind-map" ? null : { name: "block", width: 8, height: 6 },
|
||||
},
|
||||
},
|
||||
@@ -338,7 +472,7 @@ const edgeMetadata = (
|
||||
};
|
||||
|
||||
const graphToDocument = (graph: Graph, kind: DiagramDocument["kind"], theme: DiagramTheme): DiagramDocument => ({
|
||||
schemaVersion: DIAGRAM_SCHEMA_VERSION,
|
||||
schemaVersion: kind === "architecture" ? ARCHITECTURE_DIAGRAM_SCHEMA_VERSION : DIAGRAM_SCHEMA_VERSION,
|
||||
kind,
|
||||
theme,
|
||||
nodes: graph.getNodes().map((node) => {
|
||||
@@ -361,18 +495,39 @@ const graphToDocument = (graph: Graph, kind: DiagramDocument["kind"], theme: Dia
|
||||
const target = edge.getTargetCellId();
|
||||
if (!source || !target) return [];
|
||||
const label = edge.getLabels()[0]?.attrs?.label?.text;
|
||||
return [{ id: edge.id, source, target, ...(typeof label === "string" && label ? { label } : {}) }];
|
||||
const data = edge.getData<EdgeData>();
|
||||
return [{
|
||||
id: edge.id,
|
||||
source,
|
||||
target,
|
||||
...(typeof label === "string" && label ? { label } : {}),
|
||||
...(data?.kind ? { kind: data.kind } : {}),
|
||||
...(data?.bidirectional ? { bidirectional: true } : {}),
|
||||
}];
|
||||
}),
|
||||
});
|
||||
|
||||
const removeGraphSelection = (graph: Graph) => {
|
||||
const selected = graph.getSelectedCells();
|
||||
if (selected.length === 0) return false;
|
||||
const removalIds = new Set(selected.map((cell) => cell.id));
|
||||
let expanded = true;
|
||||
while (expanded) {
|
||||
expanded = false;
|
||||
for (const node of graph.getNodes()) {
|
||||
const parentId = node.getData<NodeData>()?.parentId;
|
||||
if (parentId && removalIds.has(parentId) && !removalIds.has(node.id)) {
|
||||
removalIds.add(node.id);
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const cells = [...removalIds].map((id) => graph.getCellById(id)).filter((cell) => Boolean(cell));
|
||||
graph.startBatch("remove");
|
||||
for (const cell of selected) {
|
||||
for (const cell of cells) {
|
||||
if (cell.isNode()) graph.removeConnectedEdges(cell);
|
||||
}
|
||||
graph.removeCells(selected);
|
||||
graph.removeCells(cells);
|
||||
graph.stopBatch("remove");
|
||||
return true;
|
||||
};
|
||||
@@ -382,14 +537,12 @@ const diagramEditorSnapshot = (title: string, document: DiagramDocument) => JSON
|
||||
document: {
|
||||
...document,
|
||||
theme: document.theme ?? "brand",
|
||||
nodes: document.nodes.map((node) => node.shape === "topic"
|
||||
? { ...node, ...compactMindMapNodeSize(node.label, !node.parentId) }
|
||||
: { ...node, ...compactFlowchartNodeSize(node.shape) }),
|
||||
nodes: document.nodes.map((node) => ({ ...node, ...diagramNodeSize(node, document.kind) })),
|
||||
},
|
||||
});
|
||||
|
||||
const fitDiagramContent = (graph: Graph, document: DiagramDocument, container: HTMLElement | null, padding = 32) => {
|
||||
graph.zoomToFit({ padding, maxScale: document.kind === "flowchart" ? 0.84 : 1 });
|
||||
graph.zoomToFit({ padding, maxScale: document.kind === "mind-map" ? 1 : 0.84 });
|
||||
if (!container) return;
|
||||
const anchor = document.kind === "mind-map"
|
||||
? graph.getNodes().find((node) => !node.getData<NodeData>()?.parentId)
|
||||
@@ -403,6 +556,26 @@ const fitDiagramContent = (graph: Graph, document: DiagramDocument, container: H
|
||||
graph.translate(translation.tx + desiredLeft - contentLeft, translation.ty);
|
||||
};
|
||||
|
||||
const fitArchitectureBoundaries = (graph: Graph) => {
|
||||
const nodes = graph.getNodes();
|
||||
for (const boundary of nodes.filter((node) => node.getData<NodeData>()?.shape === "boundary")) {
|
||||
const children = nodes.filter((node) => node.getData<NodeData>()?.parentId === boundary.id);
|
||||
if (children.length === 0) continue;
|
||||
const positions = new Map(children.map((node) => [node.id, node.getPosition()]));
|
||||
const boxes = children.map((node) => node.getBBox());
|
||||
const left = Math.min(...boxes.map((box) => box.x)) - 36;
|
||||
const top = Math.min(...boxes.map((box) => box.y)) - 56;
|
||||
const right = Math.max(...boxes.map((box) => box.x + box.width)) + 36;
|
||||
const bottom = Math.max(...boxes.map((box) => box.y + box.height)) + 36;
|
||||
boundary.position(left, top);
|
||||
boundary.resize(Math.max(260, right - left), Math.max(180, bottom - top));
|
||||
for (const child of children) {
|
||||
const position = positions.get(child.id);
|
||||
if (position) child.position(position.x, position.y);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const applyGraphPalette = (
|
||||
graph: Graph,
|
||||
theme: DiagramTheme,
|
||||
@@ -419,6 +592,11 @@ const applyGraphPalette = (
|
||||
const attrs = nodeAttrs(shape, theme, appearance, shape === "topic" && !data?.parentId);
|
||||
node.attr("body", attrs.body);
|
||||
node.attr("label", { ...attrs.label, text: data?.label ?? "" });
|
||||
if (kind === "architecture" && shape !== "boundary") {
|
||||
const architectureVisuals = architectureNodeVisuals(shape, node.getSize(), appearance);
|
||||
node.attr("iconFrame", architectureVisuals.attrs.iconFrame);
|
||||
node.attr("architectureIcon", architectureVisuals.attrs.architectureIcon);
|
||||
}
|
||||
for (const port of node.getPorts()) {
|
||||
if (!port.id) continue;
|
||||
node.portProp(port.id, "attrs/circle", {
|
||||
@@ -428,7 +606,8 @@ const applyGraphPalette = (
|
||||
}
|
||||
}
|
||||
for (const edge of graph.getEdges()) {
|
||||
edge.attr("line/stroke", kind === "mind-map" ? palette.mindMapEdge : palette.flowEdge);
|
||||
const edgeKind = edge.getData<EdgeData>()?.kind;
|
||||
edge.attr("line/stroke", edgeKind === "data" ? "#7C3AED" : edgeKind === "async" ? "#EA580C" : kind === "mind-map" ? palette.mindMapEdge : palette.flowEdge);
|
||||
if (edge.getLabels().length > 0) {
|
||||
edge.attr("label/fill", palette.nodeText);
|
||||
edge.attr("body/fill", palette.canvas);
|
||||
@@ -477,6 +656,8 @@ export const DiagramEditorPane = ({
|
||||
const savedSnapshotRef = useRef(document ? diagramEditorSnapshot(memo.title ?? "", document) : "");
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedNodeLabel, setSelectedNodeLabel] = useState("");
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [selectedEdgeLabel, setSelectedEdgeLabel] = useState("");
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [dirtyVersion, setDirtyVersion] = useState(0);
|
||||
@@ -540,6 +721,8 @@ export const DiagramEditorPane = ({
|
||||
useEffect(() => {
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedEdgeLabel("");
|
||||
setHasSelection(false);
|
||||
flowQuickCreateRef.current = null;
|
||||
setFlowQuickCreate(null);
|
||||
@@ -622,14 +805,14 @@ export const DiagramEditorPane = ({
|
||||
allowLoop: false,
|
||||
allowNode: false,
|
||||
allowEdge: false,
|
||||
allowPort: document.kind === "flowchart",
|
||||
allowPort: isConnectableDiagram(document.kind),
|
||||
allowMulti: false,
|
||||
highlight: document.kind === "flowchart",
|
||||
highlight: isConnectableDiagram(document.kind),
|
||||
snap: { radius: 24 },
|
||||
router: "normal",
|
||||
connector: document.kind === "mind-map" ? "smooth" : "normal",
|
||||
connector: document.kind === "mind-map" ? "smooth" : "rounded",
|
||||
validateConnection: ({ sourceCell, targetCell, sourcePort, targetPort }) => {
|
||||
if (document.kind !== "flowchart" || !sourceCell || !sourcePort) return false;
|
||||
if (!isConnectableDiagram(document.kind) || !sourceCell || !sourcePort) return false;
|
||||
if (!targetCell) return true;
|
||||
return Boolean(targetPort && sourceCell.id !== targetCell.id);
|
||||
},
|
||||
@@ -661,6 +844,13 @@ export const DiagramEditorPane = ({
|
||||
}));
|
||||
graph.use(new Selection({ enabled: true, multiple: true, rubberband: true, movable: !readOnly, showNodeSelectionBox: true, showEdgeSelectionBox: true }));
|
||||
graph.addNodes(document.nodes.map((node) => nodeMetadata(node, documentTheme, document.kind, appearance)));
|
||||
if (document.kind === "architecture") {
|
||||
for (const node of graph.getNodes()) {
|
||||
const parentId = node.getData<NodeData>()?.parentId;
|
||||
const parent = parentId ? graph.getCellById(parentId) : null;
|
||||
if (parent?.isNode()) parent.addChild(node);
|
||||
}
|
||||
}
|
||||
graph.addEdges(document.edges.map((edge) => edgeMetadata(edge, document.kind, documentTheme, appearance)));
|
||||
graph.cleanHistory();
|
||||
fitDiagramContent(graph, document, containerRef.current);
|
||||
@@ -678,9 +868,11 @@ export const DiagramEditorPane = ({
|
||||
const clearSelectionAfterHistory = () => {
|
||||
applyGraphPalette(graph, themeRef.current, document.kind, appearanceRef.current);
|
||||
graph.cleanSelection();
|
||||
if (document.kind === "flowchart") setOnlyFlowNodePortsActive(graph);
|
||||
if (isConnectableDiagram(document.kind)) setOnlyFlowNodePortsActive(graph);
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedEdgeLabel("");
|
||||
setHasSelection(false);
|
||||
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
|
||||
titleRef.current,
|
||||
@@ -692,33 +884,39 @@ export const DiagramEditorPane = ({
|
||||
graph.on("history:undo", clearSelectionAfterHistory);
|
||||
graph.on("history:redo", clearSelectionAfterHistory);
|
||||
graph.on("node:selected", ({ node }: { node: Node }) => {
|
||||
if (document.kind === "flowchart") setFlowNodePortsActive(graph, node, true);
|
||||
if (isConnectableDiagram(document.kind)) setFlowNodePortsActive(graph, node, true);
|
||||
});
|
||||
graph.on("node:unselected", ({ node }: { node: Node }) => {
|
||||
if (document.kind === "flowchart") setFlowNodePortsActive(graph, node, false);
|
||||
if (isConnectableDiagram(document.kind)) setFlowNodePortsActive(graph, node, false);
|
||||
});
|
||||
graph.on("node:click", ({ node }: { node: Node }) => {
|
||||
const data = node.getData<NodeData>();
|
||||
dismissFlowQuickCreate();
|
||||
if (document.kind === "flowchart") setOnlyFlowNodePortsActive(graph, node);
|
||||
if (isConnectableDiagram(document.kind)) setOnlyFlowNodePortsActive(graph, node);
|
||||
containerRef.current?.focus({ preventScroll: true });
|
||||
setSelectedNodeId(node.id);
|
||||
setSelectedNodeLabel(data?.label ?? "");
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedEdgeLabel("");
|
||||
setHasSelection(true);
|
||||
});
|
||||
graph.on("node:dblclick", ({ node }: { node: Node }) => beginNodeEdit(node));
|
||||
graph.on("edge:click", () => {
|
||||
graph.on("edge:click", ({ edge }: { edge: Edge }) => {
|
||||
dismissFlowQuickCreate();
|
||||
if (document.kind === "flowchart") setOnlyFlowNodePortsActive(graph);
|
||||
if (isConnectableDiagram(document.kind)) setOnlyFlowNodePortsActive(graph);
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
setSelectedEdgeId(edge.id);
|
||||
setSelectedEdgeLabel(String(edge.getLabels()[0]?.attrs?.label?.text ?? ""));
|
||||
setHasSelection(true);
|
||||
});
|
||||
graph.on("blank:click", () => {
|
||||
dismissFlowQuickCreate();
|
||||
if (document.kind === "flowchart") setOnlyFlowNodePortsActive(graph);
|
||||
if (isConnectableDiagram(document.kind)) setOnlyFlowNodePortsActive(graph);
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedEdgeLabel("");
|
||||
setHasSelection(false);
|
||||
});
|
||||
graph.on("edge:removed", ({ edge }: { edge: Edge }) => {
|
||||
@@ -778,7 +976,7 @@ export const DiagramEditorPane = ({
|
||||
};
|
||||
openFlowQuickCreateRef.current = openFlowQuickCreate;
|
||||
graph.on("edge:connected", ({ edge, isNew, type, currentCell, currentPort, currentPoint }) => {
|
||||
if (document.kind !== "flowchart" || !isNew || type !== "target") return;
|
||||
if (!isConnectableDiagram(document.kind) || !isNew || type !== "target") return;
|
||||
const sourceNodeId = edge.getSourceCellId();
|
||||
const source = edge.getSource();
|
||||
const draft = edge.getData<{ restoreHistory?: boolean }>();
|
||||
@@ -874,6 +1072,8 @@ export const DiagramEditorPane = ({
|
||||
if (!removeGraphSelection(graph)) return;
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedEdgeLabel("");
|
||||
setHasSelection(false);
|
||||
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
|
||||
titleRef.current,
|
||||
@@ -895,11 +1095,11 @@ export const DiagramEditorPane = ({
|
||||
const selected = graph.getSelectedCells().find((cell) => cell.isNode());
|
||||
if (document.kind === "mind-map") {
|
||||
insertNodeRef.current("child", selected?.id);
|
||||
} else if (selected?.isNode()) {
|
||||
} else if (document.kind === "flowchart" && selected?.isNode()) {
|
||||
openFlowQuickCreate(selected);
|
||||
}
|
||||
});
|
||||
if (document.kind === "flowchart") {
|
||||
if (isConnectableDiagram(document.kind)) {
|
||||
graph.bindKey(["meta+d", "ctrl+d"], (event) => {
|
||||
event.preventDefault();
|
||||
const selectedNodes = graph.getSelectedCells().filter((cell): cell is Node => cell.isNode());
|
||||
@@ -908,15 +1108,19 @@ export const DiagramEditorPane = ({
|
||||
const duplicates = selectedNodes.map((node) => {
|
||||
const data = node.getData<NodeData>();
|
||||
const position = node.getPosition();
|
||||
return graph.addNode(nodeMetadata({
|
||||
const duplicate = graph.addNode(nodeMetadata({
|
||||
id: createId("node"),
|
||||
label: data?.label ?? t("diagram.newStep"),
|
||||
label: data?.label ?? (document.kind === "architecture" ? t("diagram.newService") : t("diagram.newStep")),
|
||||
x: position.x + 24,
|
||||
y: position.y + 24,
|
||||
width: node.getSize().width,
|
||||
height: node.getSize().height,
|
||||
shape: data?.shape ?? "process",
|
||||
...(data?.parentId ? { parentId: data.parentId } : {}),
|
||||
}, themeRef.current, document.kind, appearanceRef.current));
|
||||
const parent = data?.parentId ? graph.getCellById(data.parentId) : null;
|
||||
if (parent?.isNode()) parent.addChild(duplicate);
|
||||
return duplicate;
|
||||
});
|
||||
graph.stopBatch("duplicate");
|
||||
graph.cleanSelection();
|
||||
@@ -964,6 +1168,8 @@ export const DiagramEditorPane = ({
|
||||
graph.cleanSelection();
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedEdgeLabel("");
|
||||
setHasSelection(false);
|
||||
setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() });
|
||||
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
|
||||
@@ -978,6 +1184,8 @@ export const DiagramEditorPane = ({
|
||||
graph.cleanSelection();
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedEdgeLabel("");
|
||||
setHasSelection(false);
|
||||
setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() });
|
||||
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
|
||||
@@ -1031,6 +1239,7 @@ export const DiagramEditorPane = ({
|
||||
const selectedPosition = selected?.isNode() ? selected.getPosition() : { x: 120, y: 120 };
|
||||
const selectedSize = selected?.isNode() ? selected.getSize() : { width: 140, height: 52 };
|
||||
const isMindMap = document.kind === "mind-map";
|
||||
const isArchitecture = document.kind === "architecture";
|
||||
const selectedData = selected?.isNode() ? selected.getData<NodeData>() : undefined;
|
||||
const requestedSibling = isMindMap && options.relation === "sibling" && Boolean(selectedData?.parentId);
|
||||
const parent = requestedSibling
|
||||
@@ -1054,17 +1263,28 @@ export const DiagramEditorPane = ({
|
||||
: selectedPosition.y,
|
||||
};
|
||||
const id = createId(isMindMap ? "topic" : "node");
|
||||
const architectureParentId = isArchitecture && shape !== "boundary"
|
||||
? selectedData?.shape === "boundary" ? selected?.id : selectedData?.parentId
|
||||
: undefined;
|
||||
const authoredSize = isArchitecture
|
||||
? compactArchitectureNodeSize(shape)
|
||||
: { width: shape === "decision" ? 132 : 140, height: shape === "decision" ? 84 : 52 };
|
||||
graph.startBatch("add");
|
||||
const node = graph.addNode(nodeMetadata({
|
||||
id,
|
||||
label: isMindMap ? t("diagram.newTopic") : t("diagram.newStep"),
|
||||
label: isMindMap ? t("diagram.newTopic") : isArchitecture ? architectureNodeLabel(shape, t) : t("diagram.newStep"),
|
||||
x: nextPosition.x,
|
||||
y: nextPosition.y,
|
||||
width: shape === "decision" ? 132 : 140,
|
||||
height: shape === "decision" ? 84 : 52,
|
||||
width: authoredSize.width,
|
||||
height: authoredSize.height,
|
||||
shape: isMindMap ? "topic" : shape,
|
||||
...(isMindMap && parent?.isNode() ? { parentId: parent.id } : {}),
|
||||
...(architectureParentId ? { parentId: architectureParentId } : {}),
|
||||
}, themeRef.current, document.kind, appearanceRef.current));
|
||||
if (architectureParentId) {
|
||||
const architectureParent = graph.getCellById(architectureParentId);
|
||||
if (architectureParent?.isNode()) architectureParent.addChild(node);
|
||||
}
|
||||
if (isMindMap && parent?.isNode()) {
|
||||
graph.addEdge(edgeMetadata(
|
||||
{ id: createId("branch"), source: parent.id, target: id },
|
||||
@@ -1090,6 +1310,8 @@ export const DiagramEditorPane = ({
|
||||
graph.select(node);
|
||||
setSelectedNodeId(id);
|
||||
setSelectedNodeLabel(node.getData<NodeData>().label);
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedEdgeLabel("");
|
||||
setHasSelection(true);
|
||||
setDirty(true);
|
||||
setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() });
|
||||
@@ -1110,6 +1332,21 @@ export const DiagramEditorPane = ({
|
||||
node.attr("label/text", label);
|
||||
};
|
||||
|
||||
const updateSelectedEdgeLabel = (label: string) => {
|
||||
setSelectedEdgeLabel(label);
|
||||
const edge = selectedEdgeId ? graphRef.current?.getCellById(selectedEdgeId) : null;
|
||||
if (!edge?.isEdge() || readOnly) return;
|
||||
if (!label) {
|
||||
edge.setLabels([]);
|
||||
return;
|
||||
}
|
||||
const palette = resolveDiagramPalette(themeRef.current, appearanceRef.current);
|
||||
edge.setLabels([{ attrs: {
|
||||
label: { text: label, fill: palette.nodeText, fontSize: 12 },
|
||||
body: { fill: palette.canvas, stroke: palette.nodeStroke, strokeWidth: 1, rx: 5, ry: 5 },
|
||||
} }]);
|
||||
};
|
||||
|
||||
const createConnectedFlowNode = (shape: DiagramNodeShape) => {
|
||||
const graph = graphRef.current;
|
||||
const pending = flowQuickCreate;
|
||||
@@ -1167,6 +1404,8 @@ export const DiagramEditorPane = ({
|
||||
if (!removeGraphSelection(graph)) return;
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedEdgeLabel("");
|
||||
setHasSelection(false);
|
||||
if (document) {
|
||||
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
|
||||
@@ -1191,6 +1430,8 @@ export const DiagramEditorPane = ({
|
||||
graph.cleanSelection();
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedEdgeLabel("");
|
||||
setHasSelection(false);
|
||||
setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() });
|
||||
if (document) {
|
||||
@@ -1216,6 +1457,7 @@ export const DiagramEditorPane = ({
|
||||
node.position(position.x, position.y);
|
||||
}
|
||||
}
|
||||
if (document.kind === "architecture") fitArchitectureBoundaries(graph);
|
||||
graph.stopBatch("layout");
|
||||
fitDiagramContent(graph, document, containerRef.current, 40);
|
||||
if (changed) {
|
||||
@@ -1248,7 +1490,7 @@ export const DiagramEditorPane = ({
|
||||
const exportDiagram = (format: "png" | "svg") => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || !document) return;
|
||||
const fallbackName = document.kind === "mind-map" ? t("diagram.mindMap") : t("diagram.flowchart");
|
||||
const fallbackName = document.kind === "mind-map" ? t("diagram.mindMap") : document.kind === "architecture" ? t("diagram.architecture") : t("diagram.flowchart");
|
||||
const fileName = (title.trim() || fallbackName).replace(/[\\/:*?"<>|]/g, "-").slice(0, 80);
|
||||
setSaveError(null);
|
||||
try {
|
||||
@@ -1342,7 +1584,7 @@ export const DiagramEditorPane = ({
|
||||
};
|
||||
|
||||
if (!document) return null;
|
||||
const kindLabel = document.kind === "mind-map" ? t("diagram.mindMap") : t("diagram.flowchart");
|
||||
const kindLabel = document.kind === "mind-map" ? t("diagram.mindMap") : document.kind === "architecture" ? t("diagram.architecture") : t("diagram.flowchart");
|
||||
const updatedLabel = formatDateTime(memo.updatedAt);
|
||||
const currentMarkdown = historyOpen
|
||||
? serializeDiagramDocument(graphRef.current ? graphToDocument(graphRef.current, document.kind, themeRef.current) : document)
|
||||
@@ -1525,6 +1767,32 @@ export const DiagramEditorPane = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("diagram.mindMapShortcuts")}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : document.kind === "architecture" ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="soft" onClick={() => addNode("service")}><Server className="h-4 w-4" />{t("diagram.addService")}</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("diagram.architectureShortcuts")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline"><Boxes className="h-4 w-4" />{t("diagram.components")}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onSelect={() => addNode("client")}><Network className="h-4 w-4" />{t("diagram.addClient")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addNode("frontend")}><Globe2 className="h-4 w-4" />{t("diagram.addFrontend")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addNode("database")}><Database className="h-4 w-4" />{t("diagram.addDatabase")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addNode("storage")}><HardDrive className="h-4 w-4" />{t("diagram.addStorage")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addNode("queue")}><GitBranch className="h-4 w-4" />{t("diagram.addQueue")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addNode("security")}><ShieldCheck className="h-4 w-4" />{t("diagram.addSecurity")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addNode("external")}><Globe2 className="h-4 w-4" />{t("diagram.addExternal")}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => addNode("boundary")}><Box className="h-4 w-4" />{t("diagram.addBoundary")}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="hidden items-center gap-1.5 px-2 text-xs text-slate-500 xl:flex"><Link2 className="h-3.5 w-3.5" />{t("diagram.architectureConnectHint")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip>
|
||||
@@ -1573,6 +1841,12 @@ export const DiagramEditorPane = ({
|
||||
<Input value={selectedNodeLabel} maxLength={120} onChange={(event) => updateSelectedLabel(event.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
{selectedEdgeId && !readOnly && (
|
||||
<div className="ml-auto flex min-w-[220px] flex-1 items-center gap-2 sm:max-w-sm">
|
||||
<span className="shrink-0 text-xs font-medium text-slate-500">{t("diagram.edgeText")}</span>
|
||||
<Input value={selectedEdgeLabel} maxLength={80} onChange={(event) => updateSelectedEdgeLabel(event.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={containerRef} className="edgeever-diagram-canvas absolute inset-0 touch-none outline-none" data-diagram-appearance={resolvedTheme} data-diagram-kind={document.kind} data-diagram-theme={theme} tabIndex={0} aria-label={t("diagram.canvas", { type: kindLabel })} />
|
||||
@@ -1663,7 +1937,7 @@ export const DiagramEditorPane = ({
|
||||
if (editedNode) {
|
||||
requestAnimationFrame(() => {
|
||||
if (document.kind === "mind-map") insertNodeRef.current("child", editedNode.id);
|
||||
else openFlowQuickCreateRef.current(editedNode);
|
||||
else if (document.kind === "flowchart") openFlowQuickCreateRef.current(editedNode);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ test("marks diagram note types as beta without labeling regular notes", () => {
|
||||
const betaBadgeCount = createTypeMenu?.match(/<DiagramBetaBadge \/>/g)?.length;
|
||||
|
||||
expect(regularNoteItem).not.toContain("DiagramBetaBadge");
|
||||
expect(betaBadgeCount).toBe(2);
|
||||
expect(createTypeMenu).toContain('onCreateMemo("architecture")');
|
||||
expect(betaBadgeCount).toBe(3);
|
||||
});
|
||||
|
||||
describe("NotebookPane client downloads", () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LayoutList,
|
||||
LayoutTemplate,
|
||||
BookPlus,
|
||||
Boxes,
|
||||
ArrowDownWideNarrow,
|
||||
Notebook as NotebookIcon,
|
||||
Tags,
|
||||
@@ -606,6 +607,11 @@ export const NotebookPane = ({
|
||||
{t("diagram.flowchart")}
|
||||
<DiagramBetaBadge />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onCreateMemo("architecture")}>
|
||||
<Boxes className="h-4 w-4" />
|
||||
{t("diagram.architecture")}
|
||||
<DiagramBetaBadge />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "react";
|
||||
import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import type { PluginPanelOpenOptions } from "@edgeever/plugin-api";
|
||||
import { Home, Search, UserRound, Plus, ChevronDown, ChevronRight, RefreshCw, X, FileText, Network, Workflow } from "lucide-react";
|
||||
import { Home, Search, UserRound, Plus, ChevronDown, ChevronRight, RefreshCw, X, FileText, Network, Workflow, Boxes } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as m from "motion/react-m";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -395,6 +395,7 @@ const MobileBottomNav = ({
|
||||
<DropdownMenuItem onSelect={() => onCreateMemo()}><FileText className="h-4 w-4" />{t("diagram.normalNote")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onCreateMemo("mind-map")}><Network className="h-4 w-4" />{t("diagram.mindMap")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onCreateMemo("flowchart")}><Workflow className="h-4 w-4" />{t("diagram.flowchart")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onCreateMemo("architecture")}><Boxes className="h-4 w-4" />{t("diagram.architecture")}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<MobileBottomNavButton active={activeItem === "settings"} icon={<UserRound className="h-5 w-5" />} label={t("nav.mine")} onClick={onOpenSettings} />
|
||||
@@ -2128,7 +2129,7 @@ export const WorkspaceApp = ({
|
||||
const diagram = kind ? createDefaultDiagramDocument(kind) : null;
|
||||
createMemoMutation.mutate({
|
||||
notebookId: targetNotebookId,
|
||||
title: diagram ? (kind === "mind-map" ? t("diagram.mindMap") : t("diagram.flowchart")) : "",
|
||||
title: diagram ? (kind === "mind-map" ? t("diagram.mindMap") : kind === "architecture" ? t("diagram.architecture") : t("diagram.flowchart")) : "",
|
||||
contentJson: diagram ? markdownToDoc(diagramFallbackMarkdown(diagram)) : undefined,
|
||||
contentMarkdown: diagram ? serializeDiagramDocument(diagram) : "",
|
||||
tags: [],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createDefaultDiagramDocument } from "@edgeever/shared";
|
||||
import { compactFlowchartNodeSize, compactMindMapNodeSize, computeDiagramLayout } from "./diagram-layout.ts";
|
||||
import { compactArchitectureNodeSize, compactFlowchartNodeSize, compactMindMapNodeSize, computeDiagramLayout } from "./diagram-layout.ts";
|
||||
|
||||
describe("diagram auto layout", () => {
|
||||
test("places mind-map children to the right of their root", () => {
|
||||
@@ -64,4 +64,14 @@ describe("diagram auto layout", () => {
|
||||
expect(compactFlowchartNodeSize("terminator")).toEqual({ width: 116, height: 44 });
|
||||
expect(compactFlowchartNodeSize("decision")).toEqual({ width: 116, height: 72 });
|
||||
});
|
||||
|
||||
test("lays out architecture components while preserving authored boundary geometry", () => {
|
||||
const document = createDefaultDiagramDocument("architecture");
|
||||
const positions = computeDiagramLayout(document);
|
||||
expect(positions.client.x).toBeLessThan(positions.api.x);
|
||||
expect(positions.api.x).toBeLessThan(positions.database.x);
|
||||
expect(positions.system).toEqual({ x: 220, y: 64 });
|
||||
expect(compactArchitectureNodeSize("database")).toEqual({ width: 150, height: 72 });
|
||||
expect(compactArchitectureNodeSize("boundary", { width: 640, height: 360 })).toEqual({ width: 640, height: 360 });
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,17 @@ export const compactFlowchartNodeSize = (shape: DiagramDocument["nodes"][number]
|
||||
: { width: 124, height: 44 }
|
||||
);
|
||||
|
||||
export const compactArchitectureNodeSize = (
|
||||
shape: DiagramDocument["nodes"][number]["shape"],
|
||||
authored?: { width: number; height: number },
|
||||
) => {
|
||||
if (shape === "boundary") return authored ?? { width: 560, height: 320 };
|
||||
if (shape === "database") return { width: 150, height: 72 };
|
||||
if (shape === "queue") return { width: 156, height: 60 };
|
||||
if (shape === "security") return { width: 148, height: 68 };
|
||||
return { width: 156, height: 64 };
|
||||
};
|
||||
|
||||
const computeMindMapLayout = (
|
||||
document: DiagramDocument,
|
||||
options: DiagramLayoutOptions,
|
||||
@@ -133,15 +144,22 @@ export const computeDiagramLayout = (
|
||||
});
|
||||
layoutGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
for (const node of document.nodes) {
|
||||
const layoutNodes = document.kind === "architecture"
|
||||
? document.nodes.filter((node) => node.shape !== "boundary")
|
||||
: document.nodes;
|
||||
const layoutNodeIds = new Set(layoutNodes.map((node) => node.id));
|
||||
for (const node of layoutNodes) {
|
||||
layoutGraph.setNode(node.id, { width: node.width, height: node.height });
|
||||
}
|
||||
for (const edge of document.edges) {
|
||||
layoutGraph.setEdge(edge.source, edge.target);
|
||||
if (layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target)) {
|
||||
layoutGraph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
}
|
||||
|
||||
runDagreLayout(layoutGraph);
|
||||
return Object.fromEntries(document.nodes.flatMap((node) => {
|
||||
if (node.shape === "boundary") return [[node.id, { x: node.x, y: node.y }]];
|
||||
const position = layoutGraph.node(node.id) as { x: number; y: number } | undefined;
|
||||
if (!position) return [];
|
||||
return [[node.id, {
|
||||
|
||||
@@ -499,7 +499,8 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
box-shadow: 0 2px 10px rgb(104 214 176 / 0.22) !important;
|
||||
}
|
||||
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .x6-widget-selection-box-node {
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .x6-widget-selection-box-node,
|
||||
.edgeever-diagram-canvas[data-diagram-kind="architecture"] .x6-widget-selection-box-node {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
@@ -508,25 +509,29 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
transition: opacity 120ms ease, filter 120ms ease;
|
||||
}
|
||||
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .x6-port-body {
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .x6-port-body,
|
||||
.edgeever-diagram-canvas[data-diagram-kind="architecture"] .x6-port-body {
|
||||
cursor: crosshair;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .edgeever-flow-port-hit-area {
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .edgeever-flow-port-hit-area,
|
||||
.edgeever-diagram-canvas[data-diagram-kind="architecture"] .edgeever-flow-port-hit-area {
|
||||
fill: transparent;
|
||||
stroke: transparent;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .edgeever-flow-port-dot {
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .edgeever-flow-port-dot,
|
||||
.edgeever-diagram-canvas[data-diagram-kind="architecture"] .edgeever-flow-port-dot {
|
||||
pointer-events: none;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
transition: filter 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .x6-node.edgeever-flow-node-active .edgeever-flow-port-dot {
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .x6-node.edgeever-flow-node-active .edgeever-flow-port-dot,
|
||||
.edgeever-diagram-canvas[data-diagram-kind="architecture"] .x6-node.edgeever-flow-node-active .edgeever-flow-port-dot {
|
||||
fill: var(--brand-green) !important;
|
||||
stroke: var(--workspace-editor) !important;
|
||||
stroke-width: 2px !important;
|
||||
@@ -534,7 +539,9 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
}
|
||||
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .x6-port-body:hover .edgeever-flow-port-dot,
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .x6-available-magnet + .edgeever-flow-port-dot {
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .x6-available-magnet + .edgeever-flow-port-dot,
|
||||
.edgeever-diagram-canvas[data-diagram-kind="architecture"] .x6-port-body:hover .edgeever-flow-port-dot,
|
||||
.edgeever-diagram-canvas[data-diagram-kind="architecture"] .x6-available-magnet + .edgeever-flow-port-dot {
|
||||
filter: drop-shadow(0 1px 3px rgb(var(--brand-green-rgb) / 0.28));
|
||||
transform: scale(1.12);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createDefaultDiagramDocument, parseDiagramDocument, serializeDiagramDocument } from "./diagram.ts";
|
||||
import { createDefaultDiagramDocument, diagramDocumentToMermaid, diagramFallbackMarkdown, parseDiagramDocument, serializeDiagramDocument } from "./diagram.ts";
|
||||
import { markdownToDoc } from "./content.ts";
|
||||
|
||||
describe("diagram document", () => {
|
||||
test("round-trips unicode labels through the Markdown compatibility envelope", () => {
|
||||
@@ -9,10 +10,33 @@ describe("diagram document", () => {
|
||||
expect(parseDiagramDocument(serializeDiagramDocument(document))).toEqual(document);
|
||||
});
|
||||
|
||||
test("keeps a readable Markdown fallback", () => {
|
||||
test("persists a Mermaid fallback that native app viewers can render", () => {
|
||||
const markdown = serializeDiagramDocument(createDefaultDiagramDocument("flowchart"));
|
||||
expect(markdown).toContain("# 流程图");
|
||||
expect(markdown).toContain("- 处理步骤");
|
||||
expect(markdown).toContain("```mermaid\nflowchart TD");
|
||||
expect(markdown).toContain('n1["处理步骤"]');
|
||||
|
||||
const doc = markdownToDoc(markdown);
|
||||
expect(doc.content?.some((node) => node.type === "codeBlock" && node.attrs?.language === "mermaid")).toBe(true);
|
||||
});
|
||||
|
||||
test("escapes labels and emits the mind-map hierarchy as a portable flowchart", () => {
|
||||
const document = createDefaultDiagramDocument("mind-map");
|
||||
document.nodes[0].label = '核心 <主题> "A&B"';
|
||||
const source = diagramDocumentToMermaid(document);
|
||||
expect(source).toContain("flowchart LR");
|
||||
expect(source).toContain("核心 <主题> "A&B"");
|
||||
expect(source).toContain("n0 --> n1");
|
||||
});
|
||||
|
||||
test("round-trips architecture components, boundaries, and semantic connections", () => {
|
||||
const document = createDefaultDiagramDocument("architecture");
|
||||
const parsed = parseDiagramDocument(serializeDiagramDocument(document));
|
||||
expect(parsed).toEqual(document);
|
||||
expect(parsed.schemaVersion).toBe(2);
|
||||
expect(parsed.nodes.find((node) => node.id === "api").parentId).toBe("system");
|
||||
expect(parsed.edges.find((edge) => edge.id === "request").kind).toBe("request");
|
||||
expect(diagramFallbackMarkdown(document)).toContain("# 架构图");
|
||||
});
|
||||
|
||||
test("rejects malformed and dangling graph data", () => {
|
||||
@@ -28,5 +52,9 @@ describe("diagram document", () => {
|
||||
const invalidTheme = createDefaultDiagramDocument("mind-map");
|
||||
invalidTheme.theme = "neon";
|
||||
expect(parseDiagramDocument(serializeDiagramDocument(invalidTheme))).toBeNull();
|
||||
|
||||
const architecture = createDefaultDiagramDocument("architecture");
|
||||
architecture.nodes.find((node) => node.id === "api").parentId = "database";
|
||||
expect(parseDiagramDocument(serializeDiagramDocument(architecture))).toBeNull();
|
||||
});
|
||||
});
|
||||
+123
-15
@@ -1,7 +1,22 @@
|
||||
export const DIAGRAM_SCHEMA_VERSION = 1 as const;
|
||||
export const ARCHITECTURE_DIAGRAM_SCHEMA_VERSION = 2 as const;
|
||||
|
||||
export type DiagramKind = "mind-map" | "flowchart";
|
||||
export type DiagramNodeShape = "topic" | "process" | "decision" | "terminator";
|
||||
export type DiagramKind = "mind-map" | "flowchart" | "architecture";
|
||||
export type DiagramNodeShape =
|
||||
| "topic"
|
||||
| "process"
|
||||
| "decision"
|
||||
| "terminator"
|
||||
| "client"
|
||||
| "frontend"
|
||||
| "service"
|
||||
| "database"
|
||||
| "storage"
|
||||
| "queue"
|
||||
| "security"
|
||||
| "external"
|
||||
| "boundary";
|
||||
export type DiagramEdgeKind = "dependency" | "request" | "async" | "data";
|
||||
export type DiagramTheme = "brand" | "ocean" | "ink";
|
||||
|
||||
export type DiagramNode = {
|
||||
@@ -20,10 +35,12 @@ export type DiagramEdge = {
|
||||
source: string;
|
||||
target: string;
|
||||
label?: string;
|
||||
kind?: DiagramEdgeKind;
|
||||
bidirectional?: boolean;
|
||||
};
|
||||
|
||||
export type DiagramDocument = {
|
||||
schemaVersion: typeof DIAGRAM_SCHEMA_VERSION;
|
||||
schemaVersion: typeof DIAGRAM_SCHEMA_VERSION | typeof ARCHITECTURE_DIAGRAM_SCHEMA_VERSION;
|
||||
kind: DiagramKind;
|
||||
theme?: DiagramTheme;
|
||||
nodes: DiagramNode[];
|
||||
@@ -50,6 +67,24 @@ const decodeBase64Url = (value: string) => {
|
||||
const isFiniteNumber = (value: unknown): value is number =>
|
||||
typeof value === "number" && Number.isFinite(value);
|
||||
|
||||
const DIAGRAM_KINDS = ["mind-map", "flowchart", "architecture"] as const;
|
||||
const DIAGRAM_NODE_SHAPES: DiagramNodeShape[] = [
|
||||
"topic",
|
||||
"process",
|
||||
"decision",
|
||||
"terminator",
|
||||
"client",
|
||||
"frontend",
|
||||
"service",
|
||||
"database",
|
||||
"storage",
|
||||
"queue",
|
||||
"security",
|
||||
"external",
|
||||
"boundary",
|
||||
];
|
||||
const DIAGRAM_EDGE_KINDS: DiagramEdgeKind[] = ["dependency", "request", "async", "data"];
|
||||
|
||||
const parseNode = (value: unknown): DiagramNode | null => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const node = value as Record<string, unknown>;
|
||||
@@ -57,7 +92,7 @@ const parseNode = (value: unknown): DiagramNode | null => {
|
||||
typeof node.id !== "string" || !node.id || typeof node.label !== "string"
|
||||
|| !isFiniteNumber(node.x) || !isFiniteNumber(node.y)
|
||||
|| !isFiniteNumber(node.width) || !isFiniteNumber(node.height)
|
||||
|| !["topic", "process", "decision", "terminator"].includes(String(node.shape))
|
||||
|| !DIAGRAM_NODE_SHAPES.includes(node.shape as DiagramNodeShape)
|
||||
) return null;
|
||||
return {
|
||||
id: node.id,
|
||||
@@ -74,12 +109,18 @@ const parseNode = (value: unknown): DiagramNode | null => {
|
||||
const parseEdge = (value: unknown): DiagramEdge | null => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const edge = value as Record<string, unknown>;
|
||||
if (typeof edge.id !== "string" || !edge.id || typeof edge.source !== "string" || typeof edge.target !== "string") return null;
|
||||
if (
|
||||
typeof edge.id !== "string" || !edge.id || typeof edge.source !== "string" || typeof edge.target !== "string"
|
||||
|| (edge.kind !== undefined && !DIAGRAM_EDGE_KINDS.includes(edge.kind as DiagramEdgeKind))
|
||||
|| (edge.bidirectional !== undefined && typeof edge.bidirectional !== "boolean")
|
||||
) return null;
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
...(typeof edge.label === "string" && edge.label ? { label: edge.label } : {}),
|
||||
...(DIAGRAM_EDGE_KINDS.includes(edge.kind as DiagramEdgeKind) ? { kind: edge.kind as DiagramEdgeKind } : {}),
|
||||
...(typeof edge.bidirectional === "boolean" ? { bidirectional: edge.bidirectional } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -88,7 +129,11 @@ export const parseDiagramDocument = (markdown: string | null | undefined): Diagr
|
||||
if (!encoded) return null;
|
||||
try {
|
||||
const value = JSON.parse(decodeBase64Url(encoded)) as Record<string, unknown>;
|
||||
if (value.schemaVersion !== DIAGRAM_SCHEMA_VERSION || (value.kind !== "mind-map" && value.kind !== "flowchart")) return null;
|
||||
if (!DIAGRAM_KINDS.includes(value.kind as DiagramKind)) return null;
|
||||
const isArchitecture = value.kind === "architecture";
|
||||
if (isArchitecture
|
||||
? value.schemaVersion !== ARCHITECTURE_DIAGRAM_SCHEMA_VERSION
|
||||
: value.schemaVersion !== DIAGRAM_SCHEMA_VERSION) return null;
|
||||
if (
|
||||
!Array.isArray(value.nodes)
|
||||
|| !Array.isArray(value.edges)
|
||||
@@ -98,14 +143,22 @@ export const parseDiagramDocument = (markdown: string | null | undefined): Diagr
|
||||
const edges = value.edges.map(parseEdge);
|
||||
if (nodes.some((node) => !node) || edges.some((edge) => !edge)) return null;
|
||||
const nodeIds = new Set((nodes as DiagramNode[]).map((node) => node.id));
|
||||
const nodeById = new Map((nodes as DiagramNode[]).map((node) => [node.id, node]));
|
||||
if (
|
||||
nodeIds.size !== nodes.length
|
||||
|| (nodes as DiagramNode[]).some((node) => node.parentId && (node.parentId === node.id || !nodeIds.has(node.parentId)))
|
||||
|| (edges as DiagramEdge[]).some((edge) => !nodeIds.has(edge.source) || !nodeIds.has(edge.target))
|
||||
) return null;
|
||||
if (isArchitecture) {
|
||||
if ((nodes as DiagramNode[]).some((node) => node.shape === "topic" || node.shape === "process" || node.shape === "decision" || node.shape === "terminator")) return null;
|
||||
if ((nodes as DiagramNode[]).some((node) => node.parentId && nodeById.get(node.parentId)?.shape !== "boundary")) return null;
|
||||
if ((edges as DiagramEdge[]).some((edge) => nodeById.get(edge.source)?.shape === "boundary" || nodeById.get(edge.target)?.shape === "boundary")) return null;
|
||||
} else if ((nodes as DiagramNode[]).some((node) => !["topic", "process", "decision", "terminator"].includes(node.shape))) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
schemaVersion: DIAGRAM_SCHEMA_VERSION,
|
||||
kind: value.kind,
|
||||
schemaVersion: value.schemaVersion as DiagramDocument["schemaVersion"],
|
||||
kind: value.kind as DiagramKind,
|
||||
...(value.theme ? { theme: value.theme as DiagramTheme } : {}),
|
||||
nodes: nodes as DiagramNode[],
|
||||
edges: edges as DiagramEdge[],
|
||||
@@ -116,13 +169,50 @@ export const parseDiagramDocument = (markdown: string | null | undefined): Diagr
|
||||
};
|
||||
|
||||
export const diagramFallbackMarkdown = (document: DiagramDocument) => {
|
||||
const title = document.kind === "mind-map" ? "思维导图" : "流程图";
|
||||
const connectedTargets = new Set(document.edges.map((edge) => edge.target));
|
||||
const orderedNodes = [
|
||||
...document.nodes.filter((node) => !connectedTargets.has(node.id)),
|
||||
...document.nodes.filter((node) => connectedTargets.has(node.id)),
|
||||
];
|
||||
return [`# ${title}`, "", ...orderedNodes.map((node) => `- ${node.label.replace(/\s+/g, " ").trim() || "未命名节点"}`)].join("\n");
|
||||
const title = document.kind === "mind-map" ? "思维导图" : document.kind === "architecture" ? "架构图" : "流程图";
|
||||
return [`# ${title}`, "", "```mermaid", diagramDocumentToMermaid(document), "```"].join("\n");
|
||||
};
|
||||
|
||||
const escapeMermaidLabel = (label: string) =>
|
||||
(label.replace(/\s+/g, " ").trim() || "未命名节点")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
/**
|
||||
* Portable rendering envelope for clients that do not ship the interactive X6
|
||||
* diagram editor. Both native apps already render Mermaid inside their TipTap
|
||||
* WebViews, so keeping this in the persisted Markdown makes diagram notes
|
||||
* visible there without a second graph renderer.
|
||||
*/
|
||||
export const diagramDocumentToMermaid = (document: DiagramDocument) => {
|
||||
const nodeIds = new Map(document.nodes.map((node, index) => [node.id, `n${index}`]));
|
||||
const direction = document.kind === "mind-map" ? "LR" : "TD";
|
||||
const lines = [`flowchart ${direction}`];
|
||||
|
||||
for (const node of document.nodes) {
|
||||
const id = nodeIds.get(node.id)!;
|
||||
const label = escapeMermaidLabel(node.label);
|
||||
const declaration = node.shape === "decision"
|
||||
? `${id}{"${label}"}`
|
||||
: node.shape === "terminator"
|
||||
? `${id}(["${label}"])`
|
||||
: node.shape === "topic"
|
||||
? `${id}("${label}")`
|
||||
: `${id}["${label}"]`;
|
||||
lines.push(` ${declaration}`);
|
||||
}
|
||||
|
||||
for (const edge of document.edges) {
|
||||
const source = nodeIds.get(edge.source);
|
||||
const target = nodeIds.get(edge.target);
|
||||
if (!source || !target) continue;
|
||||
const label = edge.label ? `|"${escapeMermaidLabel(edge.label)}"|` : "";
|
||||
lines.push(` ${source} -->${label} ${target}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
export const serializeDiagramDocument = (document: DiagramDocument) =>
|
||||
@@ -146,6 +236,24 @@ export const createDefaultDiagramDocument = (kind: DiagramKind): DiagramDocument
|
||||
],
|
||||
};
|
||||
}
|
||||
if (kind === "architecture") {
|
||||
return {
|
||||
schemaVersion: ARCHITECTURE_DIAGRAM_SCHEMA_VERSION,
|
||||
kind,
|
||||
nodes: [
|
||||
{ id: "system", label: "应用系统", x: 220, y: 64, width: 590, height: 330, shape: "boundary" },
|
||||
{ id: "client", label: "Web 客户端", x: 72, y: 190, width: 150, height: 64, shape: "client" },
|
||||
{ id: "api", label: "API 服务", x: 280, y: 190, width: 156, height: 68, shape: "service", parentId: "system" },
|
||||
{ id: "database", label: "数据库", x: 540, y: 118, width: 150, height: 64, shape: "database", parentId: "system" },
|
||||
{ id: "storage", label: "对象存储", x: 540, y: 268, width: 150, height: 64, shape: "storage", parentId: "system" },
|
||||
],
|
||||
edges: [
|
||||
{ id: "request", source: "client", target: "api", label: "HTTPS", kind: "request" },
|
||||
{ id: "query", source: "api", target: "database", label: "查询 / 写入", kind: "data" },
|
||||
{ id: "objects", source: "api", target: "storage", label: "文件", kind: "data" },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
schemaVersion: DIAGRAM_SCHEMA_VERSION,
|
||||
kind,
|
||||
|
||||
@@ -243,6 +243,7 @@ export const enUS = {
|
||||
normalNote: "Regular note",
|
||||
mindMap: "Mind map",
|
||||
flowchart: "Flowchart",
|
||||
architecture: "Architecture diagram",
|
||||
createType: "Choose note type",
|
||||
moreTypes: "More types",
|
||||
back: "Back to note list",
|
||||
@@ -252,16 +253,38 @@ export const enUS = {
|
||||
mindMapShortcuts: "Tab adds a child; Enter adds a sibling",
|
||||
addStep: "Add step",
|
||||
flowchartShortcuts: "Tab connects the next node; Enter edits; Cmd/Ctrl+D duplicates; arrow keys nudge",
|
||||
architectureShortcuts: "Drag handles to connect components; Enter edits; Cmd/Ctrl+D duplicates",
|
||||
addDecision: "Add decision",
|
||||
addTerminator: "Add start or end",
|
||||
addClient: "Add client",
|
||||
addFrontend: "Add frontend",
|
||||
addService: "Add service",
|
||||
addDatabase: "Add database",
|
||||
addStorage: "Add storage",
|
||||
addQueue: "Add queue",
|
||||
addSecurity: "Add security component",
|
||||
addExternal: "Add external system",
|
||||
addBoundary: "Add system boundary",
|
||||
components: "Components",
|
||||
architectureConnectHint: "Drag a handle to connect architecture components",
|
||||
connectHint: "Drag a handle to connect, or drop on canvas to create the next node",
|
||||
newTopic: "New topic",
|
||||
newStep: "New step",
|
||||
newDecision: "Decision",
|
||||
newTerminator: "Start / end",
|
||||
newClient: "Client",
|
||||
newFrontend: "Frontend",
|
||||
newService: "Service",
|
||||
newDatabase: "Database",
|
||||
newStorage: "Storage",
|
||||
newQueue: "Message queue",
|
||||
newSecurity: "Security",
|
||||
newExternal: "External system",
|
||||
newBoundary: "System boundary",
|
||||
quickCreateTitle: "Choose the next node type",
|
||||
quickCreateShortcuts: "Press 1 / 2 / 3 to choose; Esc to cancel",
|
||||
nodeText: "Node text",
|
||||
edgeText: "Connection label",
|
||||
editNode: "Edit node text",
|
||||
deleteSelection: "Delete selection",
|
||||
undo: "Undo",
|
||||
|
||||
@@ -243,6 +243,7 @@ export const zhCN = {
|
||||
normalNote: "普通笔记",
|
||||
mindMap: "思维导图",
|
||||
flowchart: "流程图",
|
||||
architecture: "架构图",
|
||||
createType: "选择笔记类型",
|
||||
moreTypes: "更多类型",
|
||||
back: "返回笔记列表",
|
||||
@@ -252,16 +253,38 @@ export const zhCN = {
|
||||
mindMapShortcuts: "Tab 添加子主题,Enter 添加同级主题",
|
||||
addStep: "添加步骤",
|
||||
flowchartShortcuts: "Tab 连接后续节点,Enter 编辑,⌘/Ctrl+D 复制,方向键微调",
|
||||
architectureShortcuts: "拖动端点连接组件,Enter 编辑,⌘/Ctrl+D 复制",
|
||||
addDecision: "添加判断节点",
|
||||
addTerminator: "添加起止节点",
|
||||
addClient: "添加客户端",
|
||||
addFrontend: "添加前端",
|
||||
addService: "添加服务",
|
||||
addDatabase: "添加数据库",
|
||||
addStorage: "添加存储",
|
||||
addQueue: "添加消息队列",
|
||||
addSecurity: "添加安全组件",
|
||||
addExternal: "添加外部系统",
|
||||
addBoundary: "添加系统边界",
|
||||
components: "组件",
|
||||
architectureConnectHint: "拖动端点连接架构组件",
|
||||
connectHint: "拖动端点连线;松开在空白处可创建后续节点",
|
||||
newTopic: "新主题",
|
||||
newStep: "新步骤",
|
||||
newDecision: "判断条件",
|
||||
newTerminator: "开始/结束",
|
||||
newClient: "客户端",
|
||||
newFrontend: "前端应用",
|
||||
newService: "服务",
|
||||
newDatabase: "数据库",
|
||||
newStorage: "对象存储",
|
||||
newQueue: "消息队列",
|
||||
newSecurity: "安全组件",
|
||||
newExternal: "外部系统",
|
||||
newBoundary: "系统边界",
|
||||
quickCreateTitle: "选择后续节点类型",
|
||||
quickCreateShortcuts: "按 1 / 2 / 3 选择,Esc 取消",
|
||||
nodeText: "节点文字",
|
||||
edgeText: "连线说明",
|
||||
editNode: "编辑节点文字",
|
||||
deleteSelection: "删除所选内容",
|
||||
undo: "撤销",
|
||||
|
||||
Reference in new issue
Block a user