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",