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.
This commit is contained in:
tianma-if committed 2026-09-08 18:01:11 +08:00
1 parent a16de56268
commit 748be71645
18 files changed
+714 -156

No files matched your search

+9 -3
View File
@@ -205,7 +205,7 @@ const DIAGRAM_IR_NODE_TYPES = new Set<DiagramIrNodeType>([
"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<string, unknown>): 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<string, unknown>): 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),
});
+2 -1
View File
@@ -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,
@@ -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('<TooltipContent>{t("diagram.theme")}</TooltipContent>');
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("<MemoEditorHeaderActions");
});
+195 -81
View File
@@ -70,20 +70,24 @@ import {
markdownToDoc,
MIND_MAP_CONNECTOR_NAME,
MIND_MAP_HORIZONTAL_GAP,
MIND_MAP_TOPIC_MARKUP,
MIND_MAP_VERTICAL_GAP,
mindMapBranchSides,
mindMapConnector,
mindMapEdgeTerminal,
mindMapEdgeVisual,
mindMapNodePresentation,
mindMapNodeRole,
mindMapNodeVisual,
mindMapRootRadius,
resolveMindMapNodeStyle,
parseDiagramDocument,
resolveDiagramStructure,
serializeDiagramDocument,
type ArchitectureResourceIcon,
type DiagramDocument,
type DiagramEdgeKind,
type DiagramNodeShape,
type DiagramStructure,
type DiagramTheme,
type MemoDetail,
type MemoEditSession,
@@ -120,6 +124,7 @@ import {
getDiagramLayoutViewport,
type DiagramLayoutViewport,
} from "@/lib/diagram-layout";
import { applyDiagramScrollerFitOptions, isUsableDiagramBounds } from "@/lib/diagram-scroller-fit";
import { resolveDiagramPalette, type DiagramAppearance } from "@/lib/diagram-theme";
import { isLocalMemoId } from "@/lib/local-mirror";
import { isBrowserOffline } from "@/lib/network-status";
@@ -575,6 +580,62 @@ const ARCHITECTURE_DROP_VIEWPORT_PADDING = 12;
const getDiagramScroller = (graph: Graph) => 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<string, unknown>,
) => {
const bounds = diagramNodeBounds(graph);
if (gridWidth && typeof gridWidth === "object") {
return original(applyDiagramScrollerFitOptions({ ...(gridWidth as Record<string, unknown>) }, 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<NodeData>()?.parentId,
})), node.id)
const mindNodes = graph.getNodes().map((item) => ({
id: item.id,
parentId: item.getData<NodeData>()?.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<NodeData>();
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<NodeData>()?.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<NodeData>();
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<DiagramTheme>(documentTheme);
const [structure, setStructure] = useState<DiagramStructure>(documentStructure);
const titleRef = useRef(title);
const tagsRef = useRef(tagsText);
const themeRef = useRef<DiagramTheme>(documentTheme);
const structureRef = useRef<DiagramStructure>(documentStructure);
const appearanceRef = useRef<DiagramAppearance>(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<NodeData>(), label });
refreshNodeLabel(cell, label);
refreshNodeLabel(cell, label, structureRef.current);
if (cell.getData<NodeData>()?.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)}
+31 -7
View File
@@ -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 = ({
<Tooltip><TooltipTrigger asChild><Button size="sm" variant="ghost" aria-label={t("diagram.fit")} onClick={onFit}><Scan className="h-4 w-4" /><span>{t("diagram.fit")}</span></Button></TooltipTrigger><TooltipContent>{t("diagram.fit")}</TooltipContent></Tooltip>
{onRead ? <Tooltip><TooltipTrigger asChild><Button size="sm" variant="ghost" onClick={onRead}><BookOpen className="h-4 w-4" />{t("diagram.readFlow")}</Button></TooltipTrigger><TooltipContent>{t("diagram.readFlowHint")}</TooltipContent></Tooltip> : null}
<MemoEditorToolbarDivider />
<Select value={theme} disabled={readOnly} onValueChange={(value) => onThemeChange(value as DiagramTheme)}>
<SelectTrigger className="h-8 w-[8.5rem] gap-2" aria-label={t("diagram.theme")}>
<SelectValue />
</SelectTrigger>
{onStructureChange ? (
<Select value={structure === "box" ? "box" : "map"} disabled={readOnly} onValueChange={(value) => onStructureChange(value as DiagramStructure)}>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className="h-8 w-[8.5rem] gap-2" aria-label={t("diagram.structure")}>
<SelectValue />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>{t("diagram.structure")}</TooltipContent>
</Tooltip>
<SelectContent>
<SelectItem value="map" textValue={t("diagram.structureMap")}><span className="flex items-center gap-2"><span className="h-3 w-3 border-b-2 border-slate-500" />{t("diagram.structureMap")}</span></SelectItem>
<SelectItem value="box" textValue={t("diagram.structureBox")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-sm border border-slate-400" />{t("diagram.structureBox")}</span></SelectItem>
</SelectContent>
</Select>
) : null}
<Select value={theme === "classic" ? "classic" : "brand"} disabled={readOnly} onValueChange={(value) => onThemeChange(value as DiagramTheme)}>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className="h-8 w-[8.5rem] gap-2" aria-label={t("diagram.theme")}>
<SelectValue />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>{t("diagram.theme")}</TooltipContent>
</Tooltip>
<SelectContent>
<SelectItem value="brand" textValue={t("diagram.themeBrand")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: resolveDiagramPalette("brand", appearance).topicFill }} />{t("diagram.themeBrand")}</span></SelectItem>
<SelectItem value="ocean" textValue={t("diagram.themeOcean")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: resolveDiagramPalette("ocean", appearance).topicFill }} />{t("diagram.themeOcean")}</span></SelectItem>
<SelectItem value="ink" textValue={t("diagram.themeInk")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: resolveDiagramPalette("ink", appearance).nodeFill }} />{t("diagram.themeInk")}</span></SelectItem>
<SelectItem value="classic" textValue={t("diagram.themeClassic")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: "conic-gradient(#16A06E, #3B82F6, #8B5CF6, #F59E0B, #EC4899, #16A06E)" }} />{t("diagram.themeClassic")}</span></SelectItem>
</SelectContent>
</Select>
<DropdownMenu>
@@ -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,
@@ -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);
});
});
+49
View File
@@ -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<string, unknown>,
nodeBounds: DiagramFitBounds | null | undefined,
): Record<string, unknown> => {
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;
};
+5
View File
@@ -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"));
});
});
+29 -1
View File
@@ -103,7 +103,35 @@ export const DIAGRAM_THEME_PALETTES: Record<DiagramTheme, Record<DiagramAppearan
gridStrong: "#29362F",
},
},
classic: {
light: {
topicFill: BRAND_GREEN,
topicText: "#FFFFFF",
nodeFill: "#F7FAF8",
nodeText: "#173B2E",
nodeStroke: "#D5E3DB",
topicStroke: "#12845B",
mindMapEdge: "#16A06E",
flowEdge: "#408A6D",
canvas: "#FBFCFB",
grid: "#E6EEE9",
gridStrong: "#CFDDD5",
},
dark: {
topicFill: BRAND_GREEN,
topicText: "#F4FFF9",
nodeFill: "#18211D",
nodeText: "#E8F2ED",
nodeStroke: "#3B5248",
topicStroke: "#58CDA4",
mindMapEdge: "#4DB58B",
flowEdge: "#72B99B",
canvas: "#101311",
grid: "#1D2722",
gridStrong: "#2B3A33",
},
},
};
export const resolveDiagramPalette = (theme: DiagramTheme, appearance: DiagramAppearance) =>
DIAGRAM_THEME_PALETTES[theme][appearance];
DIAGRAM_THEME_PALETTES[theme === "classic" ? "classic" : "brand"][appearance];
+4 -1
View File
@@ -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,
};
@@ -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);
+217 -37
View File
@@ -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<MindMapAppearance, MindMapBranchTint[]> = {
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<string>();
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;
+42 -14
View File
@@ -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<DiagramTheme, Record<DiagramAppearance, DiagramPalette>>
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<Record<DiagramNodeShape, string>> = {
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 } }
+14 -1
View File
@@ -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");
+12 -2
View File
@@ -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[],
};
+5 -3
View File
@@ -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",
+5 -3
View File
@@ -362,10 +362,12 @@ export const zhCN = {
autoLayout: "自动布局",
autoLayoutTooltip: "重新规整所有节点与连线",
toolbar: "图形工具栏",
theme: "图表主题",
theme: "配色",
themeBrand: "森绿",
themeOcean: "薄荷纸",
themeInk: "深林",
themeClassic: "彩枝",
structure: "结构",
structureMap: "脑图",
structureBox: "盒子",
export: "导出",
exportPng: "导出 PNG",
exportSvg: "导出 SVG",