diff --git a/README.md b/README.md index 176d577f..4738b243 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 24e3b845..5e3290d3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -60,6 +60,7 @@ iOS 客户端需要使用非中国大陆区 Apple ID 下载。 - **优雅的双视图编辑**:桌面端支持在富文本与 Markdown 源码视图之间自由切换。 - **单篇笔记便捷导出**:可将当前笔记直接导出为 Markdown、HTML 或 PDF,方便独立保存、分享与发布。 - **Mermaid 架构图与流程图渲染**:原生支持 Mermaid 代码块渲染,视图切换时完整保留可编辑源码,让绘制逻辑图表更直观。 +- **可视化图表笔记**:支持创建可编辑的思维导图、流程图和架构图,提供语义组件、系统边界、连线说明、自动布局、历史版本及 PNG/SVG 导出。 - **笔记历史版本回溯**:自动记录修改历史,随时查阅与还原过往版本。 - **公开笔记分享**:支持公开分享笔记,并可随时取消分享。 - **移动 App 微信公众号文章剪藏**:在手机上将微信公众号文章分享至 EdgeEver,即可提取正文并保存为可继续编辑的笔记。 diff --git a/apps/web/src/components/DiagramEditorPane.test.mjs b/apps/web/src/components/DiagramEditorPane.test.mjs index e813993c..33896917 100644 --- a/apps/web/src/components/DiagramEditorPane.test.mjs +++ b/apps/web/src/components/DiagramEditorPane.test.mjs @@ -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'); diff --git a/apps/web/src/components/DiagramEditorPane.tsx b/apps/web/src/components/DiagramEditorPane.tsx index 80a7c478..6a0fa51c 100644 --- a/apps/web/src/components/DiagramEditorPane.tsx +++ b/apps/web/src/components/DiagramEditorPane.tsx @@ -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> = { + 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> = { + 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> = { + 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> = { + 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, @@ -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(); + 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()?.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()?.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()?.shape === "boundary")) { + const children = nodes.filter((node) => node.getData()?.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()?.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(null); const [selectedNodeLabel, setSelectedNodeLabel] = useState(""); + const [selectedEdgeId, setSelectedEdgeId] = useState(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()?.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(); 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(); 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() : 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().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 = ({ {t("diagram.mindMapShortcuts")} + ) : document.kind === "architecture" ? ( + <> + + + + + {t("diagram.architectureShortcuts")} + + + + + + + addNode("client")}>{t("diagram.addClient")} + addNode("frontend")}>{t("diagram.addFrontend")} + addNode("database")}>{t("diagram.addDatabase")} + addNode("storage")}>{t("diagram.addStorage")} + addNode("queue")}>{t("diagram.addQueue")} + addNode("security")}>{t("diagram.addSecurity")} + addNode("external")}>{t("diagram.addExternal")} + + addNode("boundary")}>{t("diagram.addBoundary")} + + + {t("diagram.architectureConnectHint")} + ) : ( <> @@ -1573,6 +1841,12 @@ export const DiagramEditorPane = ({ updateSelectedLabel(event.target.value)} /> )} + {selectedEdgeId && !readOnly && ( +
+ {t("diagram.edgeText")} + updateSelectedEdgeLabel(event.target.value)} /> +
+ )}
@@ -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); }); } } diff --git a/apps/web/src/components/NotebookPane.test.mjs b/apps/web/src/components/NotebookPane.test.mjs index 12ebd929..06fb9683 100644 --- a/apps/web/src/components/NotebookPane.test.mjs +++ b/apps/web/src/components/NotebookPane.test.mjs @@ -28,7 +28,8 @@ test("marks diagram note types as beta without labeling regular notes", () => { const betaBadgeCount = createTypeMenu?.match(//g)?.length; expect(regularNoteItem).not.toContain("DiagramBetaBadge"); - expect(betaBadgeCount).toBe(2); + expect(createTypeMenu).toContain('onCreateMemo("architecture")'); + expect(betaBadgeCount).toBe(3); }); describe("NotebookPane client downloads", () => { diff --git a/apps/web/src/components/NotebookPane.tsx b/apps/web/src/components/NotebookPane.tsx index 9580086c..d769baee 100644 --- a/apps/web/src/components/NotebookPane.tsx +++ b/apps/web/src/components/NotebookPane.tsx @@ -9,6 +9,7 @@ import { LayoutList, LayoutTemplate, BookPlus, + Boxes, ArrowDownWideNarrow, Notebook as NotebookIcon, Tags, @@ -606,6 +607,11 @@ export const NotebookPane = ({ {t("diagram.flowchart")} + onCreateMemo("architecture")}> + + {t("diagram.architecture")} + +
diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 46377aac..d65cfdab 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -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 = ({ onCreateMemo()}>{t("diagram.normalNote")} onCreateMemo("mind-map")}>{t("diagram.mindMap")} onCreateMemo("flowchart")}>{t("diagram.flowchart")} + onCreateMemo("architecture")}>{t("diagram.architecture")} } 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: [], diff --git a/apps/web/src/lib/diagram-layout.test.mjs b/apps/web/src/lib/diagram-layout.test.mjs index ded5f1d7..69f2e8e1 100644 --- a/apps/web/src/lib/diagram-layout.test.mjs +++ b/apps/web/src/lib/diagram-layout.test.mjs @@ -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 }); + }); }); diff --git a/apps/web/src/lib/diagram-layout.ts b/apps/web/src/lib/diagram-layout.ts index 609668cd..45780d8e 100644 --- a/apps/web/src/lib/diagram-layout.ts +++ b/apps/web/src/lib/diagram-layout.ts @@ -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, { diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index b5951f6f..bcaac418 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -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); } diff --git a/packages/shared/src/diagram.test.mjs b/packages/shared/src/diagram.test.mjs index b1c2acc7..6105e642 100644 --- a/packages/shared/src/diagram.test.mjs +++ b/packages/shared/src/diagram.test.mjs @@ -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(); }); }); diff --git a/packages/shared/src/diagram.ts b/packages/shared/src/diagram.ts index b06ebef3..a7721edd 100644 --- a/packages/shared/src/diagram.ts +++ b/packages/shared/src/diagram.ts @@ -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; @@ -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; - 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; - 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, ">"); + +/** + * 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, diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index f1650ee3..dee6e6e0 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -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", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 4778ed4c..c5c4acd7 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -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: "撤销",