feat: add architecture diagram notes

This commit is contained in:
tianma-if committed 2026-09-05 23:59:49 +08:00
1 parent 533a9bf638
commit 28659e6848
14 files changed
+599 -82

No files matched your search

+1
View File
@@ -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.
+1
View File
@@ -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');
+319 -45
View File
@@ -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", () => {
+6
View File
@@ -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>
+3 -2
View File
@@ -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: [],
+11 -1
View File
@@ -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 });
});
});
+20 -2
View File
@@ -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, {
+13 -6
View File
@@ -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);
}
+31 -3
View File
@@ -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("核心 &lt;主题&gt; &quot;A&amp;B&quot;");
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
View File
@@ -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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
/**
* 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,
+23
View File
@@ -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",
+23
View File
@@ -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: "撤销",