From 748be716458c8cef4a514e9bb8d0d263bc66000e Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 8 Sep 2026 18:01:11 +0800 Subject: [PATCH] feat(diagram): split structure from color and stop canvas clipping Mind maps now pick structure (underline map vs boxed topics) independently from color (forest green vs spectrum). The editor sizes the paper to every node so two-sided branches are not cropped while editing. --- apps/api/src/mcp-tool-service.ts | 12 +- apps/api/src/mcp-tools.ts | 3 +- .../src/components/DiagramEditorPane.test.mjs | 27 +- apps/web/src/components/DiagramEditorPane.tsx | 276 +++++++++++++----- apps/web/src/components/DiagramToolbar.tsx | 38 ++- apps/web/src/lib/diagram-editor-snapshot.ts | 1 + .../web/src/lib/diagram-scroller-fit.test.mjs | 27 ++ apps/web/src/lib/diagram-scroller-fit.ts | 49 ++++ apps/web/src/lib/diagram-theme.test.mjs | 5 + apps/web/src/lib/diagram-theme.ts | 30 +- packages/shared/src/diagram-layout.ts | 5 +- .../shared/src/diagram-mindmap-style.test.mjs | 42 ++- packages/shared/src/diagram-mindmap-style.ts | 254 +++++++++++++--- packages/shared/src/diagram-view.ts | 56 +++- packages/shared/src/diagram.test.mjs | 15 +- packages/shared/src/diagram.ts | 14 +- packages/shared/src/i18n/en-US.ts | 8 +- packages/shared/src/i18n/zh-CN.ts | 8 +- 18 files changed, 714 insertions(+), 156 deletions(-) create mode 100644 apps/web/src/lib/diagram-scroller-fit.test.mjs create mode 100644 apps/web/src/lib/diagram-scroller-fit.ts diff --git a/apps/api/src/mcp-tool-service.ts b/apps/api/src/mcp-tool-service.ts index a2aba632..d5f1dbce 100644 --- a/apps/api/src/mcp-tool-service.ts +++ b/apps/api/src/mcp-tool-service.ts @@ -205,7 +205,7 @@ const DIAGRAM_IR_NODE_TYPES = new Set([ "queue", "security", "external", "boundary", ]); const DIAGRAM_EDGE_KINDS = new Set(["dependency", "request", "async", "data"]); -const DIAGRAM_MEMO_KEYS = new Set(["notebookId", "title", "kind", "theme", "layout", "tags", "nodes", "edges"]); +const DIAGRAM_MEMO_KEYS = new Set(["notebookId", "title", "kind", "theme", "structure", "layout", "tags", "nodes", "edges"]); const DIAGRAM_NODE_KEYS = new Set(["id", "label", "type", "parentId", "resourceIcon"]); const DIAGRAM_EDGE_KEYS = new Set(["source", "target", "label", "type", "bidirectional"]); @@ -276,8 +276,11 @@ const parseDiagramMemoIr = (args: Record): DiagramIr => { if (args.edges !== undefined && (!Array.isArray(args.edges) || args.edges.length > 400)) { throw new AppError("invalid_params", "edges must be an array with at most 400 items", 400); } - if (args.theme !== undefined && !["brand", "ocean", "ink"].includes(String(args.theme))) { - throw new AppError("invalid_params", "theme must be brand, ocean, or ink", 400); + if (args.theme !== undefined && !["brand", "ocean", "ink", "classic"].includes(String(args.theme))) { + throw new AppError("invalid_params", "theme must be brand, ocean, ink, or classic", 400); + } + if (args.structure !== undefined && !["map", "box"].includes(String(args.structure))) { + throw new AppError("invalid_params", "structure must be map or box", 400); } if (args.title !== undefined && (typeof args.title !== "string" || args.title.length > 160)) { throw new AppError("invalid_params", "title must be a string with at most 160 characters", 400); @@ -298,6 +301,7 @@ const parseDiagramMemoIr = (args: Record): DiagramIr => { const ir: DiagramIr = { kind: kind as DiagramIr["kind"], ...(args.theme === undefined ? {} : { theme: args.theme as DiagramIr["theme"] }), + ...(args.structure === undefined ? {} : { structure: args.structure as DiagramIr["structure"] }), ...(args.layout === undefined ? {} : { layout: args.layout as DiagramIr["layout"] }), nodes: args.nodes.map(parseDiagramNode), edges: (args.edges as unknown[] | undefined)?.map(parseDiagramEdge), @@ -354,6 +358,7 @@ const diagramNodeType = (shape: DiagramNodeShape): DiagramIrNodeType => shape; const diagramSemanticGraph = (document: DiagramDocument, includeLayout = false) => ({ kind: document.kind, ...(document.theme ? { theme: document.theme } : {}), + ...(document.structure ? { structure: document.structure } : {}), nodes: document.nodes.map((node) => ({ id: node.id, label: node.label, @@ -541,6 +546,7 @@ const applyDiagramOperations = async ( const validatedIr = parseDiagramMemoIr({ kind: current.kind, ...(current.theme ? { theme: current.theme } : {}), + ...(current.structure ? { structure: current.structure } : {}), nodes, edges: edges.map(({ id: _id, ...edge }) => edge), }); diff --git a/apps/api/src/mcp-tools.ts b/apps/api/src/mcp-tools.ts index be958d4e..617e8e29 100644 --- a/apps/api/src/mcp-tools.ts +++ b/apps/api/src/mcp-tools.ts @@ -106,7 +106,8 @@ const MCP_TOOL_DEFINITIONS = [ notebookId: { type: "string", minLength: 1 }, title: { type: "string", maxLength: 160 }, kind: { type: "string", enum: ["mind-map", "flowchart", "architecture"] }, - theme: { type: "string", enum: ["brand", "ocean", "ink"] }, + theme: { type: "string", enum: ["brand", "ocean", "ink", "classic"] }, + structure: { type: "string", enum: ["map", "box"] }, layout: { type: "object", additionalProperties: false, diff --git a/apps/web/src/components/DiagramEditorPane.test.mjs b/apps/web/src/components/DiagramEditorPane.test.mjs index 01510c7f..0e5d74aa 100644 --- a/apps/web/src/components/DiagramEditorPane.test.mjs +++ b/apps/web/src/components/DiagramEditorPane.test.mjs @@ -21,6 +21,18 @@ describe("diagram editor keyboard workflow", () => { expect(source).toContain("graph.cleanSelection();\n graph.select(node);"); }); + test("keeps scroller paper bounds on every node so left-side mind-map branches are not clipped", () => { + expect(source).toContain("bindDiagramScrollerFit(graph)"); + expect(source).toContain("applyDiagramScrollerFitOptions("); + expect(source).toContain("ensureDiagramPaperContainsNodes(graph)"); + expect(source).toContain("node.getPosition()"); + expect(source).toContain("node.getSize()"); + expect(source).toContain("scroller?.disableAutoResize()"); + expect(source).toContain("scroller?.enableAutoResize()"); + expect(source).toContain("graph.transform.fitToContent({"); + expect(source).not.toContain("visibleNodes.length !== graph.getNodes().length"); + }); + test("does not let scroller auto-fit flash a detached node while inserting", () => { expect(source).toContain("disableAutoResize()"); expect(source).toContain("enableAutoResize()"); @@ -138,6 +150,14 @@ describe("diagram editor canvas surface", () => { }); test("uses restrained rounded edges and fits the complete diagram without clipping", () => { + expect(toolbarSource).toContain('value="classic"'); + expect(toolbarSource).toContain('t("diagram.themeClassic")'); + expect(toolbarSource).toContain('t("diagram.structure")'); + expect(toolbarSource).toContain('t("diagram.structureMap")'); + expect(toolbarSource).toContain('t("diagram.structureBox")'); + expect(toolbarSource).toContain('{t("diagram.theme")}'); + expect(toolbarSource).not.toContain('value="ocean"'); + expect(toolbarSource).not.toContain('value="ink"'); expect(source).toContain("Graph.registerConnector(MIND_MAP_CONNECTOR_NAME, mindMapConnector, true)"); expect(source).toContain("name: MIND_MAP_CONNECTOR_NAME, args: { sourceWidth: mindEdge?.sourceWidth, targetWidth: mindEdge?.targetWidth }"); expect(source).toContain('{ fill: "none" }'); @@ -147,6 +167,11 @@ describe("diagram editor canvas surface", () => { expect(source).not.toContain("minScale: policy.minScale"); expect(source).toContain("graph.centerContent()"); expect(source).not.toContain("desiredLeft - contentLeft"); + expect(source).not.toContain("visibleNodes.length !== graph.getNodes().length"); + expect(source).toContain("bindDiagramScrollerFit(graph)"); + expect(source).toContain("applyDiagramScrollerFitOptions("); + expect(source).toContain("diagramNodeBounds(graph)"); + expect(source).toContain("graph.zoomToRect(bounds, { padding, maxScale: policy.maxScale })"); expect(source).toContain("getDiagramLayoutViewport(document.kind)"); expect(source).toContain("fitDiagramContent(graph, document, containerRef.current);"); }); @@ -341,7 +366,7 @@ describe("diagram editor canvas surface", () => { test("repaints the graph when the application appearance changes", () => { expect(source).toContain("const { resolvedTheme } = useAppearanceTheme();"); - expect(source).toContain("applyGraphPalette(graph, themeRef.current, document.kind, resolvedTheme);"); + expect(source).toContain("applyGraphPalette(graph, themeRef.current, document.kind, resolvedTheme, structureRef.current);"); expect(source).toContain("data-diagram-appearance={resolvedTheme}"); expect(source).toContain(" graph.getPlugin("scroller") as Scroller | undefined; +const diagramNodeBounds = (graph: Graph) => { + const nodes = graph.getNodes(); + if (nodes.length === 0) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const node of nodes) { + const position = node.getPosition(); + const size = node.getSize(); + minX = Math.min(minX, position.x); + minY = Math.min(minY, position.y); + maxX = Math.max(maxX, position.x + size.width); + maxY = Math.max(maxY, position.y + size.height); + } + const bounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + return isUsableDiagramBounds(bounds) ? bounds : null; +}; + +const bindDiagramScrollerFit = (graph: Graph) => { + const current = graph.fitToContent as typeof graph.fitToContent & { edgeeverBound?: boolean }; + if (current.edgeeverBound) return; + const original = current.bind(graph); + const bound = (( + gridWidth?: unknown, + gridHeight?: number, + padding?: unknown, + options?: Record, + ) => { + const bounds = diagramNodeBounds(graph); + if (gridWidth && typeof gridWidth === "object") { + return original(applyDiagramScrollerFitOptions({ ...(gridWidth as Record) }, bounds)); + } + return original( + gridWidth as number | undefined, + gridHeight, + padding as number | undefined, + applyDiagramScrollerFitOptions({ ...(options ?? {}) }, bounds), + ); + }) as typeof graph.fitToContent & { edgeeverBound?: boolean }; + bound.edgeeverBound = true; + graph.fitToContent = bound; +}; + +const ensureDiagramPaperContainsNodes = (graph: Graph) => { + const bounds = diagramNodeBounds(graph); + if (!bounds) return; + graph.transform.fitToContent({ + allowNewOrigin: "any", + padding: 32, + gridWidth: 1, + gridHeight: 1, + contentArea: bounds, + }); +}; + const diagramClientToLocalPoint = (graph: Graph, point: { x: number; y: number }) => { const anchorNode = graph.getNodes().find((node) => node.isVisible()); const anchorView = anchorNode ? graph.findViewByCell(anchorNode) : null; @@ -669,14 +730,18 @@ const nodeEditorState = ( const bottomRight = graph.localToClient({ x: bbox.x + bbox.width, y: bbox.y + bbox.height }); const origin = (host ?? graph.container).getBoundingClientRect(); const isRootTopic = data?.shape === "topic" && !data.parentId; - const mindRole = data?.shape === "topic" - ? mindMapNodeRole(graph.getNodes().map((item) => ({ - id: item.id, - parentId: item.getData()?.parentId, - })), node.id) + const mindNodes = graph.getNodes().map((item) => ({ + id: item.id, + parentId: item.getData()?.parentId, + ...item.getPosition(), + ...item.getSize(), + })); + const mindRole = data?.shape === "topic" ? mindMapNodeRole(mindNodes, node.id) : null; + const mindStyle = mindRole + ? resolveMindMapNodeStyle(mindNodes, node.id, resolveDiagramPalette(theme, appearance), theme, appearance, node.getSize()) : null; - const attrs = mindRole - ? mindMapNodeVisual(mindRole, resolveDiagramPalette(theme, appearance)) + const attrs = mindStyle + ? mindStyle.visual : nodeAttrs(data?.shape ?? "process", theme, appearance, isRootTopic); return { nodeId: node.id, @@ -689,7 +754,7 @@ const nodeEditorState = ( height: Math.max(1, bottomRight.y - topLeft.y), fontSize: (mindRole ? attrs.label.fontSize : data?.shape === "topic" ? 14 : 13) * graph.scale().sx, color: String(attrs.label.fill), - background: String(attrs.body.fill), + background: String(attrs.body.fill === "transparent" ? resolveDiagramPalette(theme, appearance).canvas : attrs.body.fill), borderColor: String(attrs.body.stroke), }; }; @@ -833,9 +898,10 @@ const diagramNodePresentation = ( node: DiagramDocument["nodes"][number], kind: DiagramDocument["kind"], allNodes: Array<{ id: string; parentId?: string }> = [node], + structure?: DiagramStructure, ) => { if (kind === "flowchart") return flowchartNodePresentation(node.shape, node.label); - if (kind === "mind-map") return mindMapNodePresentation(node.label, mindMapNodeRole(allNodes, node.id)); + if (kind === "mind-map") return mindMapNodePresentation(node.label, mindMapNodeRole(allNodes, node.id), structure); const size = kind === "architecture" ? compactArchitectureNodeSize(node.shape, node) : compactFlowchartNodeSize(node.shape); @@ -849,12 +915,12 @@ const diagramNodePresentation = ( return { ...size, height: Math.max(size.height, text.split("\n").length * lineHeight + 16), text }; }; -const diagramNodeSize = (node: DiagramDocument["nodes"][number], kind: DiagramDocument["kind"]) => { - const { width, height } = diagramNodePresentation(node, kind); +const diagramNodeSize = (node: DiagramDocument["nodes"][number], kind: DiagramDocument["kind"], structure?: DiagramStructure) => { + const { width, height } = diagramNodePresentation(node, kind, [node], structure); return { width, height }; }; -const refreshNodeLabel = (node: Node, label: string) => { +const refreshNodeLabel = (node: Node, label: string, structure?: DiagramStructure) => { const data = node.getData(); const shape = data?.shape ?? "process"; if (shape === "boundary") { @@ -862,7 +928,12 @@ const refreshNodeLabel = (node: Node, label: string) => { return; } const kind = shape === "topic" ? "mind-map" : ARCHITECTURE_NODE_ACCENTS[shape] ? "architecture" : "flowchart"; - const presentation = diagramNodePresentation({ id: node.id, ...node.getPosition(), ...node.getSize(), ...data, shape, label }, kind); + const graphNodes = kind === "mind-map" ? node.model?.getNodes() : undefined; + const allNodes = graphNodes?.map((item) => ({ + id: item.id, + parentId: item.getData()?.parentId, + })); + const presentation = diagramNodePresentation({ id: node.id, ...node.getPosition(), ...node.getSize(), ...data, shape, label }, kind, allNodes, structure); const currentSize = node.getSize(); if (currentSize.width !== presentation.width || currentSize.height !== presentation.height) { node.resize(presentation.width, presentation.height); @@ -902,14 +973,17 @@ const nodeMetadata = ( theme: DiagramTheme, kind: DiagramDocument["kind"], appearance: DiagramAppearance, + structure?: DiagramStructure, ) => { const isDecision = node.shape === "decision"; const isRootTopic = node.shape === "topic" && !node.parentId; - const mindRole = kind === "mind-map" ? mindMapNodeRole([node], node.id) : null; const palette = resolveDiagramPalette(theme, appearance); - const visualAttrs = mindRole ? mindMapNodeVisual(mindRole, palette) : nodeAttrs(node.shape, theme, appearance, isRootTopic); - const size = diagramNodeSize(node, kind); - if (mindRole === "root") { + const size = diagramNodeSize(node, kind, structure); + const mindStyle = kind === "mind-map" + ? resolveMindMapNodeStyle([node], node.id, palette, theme, appearance, size, structure) + : null; + const visualAttrs = mindStyle ? mindStyle.visual : nodeAttrs(node.shape, theme, appearance, isRootTopic); + if (mindStyle?.role === "root") { visualAttrs.body.rx = mindMapRootRadius(size.height); visualAttrs.body.ry = visualAttrs.body.rx; } @@ -931,13 +1005,14 @@ const nodeMetadata = ( ...(node.parentId ? { parentId: node.parentId } : {}), ...(node.resourceIcon ? { resourceIcon: node.resourceIcon } : {}), } satisfies NodeData, - ...(architectureVisuals ? { markup: architectureVisuals.markup } : {}), + ...(architectureVisuals ? { markup: architectureVisuals.markup } : mindStyle ? { markup: MIND_MAP_TOPIC_MARKUP } : {}), attrs: { body: visualAttrs.body, + ...(mindStyle ? { underline: mindStyle.visual.underline } : {}), label: { ...visualAttrs.label, text: diagramNodePresentation(node, kind).text, - lineHeight: mindRole === "root" ? 20 : 18, + lineHeight: mindStyle?.visual.label.lineHeight ?? 18, ...(architectureVisuals ? { refX: 54, refY: "50%", textAnchor: "start", textVerticalAnchor: "middle" } : {}), }, ...(architectureVisuals?.attrs ?? {}), @@ -1004,10 +1079,11 @@ const edgeMetadata = ( }; }; -const graphToDocument = (graph: Graph, kind: DiagramDocument["kind"], theme: DiagramTheme): DiagramDocument => ({ +const graphToDocument = (graph: Graph, kind: DiagramDocument["kind"], theme: DiagramTheme, structure?: DiagramStructure): DiagramDocument => ({ schemaVersion: kind === "architecture" ? ARCHITECTURE_DIAGRAM_SCHEMA_VERSION : DIAGRAM_SCHEMA_VERSION, kind, theme, + ...(kind === "mind-map" ? { structure: resolveDiagramStructure(structure) } : {}), nodes: graph.getNodes().map((node) => { const data = node.getData(); const position = node.getPosition(); @@ -1070,25 +1146,16 @@ const removeGraphSelection = (graph: Graph) => { const fitDiagramContent = ( graph: Graph, document: DiagramDocument, - container: HTMLElement | null, + _container: HTMLElement | null, padding = 32, viewport?: DiagramLayoutViewport, ) => { const policy = viewport ?? getDiagramLayoutViewport(document.kind); - const visibleNodes = graph.getNodes().filter((node) => node.isVisible()); - if (visibleNodes.length !== graph.getNodes().length) { - const bounds = graph.getCellsBBox(visibleNodes); - if (bounds) graph.zoomToRect(bounds, { padding, maxScale: policy.maxScale }); - return; - } - graph.zoomToFit({ - padding, - maxScale: policy.maxScale, - - }); - // Fit the complete bounding box, including branches left of a mind-map root. - // A minimum scale or a second anchor translation can crop existing content. - graph.centerContent(); + const bounds = diagramNodeBounds(graph); + if (!bounds) return; + // Fit every node, including mind-map branches left of the root. Zooming to a + // visible subset or to edge paths lets Scroller shrink the paper and clip. + graph.zoomToRect(bounds, { padding, maxScale: policy.maxScale }); }; const readFlowchart = (graph: Graph, document: DiagramDocument, container: HTMLElement | null) => { @@ -1102,7 +1169,7 @@ const readFlowchart = (graph: Graph, document: DiagramDocument, container: HTMLE graph.translate(container.clientWidth / 2 - bounds.center.x, 48 - bounds.y); }; -const applyMindMapHierarchy = (graph: Graph, theme: DiagramTheme, appearance: DiagramAppearance) => { +const applyMindMapHierarchy = (graph: Graph, theme: DiagramTheme, appearance: DiagramAppearance, structure?: DiagramStructure) => { const palette = resolveDiagramPalette(theme, appearance); const nodes = graph.getNodes().map((node) => ({ id: node.id, @@ -1111,21 +1178,28 @@ const applyMindMapHierarchy = (graph: Graph, theme: DiagramTheme, appearance: Di ...node.getSize(), })); for (const node of graph.getNodes()) { - const role = mindMapNodeRole(nodes, node.id); - const visual = mindMapNodeVisual(role, palette); + const size = node.getSize(); + const { role, visual } = resolveMindMapNodeStyle(nodes, node.id, palette, theme, appearance, size, structure); if (role === "root") { - visual.body.rx = mindMapRootRadius(node.getSize().height); + visual.body.rx = mindMapRootRadius(size.height); visual.body.ry = visual.body.rx; } + node.setMarkup(MIND_MAP_TOPIC_MARKUP); node.attr("body/fill", visual.body.fill); node.attr("body/stroke", visual.body.stroke); node.attr("body/strokeWidth", visual.body.strokeWidth); node.attr("body/rx", visual.body.rx); node.attr("body/ry", visual.body.ry); + node.attr("underline", visual.underline); node.attr("label/fill", visual.label.fill); node.attr("label/fontSize", visual.label.fontSize); node.attr("label/fontWeight", visual.label.fontWeight); node.attr("label/fontFamily", visual.label.fontFamily); + node.attr("label/lineHeight", visual.label.lineHeight); + node.attr("label/refX", visual.label.refX ?? "50%"); + node.attr("label/refY", visual.label.refY); + node.attr("label/textAnchor", visual.label.textAnchor); + node.attr("label/textVerticalAnchor", visual.label.textVerticalAnchor); } for (const edge of graph.getEdges()) { const sourceId = edge.getSourceCellId(); @@ -1133,10 +1207,14 @@ const applyMindMapHierarchy = (graph: Graph, theme: DiagramTheme, appearance: Di const source = nodes.find((node) => node.id === sourceId); const target = nodes.find((node) => node.id === targetId); if (!source || !target) continue; - const visual = mindMapEdgeVisual(mindMapNodeRole(nodes, source.id), palette); + const sourceRole = mindMapNodeRole(nodes, source.id); + const targetStyle = resolveMindMapNodeStyle(nodes, target.id, palette, theme, appearance, target, structure); + const visual = mindMapEdgeVisual(sourceRole, palette, targetStyle.tint); const sides = mindMapBranchSides(source, target); - edge.setSource({ cell: sourceId, anchor: { name: sides.source } }); - edge.setTarget({ cell: targetId, anchor: { name: sides.target } }); + const sourceTerminal = mindMapEdgeTerminal(source, sourceRole, sides.source, structure); + const targetTerminal = mindMapEdgeTerminal(target, targetStyle.role, sides.target, structure); + edge.setSource({ cell: sourceId, ...sourceTerminal }); + edge.setTarget({ cell: targetId, ...targetTerminal }); edge.setConnector(MIND_MAP_CONNECTOR_NAME, { sourceWidth: visual.sourceWidth, targetWidth: visual.targetWidth }); edge.attr("line/stroke", visual.stroke); edge.attr("line/fill", visual.stroke); @@ -1153,8 +1231,11 @@ const applyGraphPalette = ( theme: DiagramTheme, kind: DiagramDocument["kind"], appearance: DiagramAppearance, + structure?: DiagramStructure, ) => { const palette = resolveDiagramPalette(theme, appearance); + const scroller = getDiagramScroller(graph); + scroller?.disableAutoResize(); const historyEnabled = graph.isHistoryEnabled(); if (historyEnabled) graph.disableHistory(); try { @@ -1164,7 +1245,7 @@ const applyGraphPalette = ( const attrs = nodeAttrs(shape, theme, appearance, shape === "topic" && !data?.parentId); node.attr("body", attrs.body); node.attr("label", attrs.label); - refreshNodeLabel(node, data?.label ?? ""); + refreshNodeLabel(node, data?.label ?? "", structure); if (kind === "architecture" && shape !== "boundary") { const architectureVisuals = architectureNodeVisuals(shape, node.getSize(), appearance, data?.resourceIcon); for (const [selector, selectorAttrs] of Object.entries(architectureVisuals.attrs)) { @@ -1187,10 +1268,12 @@ const applyGraphPalette = ( edge.setLabels(edge.getLabels().map((label) => diagramEdgeLabel(String(label.attrs?.label?.text ?? ""), palette, kind))); } } - if (kind === "mind-map") applyMindMapHierarchy(graph, theme, appearance); + if (kind === "mind-map") applyMindMapHierarchy(graph, theme, appearance, structure); applyDiagramSurface(graph, theme, appearance); } finally { if (historyEnabled) graph.enableHistory(); + scroller?.enableAutoResize(); + ensureDiagramPaperContainsNodes(graph); } }; @@ -1223,12 +1306,15 @@ export const DiagramEditorPane = ({ const saveRef = useRef<() => void>(() => undefined); const document = parseDiagramDocument(memo.contentMarkdown); const documentTheme = document?.theme ?? "brand"; + const documentStructure = resolveDiagramStructure(document?.structure); const [title, setTitle] = useState(memo.title ?? ""); const [tagsText, setTagsText] = useState(memo.tags.join(", ")); const [theme, setTheme] = useState(documentTheme); + const [structure, setStructure] = useState(documentStructure); const titleRef = useRef(title); const tagsRef = useRef(tagsText); const themeRef = useRef(documentTheme); + const structureRef = useRef(documentStructure); const appearanceRef = useRef(resolvedTheme); const savedSnapshotRef = useRef(document ? diagramEditorSnapshot(memo.title ?? "", document) : ""); const viewOnlyRef = useRef(false); @@ -1337,9 +1423,9 @@ export const DiagramEditorPane = ({ if (label !== current.originalValue) { graph.startBatch("edit-label"); cell.setData({ ...cell.getData(), label }); - refreshNodeLabel(cell, label); + refreshNodeLabel(cell, label, structureRef.current); if (cell.getData()?.shape === "topic") { - applyMindMapHierarchy(graph, themeRef.current, appearanceRef.current); + applyMindMapHierarchy(graph, themeRef.current, appearanceRef.current, structureRef.current); } graph.stopBatch("edit-label"); setSelectedNodeLabel(label); @@ -1372,10 +1458,12 @@ export const DiagramEditorPane = ({ setNodeEditor(null); setTheme(documentTheme); themeRef.current = documentTheme; + setStructure(documentStructure); + structureRef.current = documentStructure; setHistoryOpen(false); setShareOpen(false); setMemoIdCopyNotice(null); - }, [documentTheme, memo.id]); + }, [documentStructure, documentTheme, memo.id]); useEffect(() => () => { if (memoIdCopyTimerRef.current !== null) window.clearTimeout(memoIdCopyTimerRef.current); @@ -1418,7 +1506,7 @@ export const DiagramEditorPane = ({ if (!graph || !document) return; viewOnlyRef.current = true; try { - applyGraphPalette(graph, themeRef.current, document.kind, resolvedTheme); + applyGraphPalette(graph, themeRef.current, document.kind, resolvedTheme, structureRef.current); } finally { viewOnlyRef.current = false; } @@ -1443,7 +1531,7 @@ export const DiagramEditorPane = ({ const incomingSnapshot = diagramEditorSnapshot(memo.title ?? "", document); const canvasSnapshot = diagramEditorSnapshot( titleRef.current, - graphToDocument(graph, document.kind, themeRef.current), + graphToDocument(graph, document.kind, themeRef.current, structureRef.current), ); if (incomingSnapshot !== canvasSnapshot) { setGraphReloadVersion((current) => current + 1); @@ -1503,6 +1591,7 @@ export const DiagramEditorPane = ({ pannable: { enabled: true, eventTypes: ["leftMouseDown", "rightMouseDown"] }, className: "edgeever-diagram-scroller", })); + bindDiagramScrollerFit(graph); graph.use(new History({ enabled: !readOnly })); graph.use(new Export()); graph.use(new Keyboard({ @@ -1530,7 +1619,7 @@ export const DiagramEditorPane = ({ return nodeMetadata({ ...node, ...(inferredResourceIcon ? { resourceIcon: inferredResourceIcon } : {}), - }, documentTheme, document.kind, appearance); + }, documentTheme, document.kind, appearance, documentStructure); })); if (document.kind === "architecture") { for (const node of graph.getNodes()) { @@ -1540,17 +1629,24 @@ export const DiagramEditorPane = ({ } } graph.addEdges(document.edges.map((edge) => edgeMetadata(edge, document.kind, documentTheme, appearance))); - applyGraphPalette(graph, documentTheme, document.kind, appearance); + applyGraphPalette(graph, documentTheme, document.kind, appearance, documentStructure); graph.on("scale", () => setZoomPercent(Math.round(graph.scale().sx * 100))); graph.cleanHistory(); - fitDiagramContent(graph, document, containerRef.current); + const settleLoadedViewport = () => { + if (graphRef.current !== graph) return; + ensureDiagramPaperContainsNodes(graph); + fitDiagramContent(graph, document, containerRef.current); + }; + settleLoadedViewport(); + graph.once("render:done", settleLoadedViewport); + requestAnimationFrame(settleLoadedViewport); if (document.kind === "flowchart" && graph.scale().sx < 0.8) readFlowchart(graph, document, containerRef.current); const updateHistory = () => setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() }); const markDirty = () => { if (viewOnlyRef.current) return; if (!readOnly) { - const currentDocument = graphToDocument(graph, document.kind, themeRef.current); + const currentDocument = graphToDocument(graph, document.kind, themeRef.current, structureRef.current); const hasChanges = savedSnapshotRef.current !== diagramEditorSnapshot(titleRef.current, currentDocument); setDirty(hasChanges); if (hasChanges) setDirtyVersion((current) => current + 1); @@ -1558,7 +1654,7 @@ export const DiagramEditorPane = ({ updateHistory(); }; const clearSelectionAfterHistory = () => { - applyGraphPalette(graph, themeRef.current, document.kind, appearanceRef.current); + applyGraphPalette(graph, themeRef.current, document.kind, appearanceRef.current, structureRef.current); graph.cleanSelection(); if (isConnectableDiagram(document.kind)) setOnlyFlowNodePortsActive(graph); setSelectedNodeId(null); @@ -1568,7 +1664,7 @@ export const DiagramEditorPane = ({ setHasSelection(false); setDirty(savedSnapshotRef.current !== diagramEditorSnapshot( titleRef.current, - graphToDocument(graph, document.kind, themeRef.current), + graphToDocument(graph, document.kind, themeRef.current, structureRef.current), )); }; graph.on("model:updated", markDirty); @@ -1598,7 +1694,7 @@ export const DiagramEditorPane = ({ const historyEnabled = graph.isHistoryEnabled(); if (historyEnabled) graph.disableHistory(); try { - applyMindMapHierarchy(graph, themeRef.current, appearanceRef.current); + applyMindMapHierarchy(graph, themeRef.current, appearanceRef.current, structureRef.current); } finally { if (historyEnabled) graph.enableHistory(); } @@ -1779,7 +1875,7 @@ export const DiagramEditorPane = ({ setHasSelection(false); setDirty(savedSnapshotRef.current !== diagramEditorSnapshot( titleRef.current, - graphToDocument(graph, document.kind, themeRef.current), + graphToDocument(graph, document.kind, themeRef.current, structureRef.current), )); setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() }); }); @@ -1820,7 +1916,7 @@ export const DiagramEditorPane = ({ shape: data?.shape ?? "process", ...(data?.parentId ? { parentId: data.parentId } : {}), ...(data?.resourceIcon ? { resourceIcon: data.resourceIcon } : {}), - }, themeRef.current, document.kind, appearanceRef.current)); + }, themeRef.current, document.kind, appearanceRef.current, structureRef.current)); const parent = data?.parentId ? graph.getCellById(data.parentId) : null; if (parent?.isNode()) parent.addChild(duplicate); return duplicate; @@ -1868,7 +1964,7 @@ export const DiagramEditorPane = ({ graph.bindKey(["meta+z", "ctrl+z"], (event) => { event.preventDefault(); graph.undo(); - applyGraphPalette(graph, themeRef.current, document.kind, appearanceRef.current); + applyGraphPalette(graph, themeRef.current, document.kind, appearanceRef.current, structureRef.current); graph.cleanSelection(); setSelectedNodeId(null); setSelectedNodeLabel(""); @@ -1878,13 +1974,13 @@ export const DiagramEditorPane = ({ setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() }); setDirty(savedSnapshotRef.current !== diagramEditorSnapshot( titleRef.current, - graphToDocument(graph, document.kind, themeRef.current), + graphToDocument(graph, document.kind, themeRef.current, structureRef.current), )); }); graph.bindKey(["meta+shift+z", "ctrl+shift+z", "ctrl+y"], (event) => { event.preventDefault(); graph.redo(); - applyGraphPalette(graph, themeRef.current, document.kind, appearanceRef.current); + applyGraphPalette(graph, themeRef.current, document.kind, appearanceRef.current, structureRef.current); graph.cleanSelection(); setSelectedNodeId(null); setSelectedNodeLabel(""); @@ -1894,7 +1990,7 @@ export const DiagramEditorPane = ({ setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() }); setDirty(savedSnapshotRef.current !== diagramEditorSnapshot( titleRef.current, - graphToDocument(graph, document.kind, themeRef.current), + graphToDocument(graph, document.kind, themeRef.current, structureRef.current), )); }); graphRef.current = graph; @@ -2035,7 +2131,7 @@ export const DiagramEditorPane = ({ ...(isMindMap && parent?.isNode() ? { parentId: parent.id } : {}), ...(architectureParentId ? { parentId: architectureParentId } : {}), ...(isArchitecture && options.resourceIcon ? { resourceIcon: options.resourceIcon } : {}), - }, themeRef.current, document.kind, appearanceRef.current)); + }, themeRef.current, document.kind, appearanceRef.current, structureRef.current)); if (architectureParentId) { const architectureParent = graph.getCellById(architectureParentId); if (architectureParent?.isNode()) architectureParent.addChild(node); @@ -2050,7 +2146,7 @@ export const DiagramEditorPane = ({ } if (isMindMap) { const positions = computeDiagramLayout( - graphToDocument(graph, document.kind, themeRef.current), + graphToDocument(graph, document.kind, themeRef.current, structureRef.current), requestedSibling && selected?.isNode() ? { insertedNodeId: id, insertAfterNodeId: selected.id } : {}, @@ -2059,7 +2155,7 @@ export const DiagramEditorPane = ({ const position = positions[graphNode.id]; if (position) graphNode.position(position.x, position.y); } - applyMindMapHierarchy(graph, themeRef.current, appearanceRef.current); + applyMindMapHierarchy(graph, themeRef.current, appearanceRef.current, structureRef.current); } graph.stopBatch("add"); settleScroller(); @@ -2184,7 +2280,7 @@ export const DiagramEditorPane = ({ width: size.width, height: size.height, shape, - }, themeRef.current, document.kind, appearanceRef.current)); + }, themeRef.current, document.kind, appearanceRef.current, structureRef.current)); graph.addEdge({ ...edgeMetadata( { id: createId("edge"), source: pending.sourceNodeId, target: id }, @@ -2219,7 +2315,7 @@ export const DiagramEditorPane = ({ if (document) { setDirty(savedSnapshotRef.current !== diagramEditorSnapshot( titleRef.current, - graphToDocument(graph, document.kind, themeRef.current), + graphToDocument(graph, document.kind, themeRef.current, structureRef.current), )); } setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() }); @@ -2235,6 +2331,7 @@ export const DiagramEditorPane = ({ themeRef.current, document?.kind ?? "flowchart", appearanceRef.current, + structureRef.current, ); graph.cleanSelection(); setSelectedNodeId(null); @@ -2246,7 +2343,7 @@ export const DiagramEditorPane = ({ if (document) { setDirty(savedSnapshotRef.current !== diagramEditorSnapshot( titleRef.current, - graphToDocument(graph, document.kind, themeRef.current), + graphToDocument(graph, document.kind, themeRef.current, structureRef.current), )); } }; @@ -2254,7 +2351,7 @@ export const DiagramEditorPane = ({ const applyAutoLayout = () => { const graph = graphRef.current; if (!graph || !document || readOnly || graph.getNodes().length === 0) return; - const layout = computeDiagramLayoutResult(graphToDocument(graph, document.kind, themeRef.current)); + const layout = computeDiagramLayoutResult(graphToDocument(graph, document.kind, themeRef.current, structureRef.current)); graph.startBatch("layout"); let changed = false; for (const nodeId of layout.nodeOrder) { @@ -2273,13 +2370,14 @@ export const DiagramEditorPane = ({ node.resize(geometry.width, geometry.height); } } - if (document.kind === "mind-map") applyMindMapHierarchy(graph, themeRef.current, appearanceRef.current); + if (document.kind === "mind-map") applyMindMapHierarchy(graph, themeRef.current, appearanceRef.current, structureRef.current); graph.stopBatch("layout"); + ensureDiagramPaperContainsNodes(graph); fitDiagramContent(graph, document, containerRef.current, 40, layout.viewport); if (changed) { setDirty(savedSnapshotRef.current !== diagramEditorSnapshot( titleRef.current, - graphToDocument(graph, document.kind, themeRef.current), + graphToDocument(graph, document.kind, themeRef.current, structureRef.current), )); setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() }); } @@ -2296,10 +2394,24 @@ export const DiagramEditorPane = ({ nextTheme, document?.kind ?? "flowchart", appearanceRef.current, + structureRef.current, ); setDirty(savedSnapshotRef.current !== diagramEditorSnapshot( titleRef.current, - graphToDocument(graph, document?.kind ?? "flowchart", nextTheme), + graphToDocument(graph, document?.kind ?? "flowchart", nextTheme, structureRef.current), + )); + }; + + const applyStructure = (nextStructure: DiagramStructure) => { + const graph = graphRef.current; + if (nextStructure === structure) return; + structureRef.current = nextStructure; + setStructure(nextStructure); + if (!graph || readOnly || document?.kind !== "mind-map") return; + applyGraphPalette(graph, themeRef.current, "mind-map", appearanceRef.current, nextStructure); + setDirty(savedSnapshotRef.current !== diagramEditorSnapshot( + titleRef.current, + graphToDocument(graph, "mind-map", themeRef.current, nextStructure), )); }; @@ -2329,7 +2441,7 @@ export const DiagramEditorPane = ({ const editSession = editSessionRef.current; if (!graph || !document || !editSession || readOnly || saving) return false; if ( - savedSnapshotRef.current === diagramEditorSnapshot(titleRef.current, graphToDocument(graph, document.kind, themeRef.current)) + savedSnapshotRef.current === diagramEditorSnapshot(titleRef.current, graphToDocument(graph, document.kind, themeRef.current, structureRef.current)) && !tagsDirty ) { setDirty(false); @@ -2339,7 +2451,7 @@ export const DiagramEditorPane = ({ setSaveError(null); setSaveFailed(false); try { - const nextDocument = graphToDocument(graph, document.kind, themeRef.current); + const nextDocument = graphToDocument(graph, document.kind, themeRef.current, structureRef.current); const markdown = serializeDiagramDocument(nextDocument); const nextTitle = titleRef.current; const nextTags = parseTagsText(tagsRef.current); @@ -2357,7 +2469,7 @@ export const DiagramEditorPane = ({ savedSnapshotRef.current = nextSnapshot; const currentSnapshot = diagramEditorSnapshot( titleRef.current, - graphToDocument(graph, document.kind, themeRef.current), + graphToDocument(graph, document.kind, themeRef.current, structureRef.current), ); const hasNewChanges = currentSnapshot !== nextSnapshot; const hasNewTagChanges = parseTagsText(tagsRef.current).join("\u0000") !== result.memo.tags.join("\u0000"); @@ -2406,7 +2518,7 @@ export const DiagramEditorPane = ({ const name = window.prompt(t("templates.templateNamePrompt"), titleRef.current); if (!name?.trim()) return; const currentDocument = graphRef.current - ? graphToDocument(graphRef.current, document.kind, themeRef.current) + ? graphToDocument(graphRef.current, document.kind, themeRef.current, structureRef.current) : document; const markdown = serializeDiagramDocument(currentDocument); void onSaveAsTemplate({ @@ -2488,7 +2600,7 @@ export const DiagramEditorPane = ({ 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 && dirty - ? serializeDiagramDocument(graphRef.current ? graphToDocument(graphRef.current, document.kind, themeRef.current) : document) + ? serializeDiagramDocument(graphRef.current ? graphToDocument(graphRef.current, document.kind, themeRef.current, structureRef.current) : document) : memo.contentMarkdown; const saveStatus = saveError ? "error" : saving || notebookUpdatePending ? "saving" : editorDirty ? "unsaved" : "saved"; const saveLabel = saveStatus === "error" @@ -2642,7 +2754,7 @@ export const DiagramEditorPane = ({ if (graph) { setDirty(savedSnapshotRef.current !== diagramEditorSnapshot( nextTitle, - graphToDocument(graph, document.kind, themeRef.current), + graphToDocument(graph, document.kind, themeRef.current, structureRef.current), )); } }} @@ -2720,6 +2832,8 @@ export const DiagramEditorPane = ({ onExport={exportDiagram} onRedo={() => runHistoryAction("redo")} onThemeChange={applyTheme} + onStructureChange={document.kind === "mind-map" ? applyStructure : undefined} + structure={structure} onUndo={() => runHistoryAction("undo")} zoomPercent={zoomPercent} onRead={document.kind === "flowchart" ? () => { if (graphRef.current) readFlowchart(graphRef.current, document, containerRef.current); } : undefined} @@ -2728,8 +2842,8 @@ export const DiagramEditorPane = ({ const graph = graphRef.current; if (!graph) return; graph.zoomTo(1); - const bounds = graph.getCellsBBox(graph.getNodes().filter((node) => node.isVisible())); - if (bounds) graph.centerPoint(bounds.center.x, bounds.center.y); + const bounds = diagramNodeBounds(graph); + if (bounds) graph.centerPoint(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); }} onZoomIn={() => graphRef.current?.zoom(0.1)} onZoomOut={() => graphRef.current?.zoom(-0.1)} diff --git a/apps/web/src/components/DiagramToolbar.tsx b/apps/web/src/components/DiagramToolbar.tsx index 3f73eb49..b81d6381 100644 --- a/apps/web/src/components/DiagramToolbar.tsx +++ b/apps/web/src/components/DiagramToolbar.tsx @@ -15,7 +15,7 @@ import { ZoomOut, } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { DiagramTheme } from "@edgeever/shared"; +import type { DiagramStructure, DiagramTheme } from "@edgeever/shared"; import { Button } from "@/components/ui/button"; import { MemoEditorToolbarDivider, MemoEditorToolbarRow } from "@/components/MemoEditorToolbarChrome"; import { @@ -39,6 +39,8 @@ type DiagramToolbarProps = { onExport: (format: "png" | "svg") => void; onRedo: () => void; onThemeChange: (theme: DiagramTheme) => void; + onStructureChange?: (structure: DiagramStructure) => void; + structure?: DiagramStructure; onUndo: () => void; onRead?: () => void; onFit: () => void; @@ -79,6 +81,7 @@ export const DiagramToolbar = ({ onExport, onRedo, onThemeChange, + onStructureChange, onUndo, onRead, onFit, @@ -88,6 +91,7 @@ export const DiagramToolbar = ({ onZoomOut, readOnly, selectionEditor, + structure, theme, }: DiagramToolbarProps) => { const { t } = useTranslation(); @@ -115,14 +119,34 @@ export const DiagramToolbar = ({ {t("diagram.fit")} {onRead ? {t("diagram.readFlowHint")} : null} - onStructureChange(value as DiagramStructure)}> + + + + + + + {t("diagram.structure")} + + + {t("diagram.structureMap")} + {t("diagram.structureBox")} + + + ) : null} + diff --git a/apps/web/src/lib/diagram-editor-snapshot.ts b/apps/web/src/lib/diagram-editor-snapshot.ts index 598f6698..d6d1c432 100644 --- a/apps/web/src/lib/diagram-editor-snapshot.ts +++ b/apps/web/src/lib/diagram-editor-snapshot.ts @@ -8,6 +8,7 @@ export const diagramEditorSnapshot = (title: string, document: DiagramDocument) schemaVersion: document.schemaVersion, kind: document.kind, theme: document.theme ?? "brand", + structure: document.kind === "mind-map" ? (document.structure === "box" ? "box" : "map") : undefined, nodes: document.nodes.map((node) => ({ id: node.id, label: node.label, x: node.x, y: node.y, shape: node.shape, parentId: node.parentId, resourceIcon: node.resourceIcon, diff --git a/apps/web/src/lib/diagram-scroller-fit.test.mjs b/apps/web/src/lib/diagram-scroller-fit.test.mjs new file mode 100644 index 00000000..921d3945 --- /dev/null +++ b/apps/web/src/lib/diagram-scroller-fit.test.mjs @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { applyDiagramScrollerFitOptions, isUsableDiagramBounds } from "./diagram-scroller-fit.ts"; + +describe("diagram scroller fit options", () => { + test("uses every node box so left-of-origin mind-map branches stay on the paper", () => { + const options = applyDiagramScrollerFitOptions( + { allowNewOrigin: "negative", contentArea: { x: 0, y: 0, width: 0, height: 0 } }, + { x: -420, y: 40, width: 960, height: 280 }, + ); + expect(options.contentArea).toEqual({ x: -420, y: 40, width: 960, height: 280 }); + expect(options.allowNewOrigin).toBe("negative"); + }); + + test("does not collapse the paper to an empty rectangle when nodes are unavailable", () => { + const options = applyDiagramScrollerFitOptions( + { contentArea: { x: 0, y: 0, width: 0, height: 0 } }, + null, + ); + expect(options.contentArea).toBeUndefined(); + }); + + test("rejects non-finite or empty node bounds", () => { + expect(isUsableDiagramBounds({ x: -10, y: 0, width: 0, height: 0 })).toBe(false); + expect(isUsableDiagramBounds({ x: Number.NaN, y: 0, width: 80, height: 36 })).toBe(false); + expect(isUsableDiagramBounds({ x: -10, y: 0, width: 80, height: 36 })).toBe(true); + }); +}); diff --git a/apps/web/src/lib/diagram-scroller-fit.ts b/apps/web/src/lib/diagram-scroller-fit.ts new file mode 100644 index 00000000..b9d01b46 --- /dev/null +++ b/apps/web/src/lib/diagram-scroller-fit.ts @@ -0,0 +1,49 @@ +export type DiagramFitBounds = { + x: number; + y: number; + width: number; + height: number; +}; + +const isFiniteNumber = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value); + +export const isUsableDiagramBounds = (bounds: DiagramFitBounds | null | undefined): bounds is DiagramFitBounds => { + if (!bounds) return false; + return isFiniteNumber(bounds.x) + && isFiniteNumber(bounds.y) + && isFiniteNumber(bounds.width) + && isFiniteNumber(bounds.height) + && bounds.width >= 0 + && bounds.height >= 0 + && (bounds.width > 0 || bounds.height > 0); +}; + +const isEmptyContentArea = (area: unknown) => { + if (!area || typeof area !== "object") return true; + const box = area as { width?: unknown; height?: unknown }; + return (!isFiniteNumber(box.width) || box.width === 0) + && (!isFiniteNumber(box.height) || box.height === 0); +}; + +// X6 Scroller.autoResize calls fitToContent. When autoResize is suspended it +// substitutes an empty rectangle, which shrinks the paper and clips nodes — +// especially mind-map branches left of the origin. Always prefer node geometry. +export const applyDiagramScrollerFitOptions = ( + options: Record, + nodeBounds: DiagramFitBounds | null | undefined, +): Record => { + if (isUsableDiagramBounds(nodeBounds)) { + options.contentArea = { + x: nodeBounds.x, + y: nodeBounds.y, + width: nodeBounds.width, + height: nodeBounds.height, + }; + return options; + } + if (isEmptyContentArea(options.contentArea)) { + delete options.contentArea; + } + return options; +}; diff --git a/apps/web/src/lib/diagram-theme.test.mjs b/apps/web/src/lib/diagram-theme.test.mjs index ae0565af..e8404edd 100644 --- a/apps/web/src/lib/diagram-theme.test.mjs +++ b/apps/web/src/lib/diagram-theme.test.mjs @@ -27,4 +27,9 @@ describe("diagram appearance palettes", () => { expect(contrast(dark.topicText, dark.topicFill)).toBeGreaterThanOrEqual(3); } }); + + test("treats retired mint and forest themes as forest green", () => { + expect(resolveDiagramPalette("ocean", "light")).toEqual(resolveDiagramPalette("brand", "light")); + expect(resolveDiagramPalette("ink", "dark")).toEqual(resolveDiagramPalette("brand", "dark")); + }); }); diff --git a/apps/web/src/lib/diagram-theme.ts b/apps/web/src/lib/diagram-theme.ts index 3e9b3e02..819064e4 100644 --- a/apps/web/src/lib/diagram-theme.ts +++ b/apps/web/src/lib/diagram-theme.ts @@ -103,7 +103,35 @@ export const DIAGRAM_THEME_PALETTES: Record - DIAGRAM_THEME_PALETTES[theme][appearance]; + DIAGRAM_THEME_PALETTES[theme === "classic" ? "classic" : "brand"][appearance]; diff --git a/packages/shared/src/diagram-layout.ts b/packages/shared/src/diagram-layout.ts index 5f492a95..4f8d4c98 100644 --- a/packages/shared/src/diagram-layout.ts +++ b/packages/shared/src/diagram-layout.ts @@ -16,6 +16,7 @@ import { type DiagramEdgeKind, type DiagramKind, type DiagramNodeShape, + type DiagramStructure, type DiagramTheme, } from "./diagram"; @@ -66,6 +67,7 @@ export type DiagramIrNodeType = export type DiagramIr = { kind: DiagramKind; theme?: DiagramTheme; + structure?: DiagramStructure; layout?: { direction?: "left-to-right" | "top-to-bottom" }; nodes: Array<{ id: string; @@ -545,7 +547,7 @@ export const compileDiagramIr = (ir: DiagramIr): DiagramDocument => { const nodes = ir.nodes.map((node, index) => { const shape = irNodeShape(ir.kind, node.type); const size = ir.kind === "mind-map" - ? mindMapNodePresentation(node.label, mindMapNodeRole(ir.nodes, node.id)) + ? mindMapNodePresentation(node.label, mindMapNodeRole(ir.nodes, node.id), ir.structure) : ir.kind === "architecture" ? compactArchitectureNodeSize(shape) : flowchartNodePresentation(shape, node.label); @@ -580,6 +582,7 @@ export const compileDiagramIr = (ir: DiagramIr): DiagramDocument => { schemaVersion: ir.kind === "architecture" ? ARCHITECTURE_DIAGRAM_SCHEMA_VERSION : DIAGRAM_SCHEMA_VERSION, kind: ir.kind, ...(ir.theme ? { theme: ir.theme } : {}), + ...(ir.kind === "mind-map" && ir.structure ? { structure: ir.structure } : {}), nodes, edges, }; diff --git a/packages/shared/src/diagram-mindmap-style.test.mjs b/packages/shared/src/diagram-mindmap-style.test.mjs index 3ccad47e..478bb782 100644 --- a/packages/shared/src/diagram-mindmap-style.test.mjs +++ b/packages/shared/src/diagram-mindmap-style.test.mjs @@ -2,12 +2,17 @@ import { describe, expect, test } from "bun:test"; import { compactMindMapNodeSize, mindMapBranchSides, + mindMapBranchTint, + mindMapBranchTintIndex, mindMapConnectorPath, + mindMapEdgeTerminal, mindMapEdgeVisual, mindMapNodePresentation, mindMapNodeRole, mindMapNodeVisual, mindMapRootRadius, + mindMapUsesUnderline, + resolveMindMapNodeStyle, MIND_MAP_CONNECTOR_NAME, MIND_MAP_VERTICAL_GAP, } from "./diagram-mindmap-style.ts"; @@ -51,7 +56,8 @@ describe("mind map presentation", () => { expect(root.body.rx).toBe(23); expect(primary.body.fill).toBe(palette.nodeFill); expect(primary.body.stroke).toBe(palette.topicStroke); - expect(nested.body.fill).toBe(palette.canvas); + expect(nested.body.fill).toBe("transparent"); + expect(nested.underline.stroke).toBe(palette.mindMapEdge); expect(nested.label.fontWeight).toBe(500); expect(mindMapRootRadius(46)).toBe(23); }); @@ -72,6 +78,40 @@ describe("mind map presentation", () => { expect(MIND_MAP_CONNECTOR_NAME).toBe("edgeever-mindmap"); }); + test("underlines nested topics and keeps first-level topics boxed", () => { + const nested = mindMapNodeVisual("nested", palette, { underline: true, width: 96, height: 32 }); + expect(mindMapUsesUnderline("nested")).toBe(true); + expect(mindMapUsesUnderline("nested", "map")).toBe(true); + expect(mindMapUsesUnderline("nested", "box")).toBe(false); + expect(mindMapUsesUnderline("primary")).toBe(false); + expect(nested.body.fill).toBe("transparent"); + expect(nested.underline.stroke).toBe(palette.mindMapEdge); + expect(nested.underline.d).toContain("H"); + expect(mindMapEdgeTerminal({ x: 0, y: 0, width: 96, height: 32 }, "nested", "left").connectionPoint.name).toBe("anchor"); + expect(mindMapEdgeTerminal({ x: 0, y: 0, width: 96, height: 32 }, "primary", "right").connectionPoint.name).toBe("boundary"); + }); + + test("colors a classic branch family from the first-level sibling index", () => { + const nodes = [ + { id: "root" }, + { id: "one", parentId: "root", y: 10 }, + { id: "two", parentId: "root", y: 80 }, + { id: "one-a", parentId: "one", y: 10 }, + ]; + expect(mindMapBranchTintIndex(nodes, "root")).toBeNull(); + expect(mindMapBranchTintIndex(nodes, "one")).toBe(0); + expect(mindMapBranchTintIndex(nodes, "two")).toBe(1); + expect(mindMapBranchTintIndex(nodes, "one-a")).toBe(0); + const first = mindMapBranchTint(0, "light"); + const second = mindMapBranchTint(1, "light"); + expect(first.edge).not.toBe(second.edge); + const styled = resolveMindMapNodeStyle(nodes, "one-a", palette, "classic", "light", { width: 96, height: 32 }); + expect(styled.underline).toBe(true); + expect(styled.visual.underline.stroke).toBe(first.edge); + const brandNested = resolveMindMapNodeStyle(nodes, "one-a", palette, "brand", "light", { width: 96, height: 32 }); + expect(brandNested.visual.underline.stroke).toBe(palette.mindMapEdge); + }); + test("builds a closed horizontal cubic ribbon that is thicker at the source", () => { const path = mindMapConnectorPath({ x: 0, y: 0 }, { x: 80, y: 0 }, 4, 1); expect(path.startsWith("M 0.00 2.00")).toBe(true); diff --git a/packages/shared/src/diagram-mindmap-style.ts b/packages/shared/src/diagram-mindmap-style.ts index 17c07930..781d42f7 100644 --- a/packages/shared/src/diagram-mindmap-style.ts +++ b/packages/shared/src/diagram-mindmap-style.ts @@ -1,6 +1,8 @@ +import type { DiagramStructure, DiagramTheme } from "./diagram"; import { visualTextUnits } from "./diagram-node-presentation"; export type MindMapRole = "root" | "primary" | "nested"; +export type MindMapAppearance = "light" | "dark"; export type MindMapPalette = { topicFill: string; @@ -13,8 +15,16 @@ export type MindMapPalette = { canvas: string; }; +export type MindMapBranchTint = { + fill: string; + stroke: string; + text: string; + edge: string; +}; + export type MindMapPoint = { x: number; y: number }; export type MindMapBox = { x: number; y: number; width: number; height: number }; +export type MindMapIndexedNode = { id: string; parentId?: string; x?: number; y?: number }; export const MIND_MAP_CONNECTOR_NAME = "edgeever-mindmap"; export const MIND_MAP_HORIZONTAL_GAP = 72; @@ -22,7 +32,30 @@ export const MIND_MAP_VERTICAL_GAP = 20; export const MIND_MAP_LABEL_FONT = 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; -type MindMapSizedNode = { id: string; parentId?: string }; +export const MIND_MAP_TOPIC_MARKUP = [ + { tagName: "rect", selector: "body" }, + { tagName: "path", selector: "underline" }, + { tagName: "text", selector: "label" }, +]; + +export const MIND_MAP_BRANCH_TINTS: Record = { + light: [ + { fill: "#E7F6EF", stroke: "#0F8A5C", text: "#145C40", edge: "#16A06E" }, + { fill: "#E7F0FE", stroke: "#2563EB", text: "#1E3A8A", edge: "#3B82F6" }, + { fill: "#F3E8FF", stroke: "#7C3AED", text: "#5B21B6", edge: "#8B5CF6" }, + { fill: "#FEF3C7", stroke: "#D97706", text: "#92400E", edge: "#F59E0B" }, + { fill: "#FCE7F3", stroke: "#DB2777", text: "#9D174D", edge: "#EC4899" }, + { fill: "#CFFAFE", stroke: "#0E7490", text: "#155E75", edge: "#06B6D4" }, + ], + dark: [ + { fill: "#1A2A22", stroke: "#4DB58B", text: "#D7F4E8", edge: "#4DB58B" }, + { fill: "#1A2438", stroke: "#60A5FA", text: "#DBEAFE", edge: "#60A5FA" }, + { fill: "#251B38", stroke: "#A78BFA", text: "#EDE9FE", edge: "#A78BFA" }, + { fill: "#2A2114", stroke: "#FBBF24", text: "#FEF3C7", edge: "#FBBF24" }, + { fill: "#2A1520", stroke: "#F472B6", text: "#FCE7F3", edge: "#F472B6" }, + { fill: "#15252B", stroke: "#22D3EE", text: "#CFFAFE", edge: "#22D3EE" }, + ], +}; const wrapVisualText = (label: string, capacity: number) => label.split("\n").flatMap((paragraph) => { const lines: string[] = []; @@ -38,8 +71,16 @@ const wrapVisualText = (label: string, capacity: number) => label.split("\n").fl return lines; }); +const formatPoint = (value: number) => (Math.round(value * 100) / 100).toFixed(2); + +export const mindMapUsesBranchColors = (theme?: DiagramTheme) => theme === "classic"; + +export const mindMapUsesUnderline = (role: MindMapRole, structure?: DiagramStructure) => ( + role === "nested" && structure !== "box" +); + export const mindMapNodeRole = ( - nodes: MindMapSizedNode[], + nodes: MindMapIndexedNode[], nodeId: string, ): MindMapRole => { const byId = new Map(nodes.map((node) => [node.id, node])); @@ -50,14 +91,52 @@ export const mindMapNodeRole = ( return "nested"; }; -export const mindMapNodeSize = (label: string, role: MindMapRole = "primary") => { +export const mindMapBranchTintIndex = ( + nodes: MindMapIndexedNode[], + nodeId: string, +): number | null => { + const byId = new Map(nodes.map((node) => [node.id, node])); + const node = byId.get(nodeId); + if (!node?.parentId) return null; + let firstLevel = node; + let current = node; + const visited = new Set(); + while (current.parentId && byId.has(current.parentId) && !visited.has(current.id)) { + visited.add(current.id); + const parent = byId.get(current.parentId); + if (!parent) break; + if (!parent.parentId) { + firstLevel = current; + break; + } + current = parent; + firstLevel = current; + } + const siblings = nodes + .filter((item) => item.parentId === firstLevel.parentId) + .sort((left, right) => (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0) || left.id.localeCompare(right.id)); + const index = siblings.findIndex((item) => item.id === firstLevel.id); + return index < 0 ? 0 : index; +}; + +export const mindMapBranchTint = ( + index: number | null, + appearance: MindMapAppearance, +): MindMapBranchTint | undefined => { + if (index == null) return undefined; + const tints = MIND_MAP_BRANCH_TINTS[appearance]; + return tints[index % tints.length]; +}; + +export const mindMapNodeSize = (label: string, role: MindMapRole = "primary", structure?: DiagramStructure) => { const isRoot = role === "root"; + const underline = mindMapUsesUnderline(role, structure); return { width: Math.round(Math.min( isRoot ? 180 : 168, - Math.max(isRoot ? 124 : 96, visualTextUnits(label) * 13 + (isRoot ? 36 : 28)), + Math.max(isRoot ? 124 : underline ? 88 : 96, visualTextUnits(label) * 13 + (isRoot ? 36 : underline ? 22 : 28)), )), - height: isRoot ? 46 : 36, + height: isRoot ? 46 : underline ? 32 : 36, }; }; @@ -65,14 +144,15 @@ export const compactMindMapNodeSize = (label: string, isRoot: boolean) => ( mindMapNodeSize(label, isRoot ? "root" : "primary") ); -export const mindMapNodePresentation = (label: string, role: MindMapRole) => { - const size = mindMapNodeSize(label, role); +export const mindMapNodePresentation = (label: string, role: MindMapRole, structure?: DiagramStructure) => { + const size = mindMapNodeSize(label, role, structure); + const underline = mindMapUsesUnderline(role, structure); const lineHeight = role === "root" ? 20 : 18; - const padX = role === "root" ? 36 : 28; + const padX = role === "root" ? 36 : underline ? 22 : 28; const lines = wrapVisualText(label, Math.max(4, (size.width - padX) / 13)); return { ...size, - height: Math.max(size.height, lines.length * lineHeight + (role === "root" ? 18 : 14)), + height: Math.max(size.height, lines.length * lineHeight + (role === "root" ? 18 : underline ? 10 : 14)), text: lines.join("\n"), fontSize: role === "root" ? 15 : role === "primary" ? 14 : 13, }; @@ -80,65 +160,154 @@ export const mindMapNodePresentation = (label: string, role: MindMapRole) => { export const mindMapRootRadius = (height: number) => Math.round(Math.max(1, height / 2)); -export const mindMapNodeVisual = (role: MindMapRole, palette: MindMapPalette) => { +export const mindMapUnderlinePath = (width: number, height: number) => ( + `M 1 ${formatPoint(Math.max(2, height - 2))} H ${formatPoint(Math.max(2, width - 1))}` +); + +export const mindMapNodeVisual = ( + role: MindMapRole, + palette: MindMapPalette, + options: { + tint?: MindMapBranchTint; + underline?: boolean; + structure?: DiagramStructure; + width?: number; + height?: number; + } = {}, +) => { + const underline = options.underline ?? mindMapUsesUnderline(role, options.structure); + const width = options.width ?? 96; + const height = options.height ?? (role === "root" ? 46 : underline ? 32 : 36); + const tint = options.tint; if (role === "root") { return { body: { fill: palette.topicFill, stroke: palette.topicStroke, strokeWidth: 1.5, - rx: 23, - ry: 23, + rx: mindMapRootRadius(height), + ry: mindMapRootRadius(height), }, label: { fill: palette.topicText, fontSize: 15, fontWeight: 650, fontFamily: MIND_MAP_LABEL_FONT, + lineHeight: 20, + refY: "50%", + textAnchor: "middle" as const, + textVerticalAnchor: "middle" as const, + }, + underline: { d: "", stroke: "none", fill: "none" }, + }; + } + if (underline) { + const color = tint?.edge ?? palette.mindMapEdge; + return { + body: { + fill: "transparent", + stroke: "none", + strokeWidth: 0, + rx: 0, + ry: 0, + }, + label: { + fill: tint?.text ?? palette.nodeText, + fontSize: 13, + fontWeight: 500, + fontFamily: MIND_MAP_LABEL_FONT, + lineHeight: 18, + refX: "50%", + refY: height - 6, + textAnchor: "middle" as const, + textVerticalAnchor: "bottom" as const, + }, + underline: { + d: mindMapUnderlinePath(width, height), + stroke: color, + strokeWidth: 1.8, + fill: "none", + strokeLinecap: "round", + pointerEvents: "none", }, }; } - if (role === "primary") { + if (role === "nested") { return { body: { - fill: palette.nodeFill, - stroke: palette.topicStroke, - strokeWidth: 1.5, - rx: 10, - ry: 10, + fill: tint?.fill ?? palette.canvas, + stroke: tint?.stroke ?? palette.nodeStroke, + strokeWidth: 1, + rx: 8, + ry: 8, }, label: { - fill: palette.nodeText, - fontSize: 14, - fontWeight: 650, + fill: tint?.text ?? palette.nodeText, + fontSize: 13, + fontWeight: 500, fontFamily: MIND_MAP_LABEL_FONT, + lineHeight: 18, + refY: "50%", + textAnchor: "middle" as const, + textVerticalAnchor: "middle" as const, }, + underline: { d: "", stroke: "none", fill: "none" }, }; } return { body: { - fill: palette.canvas, - stroke: palette.nodeStroke, - strokeWidth: 1, - rx: 8, - ry: 8, + fill: tint?.fill ?? palette.nodeFill, + stroke: tint?.stroke ?? palette.topicStroke, + strokeWidth: 1.5, + rx: 10, + ry: 10, }, label: { - fill: palette.nodeText, - fontSize: 13, - fontWeight: 500, + fill: tint?.text ?? palette.nodeText, + fontSize: 14, + fontWeight: 650, fontFamily: MIND_MAP_LABEL_FONT, + lineHeight: 18, + refY: "50%", + textAnchor: "middle" as const, + textVerticalAnchor: "middle" as const, }, + underline: { d: "", stroke: "none", fill: "none" }, }; }; -export const mindMapEdgeVisual = (sourceRole: MindMapRole, palette: MindMapPalette) => ( - sourceRole === "root" - ? { stroke: palette.mindMapEdge, sourceWidth: 3.1, targetWidth: 1.55 } - : sourceRole === "primary" - ? { stroke: palette.mindMapEdge, sourceWidth: 1.85, targetWidth: 1.15 } - : { stroke: palette.mindMapEdge, sourceWidth: 1.25, targetWidth: 0.9 } -); +export const resolveMindMapNodeStyle = ( + nodes: MindMapIndexedNode[], + nodeId: string, + palette: MindMapPalette, + theme: DiagramTheme | undefined, + appearance: MindMapAppearance, + size: { width: number; height: number }, + structure?: DiagramStructure, +) => { + const role = mindMapNodeRole(nodes, nodeId); + const underline = mindMapUsesUnderline(role, structure); + const tint = mindMapUsesBranchColors(theme) + ? mindMapBranchTint(mindMapBranchTintIndex(nodes, nodeId), appearance) + : undefined; + return { + role, + underline, + tint, + visual: mindMapNodeVisual(role, palette, { tint, underline, structure, width: size.width, height: size.height }), + }; +}; + +export const mindMapEdgeVisual = ( + sourceRole: MindMapRole, + palette: MindMapPalette, + tint?: MindMapBranchTint, +) => { + const stroke = tint?.edge ?? palette.mindMapEdge; + if (sourceRole === "root") return { stroke, sourceWidth: 3.1, targetWidth: 1.55 }; + if (sourceRole === "primary") return { stroke, sourceWidth: 1.85, targetWidth: 1.15 }; + return { stroke, sourceWidth: 1.25, targetWidth: 0.9 }; +}; export const mindMapBranchSides = (source: MindMapBox, target: MindMapBox) => { const sourceCenter = source.x + source.width / 2; @@ -148,7 +317,18 @@ export const mindMapBranchSides = (source: MindMapBox, target: MindMapBox) => { : { source: "left" as const, target: "right" as const }; }; -const formatPoint = (value: number) => (Math.round(value * 100) / 100).toFixed(2); +export const mindMapEdgeTerminal = ( + box: MindMapBox, + role: MindMapRole, + side: "left" | "right", + structure?: DiagramStructure, +) => ({ + anchor: { + name: side, + ...(mindMapUsesUnderline(role, structure) ? { args: { dy: box.height / 2 - 2 } } : {}), + }, + connectionPoint: { name: mindMapUsesUnderline(role, structure) ? "anchor" : "boundary" }, +}); const cubicPoint = (p0: MindMapPoint, p1: MindMapPoint, p2: MindMapPoint, p3: MindMapPoint, t: number) => { const u = 1 - t; diff --git a/packages/shared/src/diagram-view.ts b/packages/shared/src/diagram-view.ts index 26b33ed5..7a2da0e6 100644 --- a/packages/shared/src/diagram-view.ts +++ b/packages/shared/src/diagram-view.ts @@ -2,12 +2,13 @@ import { flowchartNodePresentation } from "./diagram-node-presentation"; import type { ArchitectureResourceIcon, DiagramDocument, DiagramNodeShape, DiagramTheme } from "./diagram"; import { MIND_MAP_CONNECTOR_NAME, + MIND_MAP_TOPIC_MARKUP, mindMapBranchSides, + mindMapEdgeTerminal, mindMapEdgeVisual, mindMapNodePresentation, mindMapNodeRole, - mindMapNodeVisual, - mindMapRootRadius, + resolveMindMapNodeStyle, } from "./diagram-mindmap-style"; export type DiagramAppearance = "light" | "dark"; @@ -39,12 +40,16 @@ const PALETTES: Record> light: { topicFill: BRAND_GREEN, topicText: "#F5FBF8", nodeFill: "#19261F", nodeText: "#E5EEE9", nodeStroke: "#3C594A", topicStroke: "#68D6B0", mindMapEdge: "#58BA94", flowEdge: "#68A78D", canvas: "#101512" }, dark: { topicFill: BRAND_GREEN, topicText: "#F5FBF8", nodeFill: "#151D19", nodeText: "#E7F0EB", nodeStroke: "#3C594A", topicStroke: "#68D6B0", mindMapEdge: "#58BA94", flowEdge: "#72B296", canvas: "#0B0F0D" }, }, + classic: { + light: { topicFill: BRAND_GREEN, topicText: "#FFFFFF", nodeFill: "#F7FAF8", nodeText: "#173B2E", nodeStroke: "#D5E3DB", topicStroke: "#12845B", mindMapEdge: "#16A06E", flowEdge: "#408A6D", canvas: "#FBFCFB" }, + dark: { topicFill: BRAND_GREEN, topicText: "#F4FFF9", nodeFill: "#18211D", nodeText: "#E8F2ED", nodeStroke: "#3B5248", topicStroke: "#58CDA4", mindMapEdge: "#4DB58B", flowEdge: "#72B99B", canvas: "#101311" }, + }, }; export const resolvePortableDiagramPalette = ( theme: DiagramTheme = "brand", appearance: DiagramAppearance = "light", -) => PALETTES[theme][appearance]; +) => PALETTES[theme === "classic" ? "classic" : "brand"][appearance]; const architectureAccent: Partial> = { client: "#0891B2", @@ -91,12 +96,15 @@ export const diagramDocumentToX6Cells = ( const palette = resolvePortableDiagramPalette(document.theme ?? "brand", appearance); const nodes = document.nodes.map((node) => { const mindMapRole = document.kind === "mind-map" ? mindMapNodeRole(document.nodes, node.id) : null; - const mindVisual = mindMapRole ? mindMapNodeVisual(mindMapRole, palette) : null; const presentation = document.kind === "flowchart" ? flowchartNodePresentation(node.shape, node.label) : mindMapRole - ? mindMapNodePresentation(node.label, mindMapRole) + ? mindMapNodePresentation(node.label, mindMapRole, document.structure) : { width: node.width, height: node.height, text: node.label }; + const mindStyle = mindMapRole + ? resolveMindMapNodeStyle(document.nodes, node.id, palette, document.theme, appearance, presentation, document.structure) + : null; + const mindVisual = mindStyle?.visual ?? null; const isRootTopic = node.shape === "topic" && !node.parentId; const isTerminator = node.shape === "terminator"; const isBoundary = node.shape === "boundary"; @@ -112,7 +120,6 @@ export const diagramDocumentToX6Cells = ( const stroke = mindVisual ? mindVisual.body.stroke : isBoundary ? palette.nodeStroke : accent ?? (emphasized ? palette.topicStroke : palette.nodeStroke); - const rootRadius = mindMapRole === "root" ? mindMapRootRadius(presentation.height) : undefined; const usesArchitectureIcon = document.kind === "architecture" && !isBoundary; const iconGlyph = node.resourceIcon ? architectureResourceGlyphs[node.resourceIcon] @@ -130,27 +137,34 @@ export const diagramDocumentToX6Cells = ( { tagName: "rect", selector: "iconFrame" }, { tagName: "text", selector: "resourceIcon" }, { tagName: "text", selector: "label" }, - ] } : {}), + ] } : mindMapRole ? { markup: MIND_MAP_TOPIC_MARKUP } : {}), attrs: { body: { fill, stroke, strokeWidth: mindVisual?.body.strokeWidth ?? (isBoundary || emphasized || accent ? 1.5 : 1), strokeDasharray: isBoundary || node.shape === "external" ? "7 5" : undefined, - rx: rootRadius ?? mindVisual?.body.rx ?? (isTerminator ? 24 : node.shape === "database" ? 24 : 11), - ry: rootRadius ?? mindVisual?.body.ry ?? (isTerminator ? 24 : node.shape === "database" ? 24 : 11), + rx: mindVisual?.body.rx ?? (isTerminator ? 24 : node.shape === "database" ? 24 : 11), + ry: mindVisual?.body.ry ?? (isTerminator ? 24 : node.shape === "database" ? 24 : 11), ...(node.shape === "decision" ? { refPoints: "0,10 10,0 20,10 10,20" } : {}), }, label: { text: presentation.text, - lineHeight: mindMapRole === "root" ? 20 : 18, + lineHeight: mindVisual?.label.lineHeight ?? (mindMapRole === "root" ? 20 : 18), fill: mindVisual?.label.fill ?? (emphasized ? palette.topicText : palette.nodeText), fontSize: mindVisual?.label.fontSize ?? (node.shape === "topic" ? 14 : isBoundary ? 12 : 13), fontWeight: mindVisual?.label.fontWeight ?? (emphasized || isBoundary || accent ? 650 : 500), - ...(mindVisual ? { fontFamily: mindVisual.label.fontFamily } : {}), + ...(mindVisual ? { + fontFamily: mindVisual.label.fontFamily, + refX: mindVisual.label.refX, + refY: mindVisual.label.refY, + textAnchor: mindVisual.label.textAnchor, + textVerticalAnchor: mindVisual.label.textVerticalAnchor, + } : {}), ...(isBoundary ? { refX: 18, refY: 22, textAnchor: "start", textVerticalAnchor: "middle" } : {}), ...(usesArchitectureIcon ? { refX: 54, refY: "50%", textAnchor: "start", textVerticalAnchor: "middle" } : {}), }, + ...(mindVisual ? { underline: mindVisual.underline } : {}), ...(usesArchitectureIcon ? { iconFrame: { x: 10, @@ -182,15 +196,29 @@ export const diagramDocumentToX6Cells = ( const sourceNode = projectedById.get(edge.source); const targetNode = projectedById.get(edge.target); const mindMapSourceRole = document.kind === "mind-map" ? mindMapNodeRole(document.nodes, edge.source) : null; - const mindEdge = mindMapSourceRole ? mindMapEdgeVisual(mindMapSourceRole, palette) : null; + const mindMapTargetRole = document.kind === "mind-map" ? mindMapNodeRole(document.nodes, edge.target) : null; + const branchTint = document.kind === "mind-map" + ? resolveMindMapNodeStyle(document.nodes, edge.target, palette, document.theme, appearance, { width: 96, height: 36 }, document.structure).tint + : undefined; + const mindEdge = mindMapSourceRole ? mindMapEdgeVisual(mindMapSourceRole, palette, branchTint) : null; const sides = sourceNode && targetNode ? mindMapBranchSides(sourceNode, targetNode) : { source: "right" as const, target: "left" as const }; + const sourceTerminal = sourceNode && mindMapSourceRole + ? mindMapEdgeTerminal(sourceNode, mindMapSourceRole, sides.source, document.structure) + : null; + const targetTerminal = targetNode && mindMapTargetRole + ? mindMapEdgeTerminal(targetNode, mindMapTargetRole, sides.target, document.structure) + : null; const stroke = edgeKind === "data" ? "#7C3AED" : edgeKind === "async" ? "#EA580C" : mindEdge?.stroke ?? palette.flowEdge; return { id: edge.id, - source: document.kind === "mind-map" ? { cell: edge.source, anchor: { name: sides.source } } : { cell: edge.source }, - target: document.kind === "mind-map" ? { cell: edge.target, anchor: { name: sides.target } } : { cell: edge.target }, + source: document.kind === "mind-map" + ? { cell: edge.source, ...(sourceTerminal ?? { anchor: { name: sides.source } }) } + : { cell: edge.source }, + target: document.kind === "mind-map" + ? { cell: edge.target, ...(targetTerminal ?? { anchor: { name: sides.target } }) } + : { cell: edge.target }, router: document.kind === "flowchart" ? { name: "manhattan", args: { padding: 28, step: 10 } } : undefined, connector: document.kind === "mind-map" ? { name: MIND_MAP_CONNECTOR_NAME, args: { sourceWidth: mindEdge?.sourceWidth, targetWidth: mindEdge?.targetWidth } } diff --git a/packages/shared/src/diagram.test.mjs b/packages/shared/src/diagram.test.mjs index 2632e575..fcc730ab 100644 --- a/packages/shared/src/diagram.test.mjs +++ b/packages/shared/src/diagram.test.mjs @@ -8,6 +8,7 @@ describe("diagram document", () => { const document = createDefaultDiagramDocument("mind-map"); document.nodes[0].label = "产品路线图 🚀"; document.theme = "ocean"; + document.structure = "box"; expect(parseDiagramDocument(serializeDiagramDocument(document))).toEqual(document); }); @@ -64,11 +65,23 @@ describe("diagram document", () => { expect(light.nodes[0].attrs.body.fill).toBe("#16A06E"); expect(light.nodes[0].attrs.body.rx).toBe(23); expect(light.nodes[1].attrs.body.fill).toBe("#F0F8F4"); - expect(light.nodes.find((node) => node.id === "topic-1-a").attrs.body.fill).toBe("#F8FAF9"); + const nested = light.nodes.find((node) => node.id === "topic-1-a"); + expect(nested.attrs.body.fill).toBe("transparent"); + expect(nested.attrs.underline.stroke).toBe("#55B891"); expect(light.edges[0].attrs.line.stroke).toBe("#55B891"); expect(light.edges[0].attrs.line.targetMarker).toBeNull(); expect(light.edges[0].connector.name).toBe("edgeever-mindmap"); expect(light.edges[0].source.anchor.name).toBe("right"); + expect(light.edges.find((edge) => edge.target.cell === "topic-1-a").target.anchor.args.dy).toBeGreaterThan(0); + const boxed = diagramDocumentToX6Cells({ ...document, structure: "box" }, "light"); + expect(boxed.nodes.find((node) => node.id === "topic-1-a").attrs.body.fill).not.toBe("transparent"); + expect(boxed.nodes.find((node) => node.id === "topic-1-a").attrs.underline.stroke).toBe("none"); + + const classic = diagramDocumentToX6Cells({ ...document, theme: "classic" }, "light"); + expect(classic.nodes.find((node) => node.id === "topic-1").attrs.body.stroke) + .not.toBe(classic.nodes.find((node) => node.id === "topic-2").attrs.body.stroke); + expect(classic.nodes.find((node) => node.id === "topic-1-a").attrs.underline.stroke) + .toBe(classic.edges.find((edge) => edge.target.cell === "topic-1").attrs.line.stroke); const dark = diagramDocumentToX6Cells(document, "dark"); expect(dark.canvas).toBe("#101311"); diff --git a/packages/shared/src/diagram.ts b/packages/shared/src/diagram.ts index ebf1348e..2c7e5fd9 100644 --- a/packages/shared/src/diagram.ts +++ b/packages/shared/src/diagram.ts @@ -19,7 +19,14 @@ export type DiagramNodeShape = | "external" | "boundary"; export type DiagramEdgeKind = "dependency" | "request" | "async" | "data"; -export type DiagramTheme = "brand" | "ocean" | "ink"; +export const DIAGRAM_THEMES = ["brand", "ocean", "ink", "classic"] as const; +export type DiagramTheme = (typeof DIAGRAM_THEMES)[number]; +export const DIAGRAM_STRUCTURES = ["map", "box"] as const; +export type DiagramStructure = (typeof DIAGRAM_STRUCTURES)[number]; + +export const resolveDiagramStructure = (structure?: DiagramStructure): DiagramStructure => ( + structure === "box" ? "box" : "map" +); export const ARCHITECTURE_RESOURCE_ICONS = [ "client", "webApp", "mobileApp", "website", "apiClient", @@ -60,6 +67,7 @@ export type DiagramDocument = { schemaVersion: typeof DIAGRAM_SCHEMA_VERSION | typeof ARCHITECTURE_DIAGRAM_SCHEMA_VERSION; kind: DiagramKind; theme?: DiagramTheme; + structure?: DiagramStructure; nodes: DiagramNode[]; edges: DiagramEdge[]; }; @@ -151,7 +159,8 @@ export const parseDiagramDocument = (markdown: string | null | undefined): Diagr if ( !Array.isArray(value.nodes) || !Array.isArray(value.edges) - || (value.theme !== undefined && !["brand", "ocean", "ink"].includes(String(value.theme))) + || (value.theme !== undefined && !DIAGRAM_THEMES.includes(value.theme as DiagramTheme)) + || (value.structure !== undefined && !DIAGRAM_STRUCTURES.includes(value.structure as DiagramStructure)) ) return null; const nodes = value.nodes.map(parseNode); const edges = value.edges.map(parseEdge); @@ -174,6 +183,7 @@ export const parseDiagramDocument = (markdown: string | null | undefined): Diagr schemaVersion: value.schemaVersion as DiagramDocument["schemaVersion"], kind: value.kind as DiagramKind, ...(value.theme ? { theme: value.theme as DiagramTheme } : {}), + ...(value.kind === "mind-map" && value.structure ? { structure: value.structure as DiagramStructure } : {}), nodes: nodes as DiagramNode[], edges: edges as DiagramEdge[], }; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 5002e8c8..ac431364 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -362,10 +362,12 @@ export const enUS = { autoLayout: "Auto layout", autoLayoutTooltip: "Re-arrange all nodes and connections", toolbar: "Diagram toolbar", - theme: "Diagram theme", + theme: "Color scheme", themeBrand: "Forest Green", - themeOcean: "Mint paper", - themeInk: "Deep forest", + themeClassic: "Spectrum", + structure: "Structure", + structureMap: "Mind map", + structureBox: "Boxes", export: "Export", exportPng: "Export PNG", exportSvg: "Export SVG", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index aab5920d..4c344115 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -362,10 +362,12 @@ export const zhCN = { autoLayout: "自动布局", autoLayoutTooltip: "重新规整所有节点与连线", toolbar: "图形工具栏", - theme: "图表主题", + theme: "配色", themeBrand: "森绿", - themeOcean: "薄荷纸", - themeInk: "深林", + themeClassic: "彩枝", + structure: "结构", + structureMap: "脑图", + structureBox: "盒子", export: "导出", exportPng: "导出 PNG", exportSvg: "导出 SVG",