feat: improve diagram reading and layout across clients
This commit is contained in:
1 parent
1d111e123a
commit
1b804b165d
29 files changed
+1459
-887
No files matched your search
@@ -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
+383
-380
File diff suppressed because it is too large.
Load diff
@@ -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}");
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
},
|
||||
});
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
compileDiagramIr,
|
||||
compactArchitectureNodeSize,
|
||||
compactFlowchartNodeSize,
|
||||
flowchartNodePresentation,
|
||||
compactMindMapNodeSize,
|
||||
computeDiagramLayout,
|
||||
computeDiagramLayoutResult,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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") };
|
||||
};
|
||||
|
||||
@@ -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(); },
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 } };
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "重新规整所有节点与连线",
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user