feat: improve diagram reading and layout across clients

This commit is contained in:
tianma-if committed 2026-09-07 10:20:46 +08:00
1 parent 1d111e123a
commit 1b804b165d
29 files changed
+1459 -887

No files matched your search

+2 -2
View File
@@ -3,7 +3,7 @@ import {
DEFAULT_MEMO_TITLE,
docToText,
markdownToDoc,
parseDiagramDocument,
getDiagramSummary,
type MemoSummary,
} from "@edgeever/shared";
import { parseJsonArray } from "./entity-utils";
@@ -69,7 +69,7 @@ export const mapMemoSummary = (row: MemoSummaryRow): MemoSummary => ({
row.excerpt ||
createExcerpt(row.content_text ?? "") ||
createExcerpt(docToText(markdownToDoc(row.content_markdown ?? ""))),
diagramKind: parseDiagramDocument(row.content_markdown)?.kind ?? null,
...getDiagramSummary(row.content_markdown),
tags: parseJsonArray(row.tags_json),
isPinned: Boolean(row.is_pinned),
isArchived: Boolean(row.is_archived),
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+12 -7
View File
@@ -17,6 +17,7 @@ import { toCanvas } from "html-to-image";
import {
createNativeUnsupportedContentExtensions,
diagramDocumentToX6Cells,
attachDiagramReader,
diagramFallbackMarkdown,
docToMarkdown,
NativeAttachmentMetadata,
@@ -372,12 +373,18 @@ async function renderMermaidBlocks(root: HTMLElement, theme: "light" | "dark") {
}
}
let viewerDiagramReader: ReturnType<typeof attachDiagramReader> | null = null;
let viewerDiagramFrame: number | null = null;
let viewerDiagram: DiagramDocument | null = null;
let viewerDiagramGraph: Graph | null = null;
let viewerDiagramObserver: ResizeObserver | null = null;
let viewerDiagramContainer: HTMLElement | null = null;
function clearViewerDiagramGraph() {
if (viewerDiagramFrame !== null) cancelAnimationFrame(viewerDiagramFrame);
viewerDiagramFrame = null;
viewerDiagramReader?.dispose();
viewerDiagramReader = null;
viewerDiagramObserver?.disconnect();
viewerDiagramObserver = null;
viewerDiagramGraph?.dispose();
@@ -421,16 +428,14 @@ function renderViewerDiagram(root: HTMLElement, diagram: DiagramDocument, theme:
grid: false,
interacting: false,
panning: { enabled: true },
mousewheel: { enabled: true, minScale: 0.35, maxScale: 2 },
mousewheel: { enabled: true, minScale: 0.1, maxScale: 2.5 },
});
graph.addNodes(cells.nodes);
graph.addEdges(cells.edges);
const fit = () => {
graph.resize(measureWidth(), Math.max(1, container.clientHeight));
graph.zoomToFit({ maxScale: 1.05, padding: 28 });
graph.centerContent();
};
requestAnimationFrame(fit);
const reader = attachDiagramReader(graph, container, { ...diagram, nodes: diagram.nodes.map((node, index) => ({ ...node, width: cells.nodes[index].width, height: cells.nodes[index].height })) }, locale, theme === "dark");
viewerDiagramReader = reader;
const fit = () => reader.resize(measureWidth(), Math.max(1, container.clientHeight));
viewerDiagramFrame = requestAnimationFrame(fit);
const observer = new ResizeObserver(fit);
observer.observe(parent ?? container);
viewerDiagramGraph = graph;
@@ -289,7 +289,7 @@ final class TipTapContentSourceTests: XCTestCase {
}
let samples = [
envelope(#"{"schemaVersion":1,"kind":"mind-map","nodes":[{"id":"a","label":"核心主题","x":0,"y":0,"width":100,"height":40,"shape":"topic"},{"id":"b","label":"分支主题","x":160,"y":0,"width":100,"height":40,"shape":"topic","parentId":"a"}],"edges":[{"id":"e","source":"a","target":"b"}]}"#),
envelope(#"{"schemaVersion":1,"kind":"flowchart","nodes":[{"id":"a","label":"开始","x":0,"y":0,"width":100,"height":40,"shape":"terminator"},{"id":"b","label":"处理步骤","x":0,"y":100,"width":100,"height":40,"shape":"process"}],"edges":[{"id":"e","source":"a","target":"b"}]}"#),
envelope(#"{"schemaVersion":1,"kind":"flowchart","nodes":[{"id":"a","label":"开始","x":0,"y":0,"width":100,"height":40,"shape":"terminator"},{"id":"b","label":"Transformer 前向计算与因果注意力处理步骤","x":0,"y":1600,"width":100,"height":40,"shape":"process"}],"edges":[{"id":"e","source":"a","target":"b"}]}"#),
envelope(#"{"schemaVersion":2,"kind":"architecture","nodes":[{"id":"system","label":"应用系统","x":0,"y":0,"width":500,"height":300,"shape":"boundary"},{"id":"api","label":"API 服务","x":40,"y":40,"width":156,"height":64,"shape":"service","parentId":"system"},{"id":"db","label":"数据库","x":260,"y":40,"width":150,"height":72,"shape":"database","parentId":"system"}],"edges":[{"id":"query","source":"api","target":"db","label":"查询","kind":"data"}]}"#),
]
@@ -321,6 +321,21 @@ final class TipTapContentSourceTests: XCTestCase {
)
XCTAssertGreaterThan(graphWidth, 300, "X6 must occupy the viewer width instead of collapsing")
XCTAssertGreaterThan(nodeCount, 0, "X6 must materialize diagram nodes")
let controlsCount = try await evalInt(webView, "document.querySelectorAll('.edgeever-diagram-reader-controls').length")
XCTAssertEqual(controlsCount, 1, "reusing a viewer must not accumulate controls")
let hasReadingButton = try await evalBool(webView, "!!document.querySelector('button[aria-label=\"从起点阅读\"]')")
if hasReadingButton {
_ = try await eval(webView, "document.querySelector('button[aria-label=\"从起点阅读\"]').click()")
let readingScale = try await evalInt(webView, "parseInt(document.querySelector('button[aria-label=\"恢复 100%\"]').textContent)")
XCTAssertEqual(readingScale, 100)
_ = try await eval(webView, "document.querySelector('button[aria-label=\"放大\"]').click()")
let enlargedScale = try await evalInt(webView, "parseInt(document.querySelector('button[aria-label=\"恢复 100%\"]').textContent)")
XCTAssertEqual(enlargedScale, 125)
_ = try await eval(webView, "document.querySelector('button[aria-label=\"适应画布\"]').click()")
let overviewScale = try await evalInt(webView, "parseInt(document.querySelector('button[aria-label=\"恢复 100%\"]').textContent)")
XCTAssertLessThan(overviewScale, 80)
}
let leakedLegacyFallback = try await evalBool(webView, "document.body.innerText.includes('node list only')")
XCTAssertFalse(leakedLegacyFallback)
let leakedCodeAffordance = try await evalBool(webView, "document.body.innerText.includes('Copy code')")
@@ -35,6 +35,7 @@ import {
ImageGallery,
getResourceIdFromUrl,
diagramDocumentToX6Cells,
attachDiagramReader,
type AiAction,
type AiPromptParameterKind,
type AiPromptResultMode,
@@ -487,25 +488,23 @@ const ReadOnlyX6Diagram = ({
grid: false,
interacting: false,
panning: { enabled: true },
mousewheel: { enabled: true, minScale: 0.35, maxScale: 2 },
mousewheel: { enabled: true, minScale: 0.1, maxScale: 2.5 },
});
graph.addNodes(cells.nodes);
graph.addEdges(cells.edges);
const fit = () => {
graph.resize(measureWidth(), Math.max(1, container.clientHeight));
graph.zoomToFit({ maxScale: 1.05, padding: 28 });
graph.centerContent();
};
const reader = attachDiagramReader(graph, container, { ...diagram, nodes: diagram.nodes.map((node, index) => ({ ...node, width: cells.nodes[index].width, height: cells.nodes[index].height })) }, locale, theme === "dark");
const fit = () => reader.resize(measureWidth(), Math.max(1, container.clientHeight));
const frame = window.requestAnimationFrame(fit);
const observer = new ResizeObserver(fit);
observer.observe(parent ?? container);
return () => {
window.cancelAnimationFrame(frame);
observer.disconnect();
reader.dispose();
graph.dispose();
};
}, [diagram, theme]);
}, [diagram, theme, locale]);
const title = locale === "en-US"
? diagram.kind === "mind-map" ? "Mind map" : diagram.kind === "architecture" ? "Architecture diagram" : "Flowchart"
@@ -77,12 +77,13 @@ describe("diagram editor canvas surface", () => {
expect(source).not.toContain("graph.drawGrid");
});
test("uses restrained rounded edges and presents connected diagrams smaller at the left", () => {
test("uses restrained rounded edges and fits the complete diagram without clipping", () => {
expect(source).toContain('connector: { name: kind === "mind-map" ? "smooth" : "rounded"');
expect(source).toContain('router: "normal"');
expect(source).not.toContain('name: "manhattan"');
expect(source).toContain('name: "manhattan"');
expect(source).toContain("maxScale: policy.maxScale");
expect(source).toContain("policy.minScale ? { minScale: policy.minScale }");
expect(source).not.toContain("minScale: policy.minScale");
expect(source).toContain("graph.centerContent()");
expect(source).not.toContain("desiredLeft - contentLeft");
expect(source).toContain("getDiagramLayoutViewport(document.kind)");
expect(source).toContain("fitDiagramContent(graph, document, containerRef.current);");
});
@@ -94,16 +95,16 @@ describe("diagram editor canvas surface", () => {
expect(toolbarSource).not.toContain('<Button size="icon" variant="ghost" aria-label={t("diagram.autoLayout")}');
});
test("keeps viewport fitting internal instead of exposing a redundant toolbar action", () => {
expect(toolbarSource).not.toContain("onFit");
expect(toolbarSource).not.toContain('t("diagram.fit")');
expect(source).not.toContain("onFit={() =>");
test("exposes view recovery separately from document layout", () => {
expect(toolbarSource).toContain("onFit");
expect(toolbarSource).toContain('t("diagram.fit")');
expect(source).toContain("onFit={() =>");
expect(source).toContain("fitDiagramContent(graph, document, containerRef.current, 40, layout.viewport);");
});
test("delegates every diagram kind to one shared toolbar shell", () => {
expect(source).toContain("<DiagramToolbar");
expect(source).toContain("leading={!readOnly ? (");
expect(source).toContain("leading={!readOnly && !activeMindMapView?.focus ? (");
expect(toolbarSource).toContain('role="toolbar"');
expect(toolbarSource).toContain("{leading ? <>{leading}<ToolbarDivider /></> : null}");
expect(toolbarSource).toContain("{selectionEditor}");
+261 -56
View File
@@ -1,5 +1,7 @@
import { diagramEditorSnapshot } from "@/lib/diagram-editor-snapshot";
import { projectMindMapView } from "@/lib/mind-map-view";
import { useCallback, useEffect, useRef, useState, type DragEvent as ReactDragEvent, type PointerEvent as ReactPointerEvent } from "react";
import { Export, Graph, History, Keyboard, Selection, type Edge, type Node } from "@antv/x6";
import { Dom, Export, Graph, History, Keyboard, Selection, type Edge, type Node } from "@antv/x6";
import * as m from "motion/react-m";
import {
Activity,
@@ -98,6 +100,7 @@ import { copyTextToClipboard } from "@/lib/clipboard";
import {
compactArchitectureNodeSize,
compactFlowchartNodeSize,
flowchartNodePresentation,
compactMindMapNodeSize,
computeDiagramLayout,
computeDiagramLayoutResult,
@@ -494,6 +497,10 @@ const applyDiagramSurface = (
};
const prepareExportSvg = (background: string) => (svg: SVGSVGElement) => {
svg.querySelectorAll<SVGElement>(".x6-node, .x6-edge").forEach((element) => {
element.removeAttribute("display");
element.style.removeProperty("display");
});
svg.querySelectorAll(".x6-port").forEach((element) => element.remove());
const viewBox = svg.getAttribute("viewBox")?.split(/\s+/).map(Number);
if (!viewBox || viewBox.length !== 4 || viewBox.some((value) => !Number.isFinite(value))) return;
@@ -677,14 +684,47 @@ const architectureNodeVisuals = (
};
};
const diagramNodeSize = (
// Reuse X6's measured text wrapping so on-screen labels and SVG/PNG exports agree.
const diagramNodePresentation = (
node: DiagramDocument["nodes"][number],
kind: DiagramDocument["kind"],
) => kind === "mind-map"
? compactMindMapNodeSize(node.label, !node.parentId)
: kind === "architecture"
? compactArchitectureNodeSize(node.shape, node)
: compactFlowchartNodeSize(node.shape);
) => {
if (kind === "flowchart") return flowchartNodePresentation(node.shape, node.label);
const size = kind === "mind-map"
? compactMindMapNodeSize(node.label, !node.parentId)
: kind === "architecture"
? compactArchitectureNodeSize(node.shape, node)
: compactFlowchartNodeSize(node.shape);
if (node.shape === "boundary") return { ...size, text: node.label };
const fontSize = kind === "mind-map" ? 14 : 13;
const lineHeight = 18;
const text = Dom.breakText(node.label, { width: size.width - (kind === "architecture" ? 66 : 24), height: 10000 }, {
fontSize, 'font-size': fontSize, 'font-weight': kind === "architecture" || !node.parentId ? 650 : 500,
lineHeight,
});
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);
return { width, height };
};
const refreshNodeLabel = (node: Node, label: string) => {
const data = node.getData<NodeData>();
const shape = data?.shape ?? "process";
if (shape === "boundary") {
node.attr("label/text", label);
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 currentSize = node.getSize();
if (currentSize.width !== presentation.width || currentSize.height !== presentation.height) {
node.resize(presentation.width, presentation.height);
}
node.attr("label/text", presentation.text);
};
const flowPortGroup = (
position: FlowPort,
@@ -747,7 +787,8 @@ const nodeMetadata = (
body: visualAttrs.body,
label: {
...visualAttrs.label,
text: node.label,
text: diagramNodePresentation(node, kind).text,
lineHeight: 18,
...(architectureVisuals ? { refX: 54, refY: "50%", textAnchor: "start", textVerticalAnchor: "middle" } : {}),
},
...(architectureVisuals?.attrs ?? {}),
@@ -764,6 +805,15 @@ const nodeMetadata = (
};
};
const diagramEdgeLabel = (text: string, palette: ReturnType<typeof resolveDiagramPalette>, kind: DiagramDocument["kind"]) => ({
position: { distance: 0.5, offset: kind === "architecture" ? { x: 0, y: -16 } : 0 },
attrs: {
label: { text, fill: palette.nodeText, fontSize: 12, lineHeight: 16, textWrap: { width: 140, height: 512 } },
body: { ref: "label", refWidth: 1, refHeight: 1, refWidth2: 12, refHeight2: 8, refX: -6, refY: -4,
fill: palette.canvas, stroke: palette.nodeStroke, strokeWidth: 1, rx: 4, ry: 4 },
},
});
const edgeMetadata = (
edge: DiagramDocument["edges"][number],
kind: DiagramDocument["kind"],
@@ -781,7 +831,7 @@ const edgeMetadata = (
id: edge.id,
source: { cell: edge.source },
target: { cell: edge.target },
router: undefined,
router: kind === "flowchart" ? { name: "manhattan", args: { padding: 28, step: 10 } } : undefined,
connector: { name: kind === "mind-map" ? "smooth" : "rounded", args: { radius: 10 } },
data: { ...(edgeKind ? { kind: edgeKind } : {}), ...(edge.bidirectional ? { bidirectional: true } : {}) } satisfies EdgeData,
attrs: {
@@ -793,10 +843,7 @@ const edgeMetadata = (
targetMarker: kind === "mind-map" ? null : { name: "block", width: 8, height: 6 },
},
},
labels: edge.label ? [{ attrs: {
label: { text: edge.label, fill: palette.nodeText, fontSize: 12 },
body: { fill: palette.canvas, stroke: palette.nodeStroke, strokeWidth: 1, rx: 5, ry: 5 },
} }] : undefined,
labels: edge.label ? [diagramEdgeLabel(edge.label, palette, kind)] : undefined,
};
};
@@ -862,14 +909,6 @@ const removeGraphSelection = (graph: Graph) => {
return true;
};
const diagramEditorSnapshot = (title: string, document: DiagramDocument) => JSON.stringify({
title,
document: {
...document,
theme: document.theme ?? "brand",
nodes: document.nodes.map((node) => ({ ...node, ...diagramNodeSize(node, document.kind) })),
},
});
const fitDiagramContent = (
graph: Graph,
@@ -879,23 +918,46 @@ const fitDiagramContent = (
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,
...(policy.minScale ? { minScale: policy.minScale } : {}),
});
if (!container) return;
if (policy.anchor === "center") return;
const anchor = policy.anchor === "root"
? graph.getNodes().find((node) => !node.getData<NodeData>()?.parentId)
: graph.getNodes().reduce<Node | null>((leftmost, node) => (
!leftmost || node.getBBox().x < leftmost.getBBox().x ? node : leftmost
), null);
if (!anchor) return;
const contentLeft = graph.localToGraph(anchor.getBBox().topLeft).x;
const desiredLeft = Math.max(32, Math.min(72, container.clientWidth * 0.055));
const translation = graph.translate();
graph.translate(translation.tx + desiredLeft - contentLeft, translation.ty);
// 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 readFlowchart = (graph: Graph, document: DiagramDocument, container: HTMLElement | null) => {
if (!container || !document.nodes.length) return;
const incoming = new Set(document.edges.map((edge) => edge.target));
const start = document.nodes.find((node) => !incoming.has(node.id)) ?? document.nodes[0];
const cell = graph.getCellById(start.id);
if (!cell?.isNode()) return;
graph.zoomTo(1);
const bounds = cell.getBBox();
graph.translate(container.clientWidth / 2 - bounds.center.x, 48 - bounds.y);
};
const applyMindMapHierarchy = (graph: Graph, theme: DiagramTheme, appearance: DiagramAppearance) => {
const palette = resolveDiagramPalette(theme, appearance);
const roots = new Set(graph.getNodes().filter((node) => !node.getData<NodeData>()?.parentId).map((node) => node.id));
for (const node of graph.getNodes()) {
const parentId = node.getData<NodeData>()?.parentId;
const primary = Boolean(parentId && roots.has(parentId));
node.attr("label/fontWeight", !parentId || primary ? 650 : 500);
node.attr("body/strokeWidth", !parentId ? 2 : primary ? 1.5 : 1);
if (primary) node.attr("body/stroke", palette.topicStroke);
}
for (const edge of graph.getEdges()) {
edge.attr("line/strokeWidth", roots.has(edge.getSourceCellId()) ? 2.5 : 1.25);
}
};
const applyGraphPalette = (
@@ -913,7 +975,8 @@ const applyGraphPalette = (
const shape = data?.shape ?? "process";
const attrs = nodeAttrs(shape, theme, appearance, shape === "topic" && !data?.parentId);
node.attr("body", attrs.body);
node.attr("label", { ...attrs.label, text: data?.label ?? "" });
node.attr("label", attrs.label);
refreshNodeLabel(node, data?.label ?? "");
if (kind === "architecture" && shape !== "boundary") {
const architectureVisuals = architectureNodeVisuals(shape, node.getSize(), appearance, data?.resourceIcon);
for (const [selector, selectorAttrs] of Object.entries(architectureVisuals.attrs)) {
@@ -932,17 +995,58 @@ const applyGraphPalette = (
const edgeKind = edge.getData<EdgeData>()?.kind;
edge.attr("line/stroke", edgeKind === "data" ? "#7C3AED" : edgeKind === "async" ? "#EA580C" : kind === "mind-map" ? palette.mindMapEdge : palette.flowEdge);
if (edge.getLabels().length > 0) {
edge.attr("label/fill", palette.nodeText);
edge.attr("body/fill", palette.canvas);
edge.attr("body/stroke", palette.nodeStroke);
edge.setLabels(edge.getLabels().map((label) => diagramEdgeLabel(String(label.attrs?.label?.text ?? ""), palette, kind)));
}
}
if (kind === "mind-map") applyMindMapHierarchy(graph, theme, appearance);
applyDiagramSurface(graph, theme, appearance);
} finally {
if (historyEnabled) graph.enableHistory();
}
};
const MindMapFocusReader = ({ source, focusId, theme, appearance, graphHandle, onZoom, onSelect }: {
source: DiagramDocument;
focusId: string;
theme: DiagramTheme;
appearance: DiagramAppearance;
graphHandle: { current: Graph | null };
onZoom: (value: number) => void;
onSelect: (id: string, label: string) => void;
}) => {
const container = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const callbacks = useRef({ onZoom, onSelect });
callbacks.current = { onZoom, onSelect };
useEffect(() => {
if (!container.current) return;
const visible = projectMindMapView(source.nodes, new Set(), focusId).visible;
const readingDocument: DiagramDocument = {
...source,
nodes: source.nodes.filter((node) => visible.has(node.id)).map((node) => ({
...node, ...diagramNodeSize(node, "mind-map"), ...(node.id === focusId ? { parentId: undefined } : {}),
})),
edges: source.edges.filter((edge) => visible.has(edge.source) && visible.has(edge.target)),
};
const layout = computeDiagramLayoutResult(readingDocument);
const graph = new Graph({ container: container.current, autoResize: true, async: false, grid: false,
background: { color: resolveDiagramPalette(theme, appearance).canvas }, interacting: false,
panning: { enabled: true, eventTypes: ["leftMouseDown", "mouseWheel"] },
mousewheel: { enabled: true, modifiers: ["ctrl", "meta"], minScale: 0.1, maxScale: 2.5 },
});
graph.addNodes(readingDocument.nodes.map((node) => nodeMetadata({ ...node, ...layout.nodes[node.id] }, theme, "mind-map", appearance)));
graph.addEdges(readingDocument.edges.map((edge) => edgeMetadata(edge, "mind-map", theme, appearance)));
applyMindMapHierarchy(graph, theme, appearance);
graph.on("scale", () => callbacks.current.onZoom(Math.round(graph.scale().sx * 100)));
graph.on("node:click", ({ node }) => callbacks.current.onSelect(node.id, node.getData<NodeData>()?.label ?? ""));
graph.on("resize", () => fitDiagramContent(graph, readingDocument, container.current, 48));
graphHandle.current = graph;
fitDiagramContent(graph, readingDocument, container.current, 48);
return () => { graphHandle.current = null; graph.dispose(); };
}, [source, focusId, theme, appearance, graphHandle]);
return <div ref={container} className="absolute inset-0 z-10" role="region" aria-label={t("diagram.branchReader")} />;
};
export const DiagramEditorPane = ({
memo,
repository,
@@ -977,6 +1081,12 @@ export const DiagramEditorPane = ({
const themeRef = useRef<DiagramTheme>(documentTheme);
const appearanceRef = useRef<DiagramAppearance>(resolvedTheme);
const savedSnapshotRef = useRef(document ? diagramEditorSnapshot(memo.title ?? "", document) : "");
const [mindMapView, setMindMapView] = useState<{ memoId: string; collapsed: Set<string>; focus: string | null; snapshot?: DiagramDocument }>({ memoId: memo.id, collapsed: new Set(), focus: null });
const focusGraphRef = useRef<Graph | null>(null);
const viewOnlyRef = useRef(false);
const lastMindMapProjectionRef = useRef<unknown>(null);
const activeMindMapView = mindMapView.memoId === memo.id ? mindMapView : null;
const mindMapChildren = document?.kind === "mind-map" ? projectMindMapView(document.nodes, new Set(), null).children : new Map<string, string[]>();
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedNodeLabel, setSelectedNodeLabel] = useState("");
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
@@ -993,6 +1103,7 @@ export const DiagramEditorPane = ({
const [memoIdCopyNotice, setMemoIdCopyNotice] = useState<"copied" | "error" | null>(null);
const memoIdCopyTimerRef = useRef<number | null>(null);
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
const [zoomPercent, setZoomPercent] = useState(100);
const [historyState, setHistoryState] = useState({ undo: false, redo: false });
const [nodeEditor, setNodeEditor] = useState<NodeEditorState | null>(null);
const [flowQuickCreate, setFlowQuickCreate] = useState<FlowQuickCreateState | null>(null);
@@ -1037,7 +1148,7 @@ export const DiagramEditorPane = ({
if (label !== current.originalValue) {
graph.startBatch("edit-label");
cell.setData({ ...cell.getData<NodeData>(), label });
cell.attr("label/text", label);
refreshNodeLabel(cell, label);
graph.stopBatch("edit-label");
setSelectedNodeLabel(label);
}
@@ -1078,6 +1189,10 @@ export const DiagramEditorPane = ({
if (memoIdCopyTimerRef.current !== null) window.clearTimeout(memoIdCopyTimerRef.current);
}, []);
useEffect(() => {
setMindMapView({ memoId: memo.id, collapsed: new Set(), focus: null });
}, [memo.id, memo.contentHash]);
useEffect(() => {
memoRef.current = memo;
setTitle(memo.title ?? "");
@@ -1109,7 +1224,12 @@ export const DiagramEditorPane = ({
appearanceRef.current = resolvedTheme;
const graph = graphRef.current;
if (!graph || !document) return;
applyGraphPalette(graph, themeRef.current, document.kind, resolvedTheme);
viewOnlyRef.current = true;
try {
applyGraphPalette(graph, themeRef.current, document.kind, resolvedTheme);
} finally {
viewOnlyRef.current = false;
}
const currentEditor = nodeEditorRef.current;
if (!currentEditor) return;
const node = graph.getCellById(currentEditor.nodeId);
@@ -1148,7 +1268,7 @@ export const DiagramEditorPane = ({
allowMulti: false,
highlight: isConnectableDiagram(document.kind),
snap: { radius: 24 },
router: "normal",
router: document.kind === "flowchart" ? { name: "manhattan", args: { padding: 28, step: 10 } } : "normal",
connector: document.kind === "mind-map" ? "smooth" : "rounded",
validateConnection: ({ sourceCell, targetCell, sourcePort, targetPort }) => {
if (!isConnectableDiagram(document.kind) || !sourceCell || !sourcePort) return false;
@@ -1199,11 +1319,22 @@ export const DiagramEditorPane = ({
}
}
graph.addEdges(document.edges.map((edge) => edgeMetadata(edge, document.kind, documentTheme, appearance)));
let viewportWidth = containerRef.current?.clientWidth ?? 0;
let viewportHeight = containerRef.current?.clientHeight ?? 0;
graph.on("resize", ({ width, height }) => {
const translation = graph.translate();
graph.translate(translation.tx + (width - viewportWidth) / 2, translation.ty + (height - viewportHeight) / 2);
viewportWidth = width;
viewportHeight = height;
});
graph.on("scale", () => setZoomPercent(Math.round(graph.scale().sx * 100)));
graph.cleanHistory();
fitDiagramContent(graph, document, containerRef.current);
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 hasChanges = savedSnapshotRef.current !== diagramEditorSnapshot(titleRef.current, currentDocument);
@@ -1503,6 +1634,7 @@ export const DiagramEditorPane = ({
graph.bindKey("1", (event) => {
event.preventDefault();
graph.zoomTo(1);
graph.centerContent();
});
graph.bindKey("esc", (event) => {
if (!flowQuickCreateRef.current) return;
@@ -1553,6 +1685,38 @@ export const DiagramEditorPane = ({
};
}, [beginNodeEdit, dismissFlowQuickCreate, memo.contentHash, memo.id, readOnly]);
useEffect(() => {
const graph = graphRef.current;
if (!graph || document?.kind !== "mind-map") return;
const view = mindMapView.memoId === memo.id ? mindMapView : null;
if (view?.focus) graph.disableKeyboard(); else if (!readOnly) graph.enableKeyboard();
const nodes = graph.getNodes();
const projection = projectMindMapView(nodes.map((node) => ({ id: node.id, parentId: node.getData<NodeData>()?.parentId })), view?.collapsed ?? new Set(), view?.focus ?? null);
const shouldFit = lastMindMapProjectionRef.current !== mindMapView;
lastMindMapProjectionRef.current = mindMapView;
const historyEnabled = graph.isHistoryEnabled();
viewOnlyRef.current = true;
if (historyEnabled) graph.disableHistory();
try {
for (const cell of graph.getSelectedCells()) {
const visible = cell.isNode() ? projection.visible.has(cell.id) : cell.isEdge()
&& projection.visible.has(cell.getSourceCellId()) && projection.visible.has(cell.getTargetCellId());
if (!visible) graph.unselect(cell);
}
for (const node of nodes) node.setVisible(projection.visible.has(node.id));
for (const edge of graph.getEdges()) edge.setVisible(projection.visible.has(edge.getSourceCellId()) && projection.visible.has(edge.getTargetCellId()));
applyMindMapHierarchy(graph, themeRef.current, appearanceRef.current);
const bounds = graph.getCellsBBox(nodes.filter((node) => projection.visible.has(node.id)));
if (bounds && shouldFit) graph.zoomToRect(bounds, { padding: 48, maxScale: 1 });
} finally {
if (historyEnabled) graph.enableHistory();
viewOnlyRef.current = false;
}
setSelectedNodeId((current) => current && projection.visible.has(current) ? current : null);
setSelectedEdgeId((current) => current && graph.getCellById(current)?.isVisible() ? current : null);
setHasSelection(graph.getSelectedCells().length > 0);
}, [memo.id, memo.contentHash, mindMapView, dirtyVersion]);
useEffect(() => {
if (!dirty) return;
const warn = (event: BeforeUnloadEvent) => event.preventDefault();
@@ -1762,7 +1926,7 @@ export const DiagramEditorPane = ({
const node = selectedNodeId ? graphRef.current?.getCellById(selectedNodeId) : null;
if (!node?.isNode() || readOnly) return;
node.setData({ ...node.getData<NodeData>(), label });
node.attr("label/text", label);
refreshNodeLabel(node, label);
};
const updateSelectedEdgeLabel = (label: string) => {
@@ -1774,10 +1938,7 @@ export const DiagramEditorPane = ({
return;
}
const palette = resolveDiagramPalette(themeRef.current, appearanceRef.current);
edge.setLabels([{ attrs: {
label: { text: label, fill: palette.nodeText, fontSize: 12 },
body: { fill: palette.canvas, stroke: palette.nodeStroke, strokeWidth: 1, rx: 5, ry: 5 },
} }]);
edge.setLabels([diagramEdgeLabel(label, palette, document?.kind ?? "flowchart")]);
};
const createConnectedFlowNode = (shape: DiagramNodeShape) => {
@@ -1935,10 +2096,11 @@ export const DiagramEditorPane = ({
try {
const palette = resolveDiagramPalette(themeRef.current, appearanceRef.current);
const beforeSerialize = prepareExportSvg(palette.canvas);
const viewBox = graph.getCellsBBox(graph.getCells()) ?? undefined;
if (format === "png") {
graph.exportPNG(fileName, { backgroundColor: palette.canvas, padding: 32, ratio: 2, copyStyles: false, beforeSerialize });
graph.exportPNG(fileName, { backgroundColor: palette.canvas, padding: 32, ratio: 2, copyStyles: false, beforeSerialize, viewBox });
} else {
graph.exportSVG(fileName, { preserveDimensions: true, copyStyles: false, beforeSerialize });
graph.exportSVG(fileName, { preserveDimensions: true, copyStyles: false, beforeSerialize, viewBox });
}
} catch {
setSaveError(t("diagram.exportError"));
@@ -1950,6 +2112,10 @@ export const DiagramEditorPane = ({
const currentMemo = memoRef.current;
const editSession = editSessionRef.current;
if (!graph || !document || !editSession || readOnly || saving) return;
if (savedSnapshotRef.current === diagramEditorSnapshot(titleRef.current, graphToDocument(graph, document.kind, themeRef.current))) {
setDirty(false);
return;
}
setSaving(true);
setSaveError(null);
setSaveFailed(false);
@@ -2025,7 +2191,7 @@ export const DiagramEditorPane = ({
if (!document) return null;
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
const currentMarkdown = historyOpen && dirty
? serializeDiagramDocument(graphRef.current ? graphToDocument(graphRef.current, document.kind, themeRef.current) : document)
: memo.contentMarkdown;
const saveStatus = saveError ? "error" : saving ? "saving" : dirty ? "unsaved" : "saved";
@@ -2237,7 +2403,7 @@ export const DiagramEditorPane = ({
canRedo={historyState.redo}
canUndo={historyState.undo}
hasSelection={hasSelection}
leading={!readOnly ? (
leading={!readOnly && !activeMindMapView?.focus ? (
document.kind === "mind-map" ? (
<DiagramInsertMenu
items={[
@@ -2266,18 +2432,28 @@ export const DiagramEditorPane = ({
onRedo={() => runHistoryAction("redo")}
onThemeChange={applyTheme}
onUndo={() => runHistoryAction("undo")}
onZoomIn={() => graphRef.current?.zoom(0.1)}
onZoomOut={() => graphRef.current?.zoom(-0.1)}
readOnly={readOnly}
zoomPercent={zoomPercent}
onRead={document.kind === "flowchart" ? () => { if (graphRef.current) readFlowchart(graphRef.current, document, containerRef.current); } : undefined}
onFit={() => { const graph = focusGraphRef.current ?? graphRef.current; if (graph) fitDiagramContent(graph, document, containerRef.current); }}
onResetZoom={() => {
const graph = focusGraphRef.current ?? 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);
}}
onZoomIn={() => (focusGraphRef.current ?? graphRef.current)?.zoom(0.1)}
onZoomOut={() => (focusGraphRef.current ?? graphRef.current)?.zoom(-0.1)}
readOnly={readOnly || Boolean(activeMindMapView?.focus)}
selectionEditor={(
<>
{selectedNodeId && !readOnly && (
{selectedNodeId && !readOnly && !activeMindMapView?.focus && (
<div className="ml-auto flex min-w-[220px] flex-1 items-center gap-2 sm:max-w-sm">
<span className="shrink-0 text-xs font-medium text-slate-500">{t("diagram.nodeText")}</span>
<Input value={selectedNodeLabel} maxLength={120} onChange={(event) => updateSelectedLabel(event.target.value)} />
</div>
)}
{selectedEdgeId && !readOnly && (
{selectedEdgeId && !readOnly && !activeMindMapView?.focus && (
<div className="ml-auto flex min-w-[220px] flex-1 items-center gap-2 sm:max-w-sm">
<span className="shrink-0 text-xs font-medium text-slate-500">{t("diagram.edgeText")}</span>
<Input value={selectedEdgeLabel} maxLength={80} onChange={(event) => updateSelectedEdgeLabel(event.target.value)} />
@@ -2287,15 +2463,44 @@ export const DiagramEditorPane = ({
)}
theme={theme}
/>
{document.kind === "mind-map" ? (
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-500" role="toolbar" aria-label={t("diagram.branchView")}>
<span>{t("diagram.branchView")}</span>
<Button size="sm" variant="ghost" onClick={() => setMindMapView({ memoId: memo.id, focus: null, collapsed: new Set(document.nodes.filter((node) => node.parentId && document.nodes.some((parent) => parent.id === node.parentId && !parent.parentId)).map((node) => node.id)) })}>{t("diagram.branchOverview")}</Button>
<Button size="sm" variant="ghost" disabled={Boolean(activeMindMapView?.focus) || !selectedNodeId || !mindMapChildren.has(selectedNodeId)} onClick={() => {
if (!selectedNodeId) return;
const collapsed = new Set(activeMindMapView?.collapsed);
if (collapsed.has(selectedNodeId)) collapsed.delete(selectedNodeId); else collapsed.add(selectedNodeId);
setMindMapView({ memoId: memo.id, collapsed, focus: activeMindMapView?.focus ?? null });
}}>{selectedNodeId && activeMindMapView?.collapsed.has(selectedNodeId) ? t("diagram.expandBranch") : t("diagram.collapseBranch")}</Button>
<Button size="sm" variant="ghost" disabled={!selectedNodeId} onClick={() => {
if (selectedNodeId) setMindMapView({ memoId: memo.id, collapsed: new Set(), focus: selectedNodeId, snapshot: graphRef.current ? graphToDocument(graphRef.current, "mind-map", themeRef.current) : document });
}}>{t("diagram.focusBranch")}</Button>
<Button size="sm" variant="ghost" disabled={!activeMindMapView?.focus && !activeMindMapView?.collapsed.size} onClick={() => setMindMapView({ memoId: memo.id, collapsed: new Set(), focus: null })}>{t("diagram.showFullMap")}</Button>
<span className="ml-auto" role="status">{activeMindMapView?.focus ? t("diagram.focusedBranch", { label: document.nodes.find((node) => node.id === activeMindMapView.focus)?.label ?? "" }) : activeMindMapView?.collapsed.size ? t("diagram.collapsedBranches", { count: activeMindMapView.collapsed.size }) : t("diagram.selectBranchHint")}</span>
</div>
) : null}
<div className="relative min-h-0 flex-1">
{activeMindMapView?.focus && activeMindMapView.snapshot ? <MindMapFocusReader
source={activeMindMapView.snapshot} focusId={activeMindMapView.focus} theme={theme} appearance={resolvedTheme}
graphHandle={focusGraphRef} onZoom={setZoomPercent}
onSelect={(id, label) => {
const graph = graphRef.current;
const node = graph?.getCellById(id);
if (graph && node) { graph.cleanSelection(); graph.select(node); }
setSelectedNodeId(id); setSelectedNodeLabel(label); setSelectedEdgeId(null); setHasSelection(Boolean(node));
}}
/> : null}
<div
ref={containerRef}
aria-hidden={activeMindMapView?.focus ? true : undefined}
className={cn("edgeever-diagram-canvas absolute inset-0 touch-none outline-none", pendingArchitectureItem && "cursor-crosshair")}
data-architecture-placement={pendingArchitectureItem ? "active" : undefined}
data-diagram-appearance={resolvedTheme}
data-diagram-kind={document.kind}
data-diagram-theme={theme}
tabIndex={0}
tabIndex={activeMindMapView?.focus ? -1 : 0}
aria-label={t("diagram.canvas", { type: kindLabel })}
onDragOver={handleArchitectureDragOver}
onDrop={handleArchitectureDrop}
@@ -1,10 +1,12 @@
import type { PointerEventHandler, ReactNode } from "react";
import {
Boxes,
BookOpen,
ChevronDown,
Download,
FileCode2,
FileImage,
Scan,
Redo2,
Trash2,
Undo2,
@@ -37,6 +39,10 @@ type DiagramToolbarProps = {
onRedo: () => void;
onThemeChange: (theme: DiagramTheme) => void;
onUndo: () => void;
onRead?: () => void;
onFit: () => void;
onResetZoom: () => void;
zoomPercent: number;
onZoomIn: () => void;
onZoomOut: () => void;
readOnly: boolean;
@@ -75,6 +81,10 @@ export const DiagramToolbar = ({
onRedo,
onThemeChange,
onUndo,
onRead,
onFit,
onResetZoom,
zoomPercent,
onZoomIn,
onZoomOut,
readOnly,
@@ -101,7 +111,11 @@ export const DiagramToolbar = ({
)}
<ToolbarDivider />
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.zoomOut")} onClick={onZoomOut}><ZoomOut className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.zoomOut")}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild><Button size="sm" variant="ghost" className="w-16 tabular-nums" aria-label={t("diagram.resetZoom")} onClick={onResetZoom}>{zoomPercent}%</Button></TooltipTrigger><TooltipContent>{t("diagram.resetZoom")}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.zoomIn")} onClick={onZoomIn}><ZoomIn className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.zoomIn")}</TooltipContent></Tooltip>
<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}
<ToolbarDivider />
<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 />
+23 -20
View File
@@ -1,8 +1,9 @@
import { useRef, useState, useEffect, type DragEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import * as m from "motion/react-m";
import { Star, Check, MoreHorizontal, RotateCcw, Trash2 } from "lucide-react";
import { GitBranch, Network, Workflow, Star, Check, MoreHorizontal, RotateCcw, Trash2 } from "lucide-react";
import { getMemoListTimestamp, type MemoSummary } from "@edgeever/shared";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { selectionSettleMotion } from "@/lib/motion";
import type { MemoListDensity, MemoSortMode } from "@/lib/app-helpers";
@@ -86,6 +87,7 @@ export const MemoCard = ({
const diagramLabel = memo.diagramKind
? t(`diagram.${memo.diagramKind === "mind-map" ? "mindMap" : memo.diagramKind}`)
: null;
const DiagramIcon = memo.diagramKind === "mind-map" ? GitBranch : memo.diagramKind === "architecture" ? Network : Workflow;
const listTimestamp = getMemoListTimestamp(memo, sortMode);
const listTimestampLabel = formatMemoPreviewDate(
listTimestamp.value,
@@ -341,10 +343,9 @@ export const MemoCard = ({
) : null}
<div className={cn("flex min-h-[132px] items-center", listDensity === "compact" && "min-h-[84px] lg:min-h-[76px]")}>
{showSelectionControl && (
<button
<Tooltip><TooltipTrigger asChild><button
className="ml-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-full transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400/70 focus-visible:ring-offset-2 lg:ml-3 lg:h-6 lg:w-6"
type="button"
title={selectionControlLabel}
aria-label={selectionControlLabel}
aria-pressed={checked}
onClick={(event) => {
@@ -363,9 +364,9 @@ export const MemoCard = ({
>
<Check className="h-3.5 w-3.5 stroke-[3] lg:h-2.5 lg:w-2.5" />
</span>
</button>
</button></TooltipTrigger><TooltipContent>{selectionControlLabel}</TooltipContent></Tooltip>
)}
<button
<Tooltip><TooltipTrigger asChild><button
className={cn(
"min-w-0 flex-1 px-4 py-3.5 text-left touch-pan-y focus-visible:bg-slate-50 focus-visible:shadow-[inset_3px_0_0_rgb(148,163,184)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-slate-400/60 [-webkit-touch-callout:none] lg:py-3.5 transition-all duration-200",
listDensity === "compact" && "py-2.5",
@@ -385,17 +386,22 @@ export const MemoCard = ({
onClick={handleClick}
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
title={t("memoCard.interactionHint")}
>
<div className={cn("mb-1.5 flex min-w-0 items-center gap-1.5 text-[15px] font-semibold tracking-[-0.012em] leading-snug text-slate-950", listDensity === "compact" && "mb-0.5 text-[14px]")}>
{memo.isPinned && <Star className="h-4 w-4 shrink-0 fill-amber-400 text-amber-500" />}
<span className="min-w-0 truncate">{memoTitle}</span>
</div>
{diagramLabel ? (
<div className={cn("flex min-h-10 items-start", listDensity === "compact" && "min-h-0")}>
<span className="inline-flex rounded-[3px] border border-slate-200/80 bg-slate-50/80 px-1.5 py-0 text-xs font-normal leading-[15px] text-slate-500 dark:border-slate-700/70 dark:bg-slate-800/40 dark:text-slate-400">
{diagramLabel}
</span>
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1 rounded border border-slate-200 px-1.5 py-0.5">
<DiagramIcon className="h-3 w-3" aria-hidden="true" />{diagramLabel}
</span>
{memo.diagramPreview ? <span>{t("diagram.listCounts", { nodes: memo.diagramPreview.nodeCount, edges: memo.diagramPreview.edgeCount })}</span> : null}
</div>
{listDensity !== "compact" && memo.diagramPreview?.labels.length ? (
<div className="line-clamp-2 text-[13px] leading-relaxed text-slate-600 dark:text-slate-400">{memo.diagramPreview.labels.join(" · ")}</div>
) : null}
</div>
) : (
<div
@@ -422,7 +428,7 @@ export const MemoCard = ({
</span>
))}
</div>
</button>
</button></TooltipTrigger><TooltipContent>{t("memoCard.interactionHint")}</TooltipContent></Tooltip>
{!selectionMode && (
<div
className={cn(
@@ -431,10 +437,9 @@ export const MemoCard = ({
listDensity === "compact" && "lg:mt-3"
)}
>
<button
<Tooltip><TooltipTrigger asChild><button
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400/70 focus-visible:ring-offset-2"
type="button"
title={t("memoCard.moreActions")}
aria-label={t("memoCard.moreActions")}
aria-haspopup="menu"
data-memo-actions-trigger
@@ -444,12 +449,11 @@ export const MemoCard = ({
}}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</button></TooltipTrigger><TooltipContent>{t("memoCard.moreActions")}</TooltipContent></Tooltip>
{isTrashView && (
<button
<Tooltip><TooltipTrigger asChild><button
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400/70 focus-visible:ring-offset-2"
type="button"
title={t("memoCard.restoreMemo")}
aria-label={t("memoCard.restoreMemo")}
onClick={(event) => {
event.stopPropagation();
@@ -457,12 +461,11 @@ export const MemoCard = ({
}}
>
<RotateCcw className="h-4 w-4" />
</button>
</button></TooltipTrigger><TooltipContent>{t("memoCard.restoreMemo")}</TooltipContent></Tooltip>
)}
<button
<Tooltip><TooltipTrigger asChild><button
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-400 transition hover:bg-rose-50 hover:text-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/70 focus-visible:ring-offset-2"
type="button"
title={isTrashView ? t("memoCard.permanentDelete") : t("memoCard.deleteMemo")}
aria-label={isTrashView ? t("memoCard.permanentDelete") : t("memoCard.deleteMemo")}
onClick={(event) => {
event.stopPropagation();
@@ -470,7 +473,7 @@ export const MemoCard = ({
}}
>
<Trash2 className="h-4 w-4" />
</button>
</button></TooltipTrigger><TooltipContent>{isTrashView ? t("memoCard.permanentDelete") : t("memoCard.deleteMemo")}</TooltipContent></Tooltip>
</div>
)}
</div>
+2 -2
View File
@@ -44,7 +44,7 @@ import {
} from "@/lib/mobile-editor";
import { cn } from "@/lib/utils";
import { isBrowserOffline, isBrowserOnline } from "@/lib/network-status";
import { createDefaultDiagramDocument, createExcerpt, diagramFallbackMarkdown, docToText, getNotebookDescendantIds, markdownToDoc, parseDiagramDocument, resolveMemoContentDoc, serializeDiagramDocument, type DiagramKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
import { createDefaultDiagramDocument, getDiagramSummary, createExcerpt, diagramFallbackMarkdown, docToText, getNotebookDescendantIds, markdownToDoc, parseDiagramDocument, resolveMemoContentDoc, serializeDiagramDocument, type DiagramKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
import { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui";
import type {
Pane,
@@ -198,7 +198,7 @@ const memoToSummary = (memo: MemoDetail): MemoSummary => ({
notebookId: memo.notebookId,
title: memo.title,
excerpt: memo.excerpt || createExcerpt(memo.contentText || docToText(resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown))),
diagramKind: parseDiagramDocument(memo.contentMarkdown)?.kind ?? null,
...getDiagramSummary(memo.contentMarkdown),
tags: memo.tags,
isPinned: memo.isPinned,
isArchived: memo.isArchived,
@@ -0,0 +1,34 @@
import { expect, test } from "bun:test";
import { diagramEditorSnapshot } from "./diagram-editor-snapshot";
const document = {
schemaVersion: 2, kind: "architecture",
nodes: [
{ id: "group", label: "Group", shape: "boundary", x: 0, y: 0, width: 600, height: 300 },
{ id: "node", label: "Long node label", shape: "service", parentId: "group", x: 40, y: 50, width: 220, height: 64 },
],
edges: [],
};
const snapshot = (value) => diagramEditorSnapshot("Title", value);
test("font wrapping and compact rendering do not create a content edit", () => {
const rendered = structuredClone(document);
rendered.theme = "brand";
rendered.nodes[1].width = 156;
rendered.nodes[1].height = 88;
expect(snapshot(rendered)).toBe(snapshot(document));
});
test("authored geometry, text, connections and theme remain editable", () => {
for (const change of [
(d) => { d.nodes[0].width += 10; },
(d) => { d.nodes[1].x += 10; },
(d) => { d.nodes[1].label = "Edited"; },
(d) => { d.edges.push({ id: "edge", source: "group", target: "node" }); },
(d) => { d.theme = "neutral"; },
]) {
const edited = structuredClone(document);
change(edited);
expect(snapshot(edited)).not.toBe(snapshot(document));
}
});
@@ -0,0 +1,21 @@
import type { DiagramDocument } from "@edgeever/shared";
// Ordinary node dimensions are computed by the renderer, including font metrics.
// Only boundaries have authored dimensions. Display changes must not create edits.
export const diagramEditorSnapshot = (title: string, document: DiagramDocument) => JSON.stringify({
title,
document: {
schemaVersion: document.schemaVersion,
kind: document.kind,
theme: document.theme ?? "brand",
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,
...(node.shape === "boundary" ? { width: node.width, height: node.height } : {}),
})),
edges: document.edges.map((edge) => ({
id: edge.id, source: edge.source, target: edge.target,
label: edge.label, kind: edge.kind, bidirectional: edge.bidirectional,
})),
},
});
+1
View File
@@ -2,6 +2,7 @@ export {
compileDiagramIr,
compactArchitectureNodeSize,
compactFlowchartNodeSize,
flowchartNodePresentation,
compactMindMapNodeSize,
computeDiagramLayout,
computeDiagramLayoutResult,
+29
View File
@@ -0,0 +1,29 @@
import { expect, test } from 'bun:test';
import { createDefaultDiagramDocument, getDiagramSummary, serializeDiagramDocument } from '../../../../packages/shared/src/diagram.ts';
test('list preview uses primary topics, keeps counts and does not change the document', () => {
const diagram = createDefaultDiagramDocument('mind-map');
const before = JSON.stringify(diagram);
const result = getDiagramSummary(serializeDiagramDocument(diagram));
expect(result.diagramKind).toBe('mind-map');
expect(result.diagramPreview.nodeCount).toBe(diagram.nodes.length);
expect(result.diagramPreview.edgeCount).toBe(diagram.edges.length);
const roots = new Set(diagram.nodes.filter(n => !n.parentId).map(n => n.id));
expect(result.diagramPreview.labels).toEqual([...new Set(diagram.nodes.filter(n => roots.has(n.parentId)).map(n => n.label))].slice(0, 4));
expect(JSON.stringify(diagram)).toBe(before);
});
test('ordinary or malformed notes safely omit optional diagram metadata', () => {
expect(getDiagramSummary('ordinary text')).toEqual({ diagramKind: null });
expect(getDiagramSummary(null)).toEqual({ diagramKind: null });
expect(getDiagramSummary('<!-- edgeever-diagram-v1:broken -->')).toEqual({ diagramKind: null });
});
test('preview payload is bounded and prioritizes architecture boundaries', () => {
const diagram = createDefaultDiagramDocument('architecture');
for (const node of diagram.nodes) node.label = '示例主题'.repeat(100);
const preview = getDiagramSummary(serializeDiagramDocument(diagram)).diagramPreview;
expect(preview.labels.length).toBeLessThanOrEqual(4);
expect(preview.labels.every(label => Array.from(label).length <= 48)).toBe(true);
expect(preview.nodeCount).toBe(diagram.nodes.length);
});
@@ -0,0 +1,23 @@
import { expect, test } from 'bun:test';
import { flowchartNodePresentation, compileDiagramIr } from './diagram-layout';
test('long labels fit bounded nodes while keeping every character', () => {
for (const shape of ['process', 'decision', 'terminator']) {
const label = 'Transformer 前向计算\n因果注意力+前馈网络以及更长的说明文字';
const presentation = flowchartNodePresentation(shape, label);
expect(presentation.text.replaceAll('\n', '')).toBe(label.replaceAll('\n', ''));
expect(presentation.width).toBeLessThanOrEqual(240);
expect(presentation.height).toBeGreaterThanOrEqual(presentation.text.split('\n').length * 18 + 24);
}
});
test('MCP compilation reserves label geometry before arranging the graph', () => {
const label = '这是非常长的流程步骤'.repeat(12);
const document = compileDiagramIr({ kind: 'flowchart', nodes: [
{ id: 'a', type: 'process', label }, { id: 'b', type: 'end', label: '结束' },
], edges: [{ source: 'a', target: 'b' }] });
expect(document.nodes[0].label).toBe(label);
expect(document.nodes[0].height).toBe(flowchartNodePresentation('process', label).height);
expect(document.nodes[1].y).toBeGreaterThan(document.nodes[0].y + document.nodes[0].height);
expect(document.nodes[0]).not.toHaveProperty('text');
});
+2 -2
View File
@@ -6,7 +6,7 @@ import {
hasSyncStateReset,
markdownToDoc,
mergeMemoDocs,
parseDiagramDocument,
getDiagramSummary,
resolveMemoContentDoc,
resolveMergedMemoTitle,
type MemoDetail,
@@ -74,7 +74,7 @@ const toSummary = (memo: MemoDetail): MemoSummary => ({
notebookId: memo.notebookId,
title: memo.title,
excerpt: memo.excerpt,
diagramKind: parseDiagramDocument(memo.contentMarkdown)?.kind ?? null,
...getDiagramSummary(memo.contentMarkdown),
tags: memo.tags,
isPinned: memo.isPinned,
isArchived: memo.isArchived,
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, test } from 'bun:test';
import { projectMindMapView } from './mind-map-view.ts';
const nodes = [{ id: 'root' }, { id: 'a', parentId: 'root' }, { id: 'b', parentId: 'root' }, { id: 'leaf', parentId: 'a' }, { id: 'detail', parentId: 'leaf' }];
describe('mind-map reading projection', () => {
test('collapsing a branch keeps its heading and other branches; expanding restores every node', () => {
const original = JSON.stringify(nodes);
expect([...projectMindMapView(nodes, new Set(['a']), null).visible]).toEqual(['root', 'a', 'b']);
expect([...projectMindMapView(nodes, new Set(), null).visible]).toEqual(nodes.map(n => n.id));
expect(JSON.stringify(nodes)).toBe(original);
});
test('focus shows the selected subtree even when an ancestor was collapsed', () => {
expect([...projectMindMapView(nodes, new Set(['root']), 'a').visible]).toEqual(['a', 'leaf', 'detail']);
expect([...projectMindMapView(nodes, new Set(['a']), 'a').visible]).toEqual(['a']);
});
test('nested collapsed branches retain their state after opening their parent', () => {
expect([...projectMindMapView(nodes, new Set(['leaf']), null).visible]).toEqual(['root', 'a', 'b', 'leaf']);
});
test('stale focus, orphaned nodes and cyclic parents remain safe', () => {
expect(projectMindMapView(nodes, new Set(['missing']), 'missing').visible.size).toBe(5);
const malformed = [{ id: 'orphan', parentId: 'missing' }, { id: 'x', parentId: 'y' }, { id: 'y', parentId: 'x' }];
expect(projectMindMapView(malformed, new Set(), null).visible.size).toBe(3);
});
});
+28
View File
@@ -0,0 +1,28 @@
/** A view projection only: never removes or rewrites nodes in a diagram document. */
export type MindMapViewNode = { id: string; parentId?: string };
export function projectMindMapView(nodes: MindMapViewNode[], collapsed: ReadonlySet<string>, focusId: string | null) {
const byId = new Map(nodes.map((node) => [node.id, node]));
const focus = focusId && byId.has(focusId) ? focusId : null;
const visible = new Set<string>();
const children = new Map<string, string[]>();
for (const node of nodes) {
if (node.parentId && byId.has(node.parentId)) {
const siblings = children.get(node.parentId) ?? [];
siblings.push(node.id);
children.set(node.parentId, siblings);
}
let current: MindMapViewNode | undefined = node;
let belongsToFocus = !focus;
let hidden = false;
const visited = new Set<string>();
while (current && !visited.has(current.id)) {
visited.add(current.id);
if (current.id === focus) { belongsToFocus = true; break; }
current = current.parentId ? byId.get(current.parentId) : undefined;
if (current && collapsed.has(current.id)) hidden = true;
}
if (belongsToFocus && !hidden) visible.add(node.id);
}
return { visible, children };
}
+5 -15
View File
@@ -1,3 +1,5 @@
import { visualTextUnits, compactFlowchartNodeSize, flowchartNodePresentation } from "./diagram-node-presentation";
export { compactFlowchartNodeSize, flowchartNodePresentation } from "./diagram-node-presentation";
import { graphlib, layout as runDagreLayout } from "@dagrejs/dagre";
import {
ARCHITECTURE_DIAGRAM_SCHEMA_VERSION,
@@ -83,24 +85,11 @@ const ARCHITECTURE_LAYOUT_ROW_WIDTH = 1480;
const ARCHITECTURE_GROUP_HORIZONTAL_GAP = 72;
const ARCHITECTURE_GROUP_VERTICAL_GAP = 88;
const visualTextUnits = (label: string) => Array.from(label).reduce(
(total, character) => total + (/[^\u0000-\u00ff]/.test(character) ? 1 : 0.55),
0,
);
export const compactMindMapNodeSize = (label: string, isRoot: boolean) => ({
width: Math.round(Math.min(isRoot ? 168 : 156, Math.max(isRoot ? 112 : 92, visualTextUnits(label) * 13 + 28))),
height: isRoot ? 42 : 36,
});
export const compactFlowchartNodeSize = (shape: DiagramNodeShape) => (
shape === "decision"
? { width: 116, height: 72 }
: shape === "terminator"
? { width: 116, height: 44 }
: { width: 124, height: 44 }
);
export const compactArchitectureNodeSize = (
shape: DiagramNodeShape,
authored?: { width: number; height: number },
@@ -558,13 +547,14 @@ export const compileDiagramIr = (ir: DiagramIr): DiagramDocument => {
? compactMindMapNodeSize(node.label, !node.parentId)
: ir.kind === "architecture"
? compactArchitectureNodeSize(shape)
: compactFlowchartNodeSize(shape);
: flowchartNodePresentation(shape, node.label);
return {
id: node.id,
label: node.label,
x: 72,
y: 64 + index * 80,
...size,
width: size.width,
height: size.height,
shape,
...(node.parentId ? { parentId: node.parentId } : {}),
...(node.resourceIcon ? { resourceIcon: node.resourceIcon } : {}),
@@ -0,0 +1,35 @@
import type { DiagramNodeShape } from "./diagram";
export const visualTextUnits = (label: string) => Array.from(label).reduce(
(total, character) => total + (/[^\u0000-\u00ff]/.test(character) ? 1 : 0.55),
0,
);
export const compactFlowchartNodeSize = (shape: DiagramNodeShape) => (
shape === "decision"
? { width: 116, height: 72 }
: shape === "terminator"
? { width: 116, height: 44 }
: { width: 124, height: 44 }
);
// Shared by semantic creation and rendering so layout reserves space for every line.
export const flowchartNodePresentation = (shape: DiagramNodeShape, label: string) => {
const base = compactFlowchartNodeSize(shape);
const decision = shape === "decision";
const width = Math.max(decision ? 184 : base.width, Math.min(decision ? 220 : 240,
Math.ceil(Math.max(...label.split("\n").map(visualTextUnits), 0) * 14 + (decision ? 64 : 32))));
const capacity = (width - (decision ? width / 2 : 32)) / 14;
const lines = label.split("\n").flatMap((paragraph) => {
const result: string[] = [];
let line = "";
for (const character of Array.from(paragraph)) {
if (line && visualTextUnits(line + character) > capacity) { result.push(line); line = ""; }
line += character;
}
result.push(line);
return result;
});
return { width, height: Math.max(base.height, lines.length * 18 * (decision ? 2 : 1) + 24), text: lines.join("\n") };
};
+74
View File
@@ -0,0 +1,74 @@
import type { DiagramDocument } from './diagram';
type ReaderGraph = {
resize: (width: number, height: number) => unknown;
scale: () => { sx: number; sy: number };
zoomTo: (scale: number) => unknown;
zoomToFit: (options: { maxScale: number; padding: number }) => unknown;
centerContent: () => unknown;
translate: { (): { tx: number; ty: number }; (x: number, y: number): unknown };
on: (event: string, callback: () => void) => unknown;
off: (event: string, callback: () => void) => unknown;
};
/** Common touch-sized reading controls for Android and iOS WebViews. No document writes. */
export const attachDiagramReader = (
graph: ReaderGraph, container: HTMLElement, diagram: DiagramDocument,
locale: string, dark: boolean,
) => {
const english = locale === 'en-US';
const toolbar = document.createElement('div');
toolbar.className = 'edgeever-diagram-reader-controls';
toolbar.setAttribute('role', 'group');
toolbar.setAttribute('aria-label', english ? 'Diagram view' : '图表视图');
toolbar.style.cssText = `display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:8px 16px;color:${dark ? '#E8F2ED' : '#173B2E'};`;
const addButton = (label: string, action: () => void, aria = label) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.setAttribute('aria-label', aria);
button.style.cssText = 'min-width:44px;min-height:44px;padding:8px;border:1px solid #7f9c8e;border-radius:8px;background:transparent;color:inherit;font:inherit;touch-action:manipulation;';
button.onclick = action;
toolbar.appendChild(button);
return button;
};
const fit = () => { graph.zoomToFit({ maxScale: 1.05, padding: 28 }); graph.centerContent(); };
const read = () => {
const incoming = new Set(diagram.edges.map((edge) => edge.target));
const start = diagram.nodes.find((node) => !incoming.has(node.id)) ?? diagram.nodes[0];
if (!start) return;
graph.zoomTo(1);
graph.translate(width / 2 - start.x - start.width / 2, 32 - start.y);
};
addButton('−', () => graph.zoomTo(Math.max(0.1, graph.scale().sx / 1.25)), english ? 'Zoom out' : '缩小');
const percent = addButton('100%', () => { graph.zoomTo(1); graph.centerContent(); }, english ? 'Reset zoom to 100%' : '恢复 100%');
addButton('+', () => graph.zoomTo(Math.min(2.5, graph.scale().sx * 1.25)), english ? 'Zoom in' : '放大');
addButton(english ? 'Fit' : '适应画布', fit);
if (diagram.kind === 'flowchart') addButton(english ? 'Read from start' : '从起点阅读', read);
const hint = document.createElement('div');
hint.textContent = english ? 'Drag the diagram to explore. Use + / − to zoom.' : '拖动画布浏览,使用 + / − 缩放';
hint.style.cssText = 'flex-basis:100%;font-size:12px;opacity:.75;';
toolbar.appendChild(hint);
container.parentNode?.insertBefore(toolbar, container);
const update = () => { percent.textContent = `${Math.round(graph.scale().sx * 100)}%`; };
graph.on('scale', update);
let width = 0;
let height = 0;
return {
resize(nextWidth: number, nextHeight: number) {
if (nextWidth === width && nextHeight === height) return;
const first = width === 0;
const previous = graph.translate();
const dx = (nextWidth - width) / 2;
const dy = (nextHeight - height) / 2;
width = nextWidth; height = nextHeight;
graph.resize(width, height);
if (first) {
fit();
if (diagram.kind === 'flowchart' && graph.scale().sx < 0.8) read();
} else graph.translate(previous.tx + dx, previous.ty + dy);
update();
},
dispose() { graph.off('scale', update); toolbar.remove(); },
};
};
+9 -5
View File
@@ -1,3 +1,4 @@
import { flowchartNodePresentation } from "./diagram-node-presentation";
import type { ArchitectureResourceIcon, DiagramDocument, DiagramNodeShape, DiagramTheme } from "./diagram";
export type DiagramAppearance = "light" | "dark";
@@ -80,6 +81,7 @@ export const diagramDocumentToX6Cells = (
) => {
const palette = resolvePortableDiagramPalette(document.theme ?? "brand", appearance);
const nodes = document.nodes.map((node) => {
const presentation = document.kind === "flowchart" ? flowchartNodePresentation(node.shape, node.label) : { width: node.width, height: node.height, text: node.label };
const isRootTopic = node.shape === "topic" && !node.parentId;
const isTerminator = node.shape === "terminator";
const isBoundary = node.shape === "boundary";
@@ -100,8 +102,8 @@ export const diagramDocumentToX6Cells = (
shape: node.shape === "decision" ? "polygon" : "rect",
x: node.x,
y: node.y,
width: node.width,
height: node.height,
width: presentation.width,
height: presentation.height,
zIndex: isBoundary ? 0 : 2,
...(usesArchitectureIcon ? { markup: [
{ tagName: "rect", selector: "body" },
@@ -120,7 +122,8 @@ export const diagramDocumentToX6Cells = (
...(node.shape === "decision" ? { refPoints: "0,10 10,0 20,10 10,20" } : {}),
},
label: {
text: node.label,
text: presentation.text,
lineHeight: 18,
fill: emphasized ? palette.topicText : palette.nodeText,
fontSize: node.shape === "topic" ? 14 : isBoundary ? 12 : 13,
fontWeight: emphasized || isBoundary || accent ? 650 : 500,
@@ -159,6 +162,7 @@ export const diagramDocumentToX6Cells = (
id: edge.id,
source: { cell: edge.source },
target: { cell: edge.target },
router: document.kind === "flowchart" ? { name: "manhattan", args: { padding: 28, step: 10 } } : undefined,
connector: { name: document.kind === "mind-map" ? "smooth" : "rounded", args: { radius: 10 } },
attrs: { line: {
stroke,
@@ -168,8 +172,8 @@ export const diagramDocumentToX6Cells = (
targetMarker: document.kind === "mind-map" ? null : { name: "block", width: 8, height: 6 },
} },
labels: edge.label ? [{ attrs: {
label: { text: edge.label, fill: palette.nodeText, fontSize: 12 },
body: { fill: palette.canvas, stroke: palette.nodeStroke, strokeWidth: 1, rx: 5, ry: 5 },
label: { text: edge.label, fill: palette.nodeText, fontSize: 12, lineHeight: 16, textWrap: { width: 140, height: 512 } },
body: { ref: "label", refWidth: 1, refHeight: 1, refWidth2: 12, refHeight2: 8, refX: -6, refY: -4, fill: palette.canvas, stroke: palette.nodeStroke, strokeWidth: 1, rx: 5, ry: 5 },
} }] : undefined,
};
});
+11
View File
@@ -134,3 +134,14 @@ describe("diagram document", () => {
expect(parseDiagramDocument(serializeDiagramDocument(architecture))).toBeNull();
});
});
test('native flowchart projection shares label sizing and obstacle routing without mutating content', () => {
const document = createDefaultDiagramDocument('flowchart');
document.nodes[1].label = 'Transformer 前向计算\n因果注意力以及前馈网络'.repeat(4);
const original = structuredClone(document);
const projection = diagramDocumentToX6Cells(document, 'dark');
expect(projection.nodes[1].height).toBeGreaterThan(document.nodes[1].height);
expect(projection.nodes[1].attrs.label.text.replaceAll('\n', '')).toBe(document.nodes[1].label.replaceAll('\n', ''));
expect(projection.edges[0].router.name).toBe('manhattan');
expect(document).toEqual(original);
});
+19
View File
@@ -363,3 +363,22 @@ export const createDefaultDiagramDocument = (kind: DiagramKind): DiagramDocument
],
};
};
export type DiagramSummaryPreview = { nodeCount: number; edgeCount: number; labels: string[] };
/** Read-only, bounded list metadata; never ships the diagram payload to list rows. */
export const getDiagramSummary = (markdown: string | null | undefined): {
diagramKind: DiagramKind | null;
diagramPreview?: DiagramSummaryPreview;
} => {
const document = parseDiagramDocument(markdown);
if (!document) return { diagramKind: null };
const roots = new Set(document.nodes.filter((node) => !node.parentId).map((node) => node.id));
const primary = document.kind === "mind-map"
? document.nodes.filter((node) => node.parentId && roots.has(node.parentId))
: document.kind === "architecture" ? document.nodes.filter((node) => node.shape === "boundary") : [];
const candidates = primary.length ? primary : document.nodes;
const labels = [...new Set(candidates.map((node) => node.label.replace(/\s+/g, " ").trim()).filter(Boolean))]
.slice(0, 4).map((label) => Array.from(label).slice(0, 48).join(""));
return { diagramKind: document.kind, diagramPreview: { nodeCount: document.nodes.length, edgeCount: document.edges.length, labels } };
};
+14
View File
@@ -351,6 +351,20 @@ export const enUS = {
redo: "Redo",
zoomIn: "Zoom in",
zoomOut: "Zoom out",
listCounts: "{{nodes}} nodes · {{edges}} links",
branchReader: "Branch reading canvas (read-only)",
branchView: "Branch view",
branchOverview: "Overview",
expandBranch: "Expand branch",
collapseBranch: "Collapse branch",
focusBranch: "Focus branch",
showFullMap: "Show full map",
focusedBranch: "Reading (read-only): {{label}}",
collapsedBranches: "{{count}} branches collapsed",
selectBranchHint: "Select a topic to collapse or focus; exports always include the full map",
resetZoom: "Reset to 100% and center",
readFlow: "Read from start",
readFlowHint: "Read at 100%; scroll or drag to pan, Ctrl / ⌘ + scroll to zoom",
fit: "Fit diagram",
autoLayout: "Auto layout",
autoLayoutTooltip: "Re-arrange all nodes and connections",
+14
View File
@@ -351,6 +351,20 @@ export const zhCN = {
redo: "重做",
zoomIn: "放大",
zoomOut: "缩小",
listCounts: "{{nodes}} 节点 · {{edges}} 连线",
branchReader: "分支阅读画布(只读)",
branchView: "分支视图",
branchOverview: "概览",
expandBranch: "展开分支",
collapseBranch: "折叠分支",
focusBranch: "聚焦分支",
showFullMap: "显示完整导图",
focusedBranch: "聚焦阅读(只读):{{label}}",
collapsedBranches: "已折叠 {{count}} 个分支",
selectBranchHint: "选择主题可折叠或聚焦;导出始终包含完整导图",
resetZoom: "恢复 100% 并居中",
readFlow: "从起点阅读",
readFlowHint: "以原始字号从起点阅读;滚轮或拖动画布浏览,Ctrl / ⌘ + 滚轮缩放",
fit: "适应画布",
autoLayout: "自动布局",
autoLayoutTooltip: "重新规整所有节点与连线",
+2
View File
@@ -36,3 +36,5 @@ export * from "./note-image-card";
export * from "./pdf-attachment";
export * from "./types";
export * from './plugin-capabilities';
export { attachDiagramReader } from "./diagram-reader";
+2 -1
View File
@@ -1,6 +1,6 @@
import type { TiptapDoc } from "./content";
import type { AiAction, AiPromptParameterKind, AiPromptResultMode } from "./ai-assistant";
import type { DiagramKind } from "./diagram";
import type { DiagramKind, DiagramSummaryPreview } from "./diagram";
export type Notebook = {
id: string;
@@ -22,6 +22,7 @@ export type MemoSummary = {
title: string | null;
excerpt: string;
diagramKind?: DiagramKind | null;
diagramPreview?: DiagramSummaryPreview;
tags: string[];
isPinned: boolean;
isArchived: boolean;