feat(web): add Markdown outline editing for mind maps (#487)

This commit is contained in:
tianma-if committed 2026-10-07 23:22:45 +08:00
1 parent 464e62da62
commit c7a5eb6c30
11 files changed
+826 -4

No files matched your search

+207 -4
View File
@@ -1,4 +1,6 @@
import { diagramEditorSnapshot } from "@/lib/diagram-editor-snapshot";
import { formatMindMapOutline, projectMindMapOutline, updateMindMapOutlineDraft, type MindMapOutlineDraft, type MindMapOutlineError } from "@/lib/mind-map-outline";
import { MindMapOutlineEditor } from "@/components/MindMapOutlineEditor";
import { focusArchitectureRelations } from "@/lib/architecture-relations";
import { MemoTitleInput } from "@/components/MemoTitleInput";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
@@ -1455,6 +1457,72 @@ const graphToDocument = (graph: Graph, kind: DiagramDocument["kind"], theme: Dia
}),
});
const applyMindMapOutlineDocument = (
graph: Graph,
nextDocument: DiagramDocument,
structureChanged: boolean,
theme: DiagramTheme,
appearance: DiagramAppearance,
structure: DiagramStructure,
) => {
const nextNodeIds = new Set(nextDocument.nodes.map((node) => node.id));
const nextEdgeIds = new Set(nextDocument.edges.map((edge) => edge.id));
const changedNodeIds = new Set<string>();
graph.cleanSelection();
graph.startBatch("outline");
try {
for (const edge of graph.getEdges()) {
if (!nextEdgeIds.has(edge.id)) edge.remove();
}
for (const node of graph.getNodes()) {
if (!nextNodeIds.has(node.id)) node.remove();
}
for (const nextNode of nextDocument.nodes) {
const current = graph.getCellById(nextNode.id);
if (!current?.isNode()) {
graph.addNode(nodeMetadata(nextNode, theme, "mind-map", appearance, structure));
changedNodeIds.add(nextNode.id);
continue;
}
const data = current.getData<NodeData>();
if (data?.label !== nextNode.label || data?.parentId !== nextNode.parentId) {
current.setData({ ...data, label: nextNode.label, parentId: nextNode.parentId });
changedNodeIds.add(nextNode.id);
}
}
for (const nextEdge of nextDocument.edges) {
const current = graph.getCellById(nextEdge.id);
if (!current?.isEdge()) {
graph.addEdge(edgeMetadata(nextEdge, "mind-map", theme, appearance, structure));
continue;
}
if (current.getSourceCellId() !== nextEdge.source) current.setSource({ cell: nextEdge.source });
if (current.getTargetCellId() !== nextEdge.target) current.setTarget({ cell: nextEdge.target });
}
for (const [index, nextNode] of nextDocument.nodes.entries()) {
const current = graph.getCellById(nextNode.id);
if (!current?.isNode()) continue;
if (structureChanged) current.position(current.getPosition().x, index * 80);
if (structureChanged || changedNodeIds.has(nextNode.id)) {
refreshNodeLabel(current, nextNode.label, structure);
}
}
if (structureChanged) {
const layout = computeDiagramLayoutResult(graphToDocument(graph, "mind-map", theme, structure));
for (const nodeId of layout.nodeOrder) {
const node = graph.getCellById(nodeId);
const geometry = layout.nodes[nodeId];
if (!node?.isNode() || !geometry) continue;
node.position(geometry.x, geometry.y);
node.resize(geometry.width, geometry.height);
}
}
if (structureChanged || changedNodeIds.size > 0) applyMindMapHierarchy(graph, theme, appearance, structure);
} finally {
graph.stopBatch("outline");
}
};
const removeGraphSelection = (graph: Graph) => {
const selected = graph.getSelectedCells();
if (selected.length === 0) return false;
@@ -1727,6 +1795,22 @@ export const DiagramEditorPane = ({
const [tagsDirty, setTagsDirty] = useState(false);
const [dirtyVersion, setDirtyVersion] = useState(0);
const [graphReloadVersion, setGraphReloadVersion] = useState(0);
const [outlineMode, setOutlineMode] = useState(false);
const [outlineDraft, setOutlineDraft] = useState<MindMapOutlineDraft | null>(null);
const [outlineError, setOutlineError] = useState<MindMapOutlineError | null>(null);
const [outlinePendingBulk, setOutlinePendingBulk] = useState<{
document: DiagramDocument;
draft: MindMapOutlineDraft;
structureChanged: boolean;
removedCount: number;
} | null>(null);
useEffect(() => {
setOutlineMode(false);
setOutlineDraft(null);
setOutlineError(null);
setOutlinePendingBulk(null);
}, [memo.id]);
const [saving, setSaving] = useState(false);
const [editSessionReady, setEditSessionReady] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
@@ -1763,7 +1847,7 @@ export const DiagramEditorPane = ({
const flowQuickCreateRef = useRef<FlowQuickCreateState | null>(null);
const flowPointerDragRef = useRef<FlowPointerDragState | null>(null);
const nodeEditorRef = useRef<NodeEditorState | null>(null);
const editorDirty = dirty || tagsDirty;
const editorDirty = dirty || tagsDirty || Boolean(outlineError) || Boolean(outlinePendingBulk);
spacePanActiveRef.current = spacePanActive;
@@ -2906,6 +2990,11 @@ export const DiagramEditorPane = ({
setSelectedEdgeLabel("");
setHasSelection(false);
setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() });
if (outlineMode && document?.kind === "mind-map") {
setOutlineDraft(formatMindMapOutline(graphToDocument(graph, "mind-map", themeRef.current, structureRef.current)));
setOutlineError(null);
setOutlinePendingBulk(null);
}
if (document) {
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
titleRef.current,
@@ -3006,11 +3095,105 @@ export const DiagramEditorPane = ({
}
};
const openMindMapOutline = () => {
const graph = graphRef.current;
if (!graph || document?.kind !== "mind-map") return;
const draft = formatMindMapOutline(graphToDocument(graph, "mind-map", themeRef.current, structureRef.current));
if (!draft) return;
setOutlineDraft(draft);
setOutlineError(null);
setOutlinePendingBulk(null);
setOutlineMode(true);
};
const commitMindMapOutline = (result: Extract<ReturnType<typeof projectMindMapOutline>, { ok: true }>) => {
const graph = graphRef.current;
if (!graph || document?.kind !== "mind-map" || readOnly) return;
const currentDocument = graphToDocument(graph, "mind-map", themeRef.current, structureRef.current);
const oldNodes = new Map(currentDocument.nodes.map((node) => [node.id, node]));
const oldEdges = new Map(currentDocument.edges.map((edge) => [edge.id, edge]));
const hasGraphChanges = result.structureChanged
|| result.document.nodes.some((node) => node.label !== oldNodes.get(node.id)?.label)
|| result.document.edges.length !== currentDocument.edges.length
|| result.document.edges.some((edge) => {
const old = oldEdges.get(edge.id);
return !old || old.source !== edge.source || old.target !== edge.target;
});
if (hasGraphChanges) {
applyMindMapOutlineDocument(
graph,
result.document,
result.structureChanged,
themeRef.current,
appearanceRef.current,
structureRef.current,
);
}
setOutlineDraft(result.draft);
setOutlineError(null);
setOutlinePendingBulk(null);
setSelectedNodeId(null);
setSelectedNodeLabel("");
setSelectedEdgeId(null);
setSelectedEdgeLabel("");
setHasSelection(false);
setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() });
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
titleRef.current,
graphToDocument(graph, "mind-map", themeRef.current, structureRef.current),
));
if (hasGraphChanges) setDirtyVersion((current) => current + 1);
};
const updateMindMapOutline = (text: string) => {
const graph = graphRef.current;
if (!graph || !outlineDraft || document?.kind !== "mind-map" || readOnly) return;
const draft = updateMindMapOutlineDraft(outlineDraft, text);
const currentDocument = graphToDocument(graph, "mind-map", themeRef.current, structureRef.current);
const result = projectMindMapOutline(currentDocument, draft, () => createId("topic"));
if (!result.ok) {
setOutlineDraft(draft);
setOutlineError(result.error);
setOutlinePendingBulk(null);
return;
}
const nextIds = new Set(result.document.nodes.map((node) => node.id));
const removedCount = currentDocument.nodes.filter((node) => !nextIds.has(node.id)).length;
if (removedCount >= 3 && removedCount >= Math.ceil(currentDocument.nodes.length * 0.3)) {
setOutlineDraft({ ...result.draft, lastValidText: draft.lastValidText, lastValidLineIds: draft.lastValidLineIds });
setOutlineError(null);
setOutlinePendingBulk({ ...result, removedCount });
return;
}
commitMindMapOutline(result);
};
const restoreMindMapOutline = () => {
const graph = graphRef.current;
if (!graph || document?.kind !== "mind-map") return;
setOutlineDraft(formatMindMapOutline(graphToDocument(graph, "mind-map", themeRef.current, structureRef.current)));
setOutlineError(null);
setOutlinePendingBulk(null);
};
const closeMindMapOutline = () => {
if (outlineError || outlinePendingBulk) return;
setOutlineMode(false);
setOutlineDraft(null);
const graph = graphRef.current;
if (!graph) return;
requestAnimationFrame(() => {
if (graphRef.current !== graph) return;
ensureDiagramPaperContainsNodes(graph);
fitDiagramContent(graph, graphToDocument(graph, "mind-map", themeRef.current, structureRef.current), containerRef.current);
});
};
const save = async () => {
const graph = graphRef.current;
const currentMemo = memoRef.current;
const editSession = editSessionRef.current;
if (!graph || !document || !editSession || readOnly || saving) return false;
if (!graph || !document || !editSession || readOnly || saving || outlineError || outlinePendingBulk) return false;
if (
savedSnapshotRef.current === diagramEditorSnapshot(titleRef.current, graphToDocument(graph, document.kind, themeRef.current, structureRef.current))
&& !tagsDirty
@@ -3068,10 +3251,10 @@ export const DiagramEditorPane = ({
saveRef.current = () => { void save(); };
useEffect(() => {
if (readOnly || !editorDirty || nodeEditor !== null || saving || !editSessionReady || saveFailed) return;
if (readOnly || !editorDirty || outlineError || outlinePendingBulk || nodeEditor !== null || saving || !editSessionReady || saveFailed) return;
const timer = window.setTimeout(() => saveRef.current(), EDITOR_LOCAL_SAVE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [dirtyVersion, editSessionReady, editorDirty, nodeEditor, readOnly, saveFailed, saving]);
}, [dirtyVersion, editSessionReady, editorDirty, nodeEditor, outlineError, outlinePendingBulk, readOnly, saveFailed, saving]);
const handleCopyMemoId = async () => {
if (isLocalMemoId(memo.id)) return;
@@ -3446,6 +3629,13 @@ export const DiagramEditorPane = ({
)
) : undefined}
onAutoLayout={applyAutoLayout}
outlineView={document.kind === "mind-map" ? {
active: outlineMode,
available: outlineMode || Boolean(formatMindMapOutline(graphRef.current
? graphToDocument(graphRef.current, "mind-map", themeRef.current, structureRef.current)
: document)),
onToggle: outlineMode ? closeMindMapOutline : openMindMapOutline,
} : undefined}
onDeleteSelection={removeSelected}
onExport={exportDiagram}
onRedo={() => runHistoryAction("redo")}
@@ -3614,6 +3804,19 @@ export const DiagramEditorPane = ({
<div className="whitespace-nowrap px-1.5 pt-1 text-xs text-slate-400">{t("diagram.quickCreateShortcuts")}</div>
</div>
) : null}
{outlineMode && outlineDraft ? (
<MindMapOutlineEditor
value={outlineDraft.text}
error={outlineError}
readOnly={readOnly}
onChange={updateMindMapOutline}
onUndo={() => runHistoryAction("undo")}
onRedo={() => runHistoryAction("redo")}
pendingRemovalCount={outlinePendingBulk?.removedCount ?? 0}
onApplyRemoval={() => { if (outlinePendingBulk) commitMindMapOutline({ ...outlinePendingBulk, ok: true }); }}
onRestore={restoreMindMapOutline}
/>
) : null}
{nodeEditor ? (
<input
autoFocus
@@ -8,6 +8,8 @@ import {
EyeOff,
FileCode2,
FileImage,
ListTree,
Network,
Redo2,
Trash2,
Undo2,
@@ -144,6 +146,7 @@ type DiagramToolbarProps = {
leading?: ReactNode;
architectureLabels?: { showAll: boolean; onToggle: () => void };
onAutoLayout: () => void;
outlineView?: { active: boolean; available: boolean; onToggle: () => void };
onDeleteSelection: () => void;
onExport: (format: "png" | "svg") => void;
onRedo: () => void;
@@ -261,6 +264,7 @@ export const DiagramToolbar = ({
leading,
architectureLabels,
onAutoLayout,
outlineView,
onDeleteSelection,
onExport,
onRedo,
@@ -284,6 +288,22 @@ export const DiagramToolbar = ({
const themeLabel = t(`diagram.theme${resolvedTheme.charAt(0).toUpperCase()}${resolvedTheme.slice(1)}` as "diagram.themeBrand");
return (
<MemoEditorToolbarRow className="shrink-0 border-b border-slate-200 bg-card" role="toolbar" aria-label={t("diagram.toolbar")}>
{outlineView ? (
<Tooltip>
<TooltipTrigger asChild>
<Button size="sm" variant={outlineView.active ? "soft" : "ghost"} aria-disabled={!outlineView.available} onClick={outlineView.available ? outlineView.onToggle : undefined}>
{outlineView.active ? <Network className="h-4 w-4" /> : <ListTree className="h-4 w-4" />}
{t(outlineView.active ? "diagram.canvasView" : "diagram.outlineView")}
</Button>
</TooltipTrigger>
<TooltipContent>{t(outlineView.available ? (outlineView.active ? "diagram.canvasView" : "diagram.outlineView") : "diagram.outlineUnavailable")}</TooltipContent>
</Tooltip>
) : null}
{outlineView?.active ? <>
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.undo")} disabled={!canUndo || readOnly} onClick={onUndo}><Undo2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.undo")}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.redo")} disabled={!canRedo || readOnly} onClick={onRedo}><Redo2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.redo")}</TooltipContent></Tooltip>
</> : null}
{outlineView?.active ? null : <>
{leading ? <>{leading}<MemoEditorToolbarDivider /></> : null}
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.undo")} disabled={!canUndo || readOnly} onClick={onUndo}><Undo2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.undo")}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.redo")} disabled={!canRedo || readOnly} onClick={onRedo}><Redo2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.redo")}</TooltipContent></Tooltip>
@@ -406,6 +426,7 @@ export const DiagramToolbar = ({
</DropdownMenuContent>
</DropdownMenu>
{selectionEditor}
</>}
</MemoEditorToolbarRow>
);
};
@@ -0,0 +1,132 @@
import { useRef, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type { MindMapOutlineError } from "@/lib/mind-map-outline";
type MindMapOutlineEditorProps = {
value: string;
error: MindMapOutlineError | null;
readOnly: boolean;
onChange: (value: string) => void;
onUndo: () => void;
onRedo: () => void;
pendingRemovalCount: number;
onApplyRemoval: () => void;
onRestore: () => void;
};
export const MindMapOutlineEditor = ({ value, error, readOnly, onChange, onUndo, onRedo, pendingRemovalCount, onApplyRemoval, onRestore }: MindMapOutlineEditorProps) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const replaceSelection = (next: string, start: number, end = start) => {
onChange(next);
requestAnimationFrame(() => inputRef.current?.setSelectionRange(start, end));
};
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (readOnly || event.nativeEvent.isComposing) return;
const input = event.currentTarget;
if (!error && (event.metaKey || event.ctrlKey)) {
const key = event.key.toLowerCase();
if (key === "z" || key === "y") {
event.preventDefault();
event.stopPropagation();
if (key === "y" || event.shiftKey) onRedo();
else onUndo();
return;
}
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
event.stopPropagation();
const lineStart = value.lastIndexOf("\n", input.selectionStart - 1) + 1;
const lineEnd = value.indexOf("\n", lineStart);
const line = value.slice(lineStart, lineEnd < 0 ? value.length : lineEnd);
const indent = line.match(/^[\t ]*/)?.[0] ?? "";
const marker = line.slice(indent.length).match(/^(?:[-*+]|\d+[.)]) /)?.[0] ?? "- ";
const rootIsList = lineStart === 0 && /^(?:[-*+]|\d+[.)]) /.test(line);
const insertion = lineStart === 0 ? `\n${rootIsList ? " " : ""}- ` : `\n${indent}${marker}`;
replaceSelection(
value.slice(0, input.selectionStart) + insertion + value.slice(input.selectionEnd),
input.selectionStart + insertion.length,
);
return;
}
if (event.key !== "Tab") return;
event.preventDefault();
event.stopPropagation();
const start = value.lastIndexOf("\n", input.selectionStart - 1) + 1;
const selectionEnd = input.selectionEnd > start && value[input.selectionEnd - 1] === "\n"
? input.selectionEnd - 1
: input.selectionEnd;
const end = value.indexOf("\n", selectionEnd);
const blockEnd = end < 0 ? value.length : end;
const block = value.slice(start, blockEnd).split("\n");
let delta = 0;
let firstLineDelta = 0;
const changed = block.map((line, index) => {
if (!line.trim()) return line;
if (event.shiftKey) {
if (line.startsWith("\t")) { delta -= 1; if (index === 0) firstLineDelta = -1; return line.slice(1); }
if (line.startsWith(" ")) { delta -= 2; if (index === 0) firstLineDelta = -2; return line.slice(2); }
return line;
}
if (start === 0 && index === 0) return line;
delta += 2;
if (index === 0) firstLineDelta = 2;
return ` ${line}`;
}).join("\n");
replaceSelection(
value.slice(0, start) + changed + value.slice(blockEnd),
Math.max(start, input.selectionStart + firstLineDelta),
Math.max(start, input.selectionEnd + delta),
);
};
const visibleError = error?.reason === "empty" ? null : error;
const errorKey = visibleError ? ({
empty: "diagram.outlineErrorEmpty",
indent: "diagram.outlineErrorIndent",
root: "diagram.outlineErrorRoot",
depth: "diagram.outlineErrorDepth",
label: "diagram.outlineErrorLabel",
} as const)[visibleError.reason] : null;
return (
<section className="absolute inset-0 z-40 flex min-h-0 flex-col bg-card" aria-label={t("diagram.outlineView")}>
<div className="shrink-0 border-b border-slate-200 px-4 py-3">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{t("diagram.outlineView")}</p>
<p className="mt-0.5 text-xs text-slate-500">{t("diagram.outlineHint")}</p>
</div>
<div className="min-h-0 flex-1 p-3 sm:p-5">
<Textarea
ref={inputRef}
autoFocus
aria-label={t("diagram.outlineView")}
aria-invalid={Boolean(visibleError)}
aria-describedby={visibleError ? "mind-map-outline-error" : undefined}
className="h-full min-h-0 resize-none rounded-lg border-slate-200 bg-card px-4 py-3 font-mono text-sm leading-7 shadow-none focus-visible:ring-2"
readOnly={readOnly}
spellCheck={false}
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
{visibleError && errorKey ? (
<div id="mind-map-outline-error" className="shrink-0 border-t border-rose-200 bg-rose-50 px-4 py-2 text-sm text-rose-700" role="alert">
{t(errorKey, { line: visibleError.line })}
</div>
) : null}
{pendingRemovalCount > 0 ? (
<div className="flex shrink-0 flex-wrap items-center gap-2 border-t border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-900" role="alert">
<span className="min-w-0 flex-1">{t("diagram.outlineRemovalWarning", { count: pendingRemovalCount })}</span>
<Button size="sm" variant="outline" onClick={onRestore}>{t("diagram.outlineRestore")}</Button>
<Button size="sm" variant="soft" onClick={onApplyRemoval}>{t("diagram.outlineApplyRemoval")}</Button>
</div>
) : null}
</section>
);
};
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, test } from "bun:test";
import { createDefaultDiagramDocument } from "@edgeever/shared";
import { formatMindMapOutline, projectMindMapOutline, updateMindMapOutlineDraft } from "./mind-map-outline.ts";
const project = (document, draft) => {
let next = 0;
return projectMindMapOutline(document, draft, () => `new-${++next}`);
};
describe("mind map outline", () => {
test("round trips existing labels, hierarchy, and node identities", () => {
const document = createDefaultDiagramDocument("mind-map");
document.nodes[0].label = "- Root\\name\nsecond line\tend ";
const draft = formatMindMapOutline(document);
expect(draft).not.toBeNull();
expect(draft.text).toStartWith("# \\- Root\\\\name\\nsecond line\\tend ");
expect(draft.text).toContain("\n- 采集想法\n - 快速记录");
expect(draft.text).not.toContain("\t");
const result = project(document, draft);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.structureChanged).toBe(false);
expect(result.document.nodes.find((node) => node.id === "topic-root")?.label).toBe(document.nodes[0].label);
expect(result.document.nodes.map((node) => node.id).sort()).toEqual(document.nodes.map((node) => node.id).sort());
});
test("typing a label retains its node and labeled branch", () => {
const document = createDefaultDiagramDocument("mind-map");
const branch = document.edges.find((edge) => edge.target === "topic-1-a");
branch.label = "context";
const initial = formatMindMapOutline(document);
const edited = updateMindMapOutlineDraft(initial, initial.text.replace("快速记录", "快速记录 2"));
const result = project(document, edited);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.structureChanged).toBe(false);
expect(result.document.nodes.find((node) => node.id === "topic-1-a")?.label).toBe("快速记录 2");
expect(result.document.edges.find((edge) => edge.id === branch.id)).toEqual(branch);
});
test("pasting a renamed topic and a new topic keeps the renamed topic metadata", () => {
const document = createDefaultDiagramDocument("mind-map");
const existing = document.nodes.find((node) => node.id === "topic-1-a");
existing.width = 180;
const initial = formatMindMapOutline(document);
const edited = initial.text.replace("快速记录", "快速记录与整理").concat("\n- 新增主题");
const result = project(document, updateMindMapOutlineDraft(initial, edited));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.document.nodes.find((node) => node.label === "快速记录与整理")).toMatchObject({
id: "topic-1-a", width: 180,
});
expect(result.document.nodes.find((node) => node.label === "新增主题")?.id).toStartWith("new-");
});
test("pasting and reparenting topics preserves existing IDs and edge labels", () => {
const document = createDefaultDiagramDocument("mind-map");
const branch = document.edges.find((edge) => edge.target === "topic-1-a");
branch.label = "context";
const initial = formatMindMapOutline(document);
const lines = initial.text.split("\n");
const moved = lines.splice(lines.findIndex((line) => line.endsWith("快速记录")), 1)[0];
lines.splice(lines.findIndex((line) => line.endsWith("笔记本")) + 1, 0, moved, " - 新主题");
const result = project(document, updateMindMapOutlineDraft(initial, lines.join("\n")));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.structureChanged).toBe(true);
expect(result.document.nodes.find((node) => node.id === "topic-1-a")?.parentId).toBe("topic-2");
expect(result.document.edges.find((edge) => edge.id === branch.id)).toMatchObject({
source: "topic-2", target: "topic-1-a", label: "context",
});
expect(result.document.nodes.find((node) => node.label === "新主题")?.id).toStartWith("new-");
});
test("rejects malformed hierarchy without changing the source", () => {
const document = createDefaultDiagramDocument("mind-map");
const initial = formatMindMapOutline(document);
for (const [text, reason] of [
["", "empty"],
["# Root\n# Sibling", "root"],
["# Root\n - Skipped", "depth"],
["# Root\n - Child", "indent"],
["# Root\n- X" + "x".repeat(500), "label"],
]) {
const result = project(document, updateMindMapOutlineDraft(initial, text));
expect(result).toMatchObject({ ok: false, error: { reason } });
}
expect(document.nodes).toHaveLength(8);
});
test("ignores an empty list item without changing the diagram", () => {
const document = createDefaultDiagramDocument("mind-map");
const initial = formatMindMapOutline(document);
const edited = initial.text.replace(" - 跨设备同步", " - \n - 跨设备同步");
const result = project(document, updateMindMapOutlineDraft(initial, edited));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.structureChanged).toBe(false);
expect(result.document.nodes.map((node) => node.id).sort()).toEqual(document.nodes.map((node) => node.id).sort());
});
test("omits an emptied topic and promotes its nonempty children", () => {
const document = createDefaultDiagramDocument("mind-map");
const initial = formatMindMapOutline(document);
const edited = initial.text.replace("- 采集想法", "- ");
const result = project(document, updateMindMapOutlineDraft(initial, edited));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.document.nodes.some((node) => node.id === "topic-1")).toBe(false);
expect(result.document.nodes.find((node) => node.id === "topic-1-a")?.parentId).toBe("topic-root");
expect(result.document.nodes.find((node) => node.id === "topic-1-b")?.parentId).toBe("topic-root");
});
test("keeps the last valid map while the root is empty", () => {
const document = createDefaultDiagramDocument("mind-map");
const initial = formatMindMapOutline(document);
const result = project(document, updateMindMapOutlineDraft(initial, initial.text.replace("# 核心主题", "# ")));
expect(result).toMatchObject({ ok: false, error: { reason: "empty" } });
expect(document.nodes).toHaveLength(8);
});
test("restores identities after an invalid intermediate paste", () => {
const document = createDefaultDiagramDocument("mind-map");
const original = formatMindMapOutline(document);
const invalid = updateMindMapOutlineDraft(original, "核心主题\n第二根");
expect(project(document, invalid).ok).toBe(false);
const restored = updateMindMapOutlineDraft(invalid, original.text);
const result = project(document, restored);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.document.nodes.map((node) => node.id).sort()).toEqual(document.nodes.map((node) => node.id).sort());
expect(result.structureChanged).toBe(false);
});
test("a wholesale replacement does not silently inherit old topic identities", () => {
const document = createDefaultDiagramDocument("mind-map");
const initial = formatMindMapOutline(document);
const replacement = initial.text.split("\n").map((line, index) =>
`${line.match(/^(?:# | *- )/)?.[0] ?? ""}Replacement ${index}`,
).join("\n");
const result = project(document, updateMindMapOutlineDraft(initial, replacement));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.document.nodes.every((node) => node.id.startsWith("new-"))).toBe(true);
});
test("accepts pasted Markdown lists and preserves unrelated edges", () => {
const document = createDefaultDiagramDocument("mind-map");
document.edges.push({ id: "extra", source: "topic-1-a", target: "topic-3-a", label: "see also" });
const initial = formatMindMapOutline(document);
const draft = updateMindMapOutlineDraft(initial, "# 核心主题\r\n- 采集想法\r\n - 快速记录\r\n - 跨设备同步\r\n- 整理结构\r\n - 笔记本\r\n- 输出分享\r\n - 公开链接");
const result = project(document, draft);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.document.nodes.map((node) => node.id).sort()).toEqual(document.nodes.map((node) => node.id).sort());
expect(result.document.edges.find((edge) => edge.id === "extra")).toMatchObject({ label: "see also" });
});
test("still accepts the earlier plain indented outline", () => {
const document = createDefaultDiagramDocument("mind-map");
const initial = formatMindMapOutline(document);
const plain = "核心主题\n\t采集想法\n\t\t快速记录\n\t\t跨设备同步\n\t整理结构\n\t\t笔记本\n\t输出分享\n\t\t公开链接";
const result = project(document, updateMindMapOutlineDraft(initial, plain));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.document.nodes.map((node) => node.id).sort()).toEqual(document.nodes.map((node) => node.id).sort());
});
});
+246
View File
@@ -0,0 +1,246 @@
import type { DiagramDocument, DiagramNode } from "@edgeever/shared";
export type MindMapOutlineDraft = {
text: string;
lineIds: Array<string | null>;
lastValidText: string;
lastValidLineIds: Array<string | null>;
};
export type MindMapOutlineError = {
line: number;
reason: "empty" | "indent" | "root" | "depth" | "label";
};
type OutlineResult =
| { ok: true; document: DiagramDocument; draft: MindMapOutlineDraft; structureChanged: boolean }
| { ok: false; error: MindMapOutlineError };
const escapeLabel = (label: string) => label
.replaceAll("\\", "\\\\")
.replaceAll("\r", "\\r")
.replaceAll("\n", "\\n")
.replaceAll("\t", "\\t")
.replace(/^([-*+] )/, "\\$1");
const unescapeLabel = (label: string) => label.replace(/\\(\\|r|n|t|[-*+] )/g, (_match, value: string) => (
value === "n" ? "\n" : value === "t" ? "\t" : value === "r" ? "\r" : value
));
const listMarker = /^(?:[-*+]|\d+[.)]) /;
const headingMarker = /^#{1,6} /;
const outlineLabel = (line: string) => {
const content = line.replace(/^[\t ]*/, "");
return content.replace(headingMarker.test(content) ? headingMarker : listMarker, "").trim();
};
export const formatMindMapOutline = (document: DiagramDocument): MindMapOutlineDraft | null => {
if (document.kind !== "mind-map" || document.nodes.length === 0) return null;
const roots = document.nodes.filter((node) => !node.parentId);
if (roots.length !== 1 || document.nodes.some((node) => node.shape !== "topic")) return null;
const children = new Map<string, DiagramNode[]>();
for (const node of document.nodes) {
if (!node.parentId) continue;
const group = children.get(node.parentId) ?? [];
group.push(node);
children.set(node.parentId, group);
}
for (const group of children.values()) {
group.sort((left, right) => left.y - right.y || left.x - right.x || left.id.localeCompare(right.id));
}
const lines: string[] = [];
const lineIds: string[] = [];
const visited = new Set<string>();
const visit = (node: DiagramNode, depth: number) => {
if (visited.has(node.id)) return;
visited.add(node.id);
lines.push(depth === 0
? `# ${escapeLabel(node.label)}`
: `${" ".repeat(depth - 1)}- ${escapeLabel(node.label)}`);
lineIds.push(node.id);
for (const child of children.get(node.id) ?? []) visit(child, depth + 1);
};
visit(roots[0], 0);
if (visited.size !== document.nodes.length) return null;
const text = lines.join("\n");
return { text, lineIds, lastValidText: text, lastValidLineIds: [...lineIds] };
};
/** Keep identities through typing, indentation, and moving unchanged lines. */
export const updateMindMapOutlineDraft = (draft: MindMapOutlineDraft, text: string): MindMapOutlineDraft => {
text = text.replace(/\r\n?/g, "\n");
const previous = draft.text.split("\n");
const next = text.split("\n");
const lineIds: Array<string | null> = Array(next.length).fill(null);
const used = new Set<number>();
const previousLabels = previous.map(outlineLabel);
const nextLabels = next.map(outlineLabel);
const previousByLabel = new Map<string, number[]>();
const nextCounts = new Map<string, number>();
previousLabels.forEach((label, index) => {
if (!label) return;
const matches = previousByLabel.get(label) ?? [];
matches.push(index);
previousByLabel.set(label, matches);
});
nextLabels.forEach((label) => {
if (label) nextCounts.set(label, (nextCounts.get(label) ?? 0) + 1);
});
// Match unique labels first, including lines moved to a different branch.
for (let index = 0; index < next.length; index += 1) {
const label = nextLabels[index];
if (!label) continue;
const candidates = previousByLabel.get(label) ?? [];
if (candidates.length !== 1 || nextCounts.get(label) !== 1) continue;
lineIds[index] = draft.lineIds[candidates[0]] ?? null;
used.add(candidates[0]);
}
// Pair unchanged duplicate labels in their existing order.
for (let index = 0; index < next.length; index += 1) {
if (lineIds[index] || !nextLabels[index]) continue;
const oldIndex = previousLabels.findIndex((label, candidate) =>
!used.has(candidate) && label === nextLabels[index] && draft.lineIds[candidate]);
if (oldIndex < 0) continue;
lineIds[index] = draft.lineIds[oldIndex];
used.add(oldIndex);
}
// Invalid intermediate text must not forget the last committed tree. This
// restores IDs when a user pastes the original outline back in one action.
const validLabels = draft.lastValidText.split("\n").map(outlineLabel);
const assigned = new Set(lineIds.filter((id): id is string => Boolean(id)));
for (let index = 0; index < next.length; index += 1) {
if (lineIds[index] || !nextLabels[index]) continue;
const oldIndex = validLabels.findIndex((label, candidate) => {
const id = draft.lastValidLineIds[candidate];
return label === nextLabels[index] && Boolean(id) && !assigned.has(id!);
});
if (oldIndex < 0) continue;
const id = draft.lastValidLineIds[oldIndex];
lineIds[index] = id;
if (id) assigned.add(id);
}
// Within an unchanged pair of neighboring topics, a one-for-one replacement
// is an edited label. Insertions and removals leave unequal gaps and do not
// inherit metadata from a different topic.
const anchors: Array<{ previousIndex: number; nextIndex: number }> = [];
for (let nextIndex = 0; nextIndex < lineIds.length; nextIndex += 1) {
const id = lineIds[nextIndex];
const previousIndex = id ? draft.lineIds.indexOf(id) : -1;
if (previousIndex < 0) continue;
used.add(previousIndex);
if (previousIndex > (anchors.at(-1)?.previousIndex ?? -1)) anchors.push({ previousIndex, nextIndex });
}
const boundaries = [{ previousIndex: -1, nextIndex: -1 }, ...anchors, { previousIndex: previous.length, nextIndex: next.length }];
for (let boundary = 1; boundary < boundaries.length; boundary += 1) {
const before = boundaries[boundary - 1];
const after = boundaries[boundary];
const oldGap = previous.flatMap((_line, index) => index > before.previousIndex && index < after.previousIndex
&& !used.has(index) && draft.lineIds[index] ? [index] : []);
const newGap = next.flatMap((_line, index) => index > before.nextIndex && index < after.nextIndex
&& !lineIds[index] && nextLabels[index] ? [index] : []);
if (oldGap.length !== 1 || newGap.length !== 1) continue;
lineIds[newGap[0]] = draft.lineIds[oldGap[0]];
used.add(oldGap[0]);
}
// A single edited line keeps its identity. Bulk replacement remains conservative.
const unmatchedOld = previous.flatMap((_line, index) => !used.has(index) && draft.lineIds[index] ? [index] : []);
const unmatchedNext = next.flatMap((_line, index) => !lineIds[index] && nextLabels[index] ? [index] : []);
if (unmatchedOld.length === 1 && unmatchedNext.length === 1) {
lineIds[unmatchedNext[0]] = draft.lineIds[unmatchedOld[0]];
}
return { ...draft, text, lineIds };
};
export const projectMindMapOutline = (
source: DiagramDocument,
draft: MindMapOutlineDraft,
createId: () => string,
): OutlineResult => {
if (source.kind !== "mind-map") return { ok: false, error: { line: 1, reason: "root" } };
const oldNodes = new Map(source.nodes.map((node) => [node.id, node]));
const lines = draft.text.replaceAll("\r\n", "\n").split("\n");
const nodes: DiagramNode[] = [];
const stack: Array<string | null> = [];
const lineIds = [...draft.lineIds];
const assignedIds = new Set<string>();
let previousDepth = -1;
let rootStyle: "heading" | "list" | "plain" | null = null;
let entriesSeen = 0;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (!line.trim()) continue;
const indent = line.match(/^[\t ]*/)?.[0] ?? "";
const spaceCount = (indent.match(/ /g) ?? []).length;
if (spaceCount % 2 !== 0) return { ok: false, error: { line: index + 1, reason: "indent" } };
const indentDepth = (indent.match(/\t/g) ?? []).length + spaceCount / 2;
const content = line.slice(indent.length);
const isHeading = headingMarker.test(content);
const isListItem = listMarker.test(content);
if (rootStyle === null) rootStyle = isHeading ? "heading" : isListItem ? "list" : "plain";
const depth = entriesSeen === 0 ? indentDepth
: isHeading ? 0
: isListItem && rootStyle === "heading" ? indentDepth + 1
: isListItem && rootStyle === "plain" && indentDepth === 0 ? 1
: !isListItem && rootStyle === "heading" ? indentDepth + 1
: indentDepth;
if (entriesSeen === 0 && depth !== 0) return { ok: false, error: { line: index + 1, reason: "root" } };
if (nodes.length > 0 && depth === 0) return { ok: false, error: { line: index + 1, reason: "root" } };
if (depth > previousDepth + 1) return { ok: false, error: { line: index + 1, reason: "depth" } };
const raw = content.replace(isHeading ? headingMarker : listMarker, "");
const label = unescapeLabel(raw);
entriesSeen += 1;
previousDepth = depth;
if (!label.trim()) {
stack[depth] = null;
stack.length = depth + 1;
continue;
}
if (label.length > 500) return { ok: false, error: { line: index + 1, reason: "label" } };
let parentId: string | undefined;
for (let parentDepth = depth - 1; parentDepth >= 0; parentDepth -= 1) {
if (stack[parentDepth]) { parentId = stack[parentDepth] ?? undefined; break; }
}
if (depth > 0 && !parentId) return { ok: false, error: { line: index + 1, reason: "empty" } };
const originalId = lineIds[index];
const id = originalId && oldNodes.has(originalId) && !assignedIds.has(originalId) ? originalId : createId();
lineIds[index] = id;
assignedIds.add(id);
const oldNode = oldNodes.get(id);
nodes.push({
...(oldNode ?? { id, x: 72, y: 64 + nodes.length * 80, width: 96, height: 36, shape: "topic" as const }),
label,
...(parentId ? { parentId } : {}),
...(!parentId && oldNode?.parentId ? { parentId: undefined } : {}),
});
stack[depth] = id;
stack.length = depth + 1;
}
if (nodes.length === 0) return { ok: false, error: { line: 1, reason: "empty" } };
const nextIds = new Set(nodes.map((node) => node.id));
const hierarchyEdgeIds = new Set(source.edges.filter((edge) => {
const oldNode = oldNodes.get(edge.target);
return oldNode?.parentId === edge.source;
}).map((edge) => edge.id));
const edges = source.edges.filter((edge) =>
!hierarchyEdgeIds.has(edge.id) && nextIds.has(edge.source) && nextIds.has(edge.target));
for (const node of nodes) {
if (!node.parentId) continue;
const oldNode = oldNodes.get(node.id);
const previousEdge = source.edges.find((edge) => edge.target === node.id && edge.source === oldNode?.parentId);
edges.push(previousEdge
? { ...previousEdge, source: node.parentId }
: { id: createId(), source: node.parentId, target: node.id });
}
const originalOutline = formatMindMapOutline(source);
const structureChanged = !originalOutline
|| nodes.length !== source.nodes.length
|| nodes.some((node, index) => node.parentId !== oldNodes.get(node.id)?.parentId || node.id !== originalOutline.lineIds[index]);
return {
ok: true,
document: { ...source, nodes, edges },
draft: { text: draft.text, lineIds, lastValidText: draft.text, lastValidLineIds: [...lineIds] },
structureChanged,
};
};
+2
View File
@@ -8,6 +8,8 @@ EdgeEver has two graphic-note paths: mind maps, flowcharts, and architecture dia
Web and desktop edit with AntV X6. Android, iOS, and public shares render the same IR read-only. A Base64URL-encoded IR lives in the persisted Markdown's `edgeever-diagram-v1` comment. The Mermaid diagram in the body supports reading outside EdgeEver and fallback when IR parsing fails; it is not the editing canvas. Mermaid code blocks in ordinary notes are a separate feature.
Web and desktop mind maps can switch between the canvas and a Markdown outline. The root topic is an `#` heading, with nested `-` list items for branches; each list level uses two spaces. Enter continues the list, Tab and Shift+Tab change hierarchy, and an existing indented outline can still be pasted. Empty list items are omitted from the diagram; nonempty descendants attach to the nearest nonempty ancestor. Both views edit the same `DiagramDocument` rather than the Mermaid fallback. The outline requires one nonempty root topic. Invalid hierarchy does not update the diagram, and removing many topics requires confirmation. Existing node and edge identities and metadata survive round trips; hierarchy changes trigger a relayout.
Parsing and validation live in [`diagram.ts`](../packages/shared/src/diagram.ts), semantic compilation and layout in [`diagram-layout.ts`](../packages/shared/src/diagram-layout.ts), and cross-platform read-only rendering in [`diagram-view.ts`](../packages/shared/src/diagram-view.ts). Native apps remain read-only until they can safely edit the same IR.
## Infographics
+2
View File
@@ -8,6 +8,8 @@ EdgeEver 有两条图形笔记路径:思维导图、流程图和架构图使
Web 和桌面端用 AntV X6 编辑;Android、iOS 和公开分享用同一份 IR 只读绘制。持久化 Markdown 的 `edgeever-diagram-v1` 注释保存 Base64URL 编码的 IR,正文中的 Mermaid 图表用于外部阅读及 IR 解析失败时降级,不是编辑画布。普通笔记内的 Mermaid 代码块属于独立功能。
Web 和桌面端的思维导图可在画布与 Markdown 大纲间切换。根主题使用 `#` 标题,分支使用嵌套的 `-` 列表,每级缩进两个空格。Enter 续写列表,Tab / Shift+Tab 调整层级,也兼容粘贴已有的纯缩进大纲。空列表项不会成为导图节点,非空子项会接到最近的非空上级;两种视图编辑同一份 `DiagramDocument`,不会把 Mermaid 正文当成可编辑源文件。大纲需要一个非空根主题;无效层级不会写回导图,一次删除大量主题须确认。已有节点和连线在往返切换时保留身份及元数据,层级变化会重新排版。
解析与校验见 [`diagram.ts`](../packages/shared/src/diagram.ts),语义图编译及布局见 [`diagram-layout.ts`](../packages/shared/src/diagram-layout.ts),跨端只读绘制见 [`diagram-view.ts`](../packages/shared/src/diagram-view.ts)。原生 App 在能安全读写同一 IR 前保持只读。
## 信息图
+12
View File
@@ -412,6 +412,18 @@ export const enUS = {
back: "Back to note list",
title: "Diagram title",
canvas: "{{type}} canvas",
canvasView: "Canvas view",
outlineView: "Outline view",
outlineUnavailable: "Outline view requires exactly one root topic",
outlineHint: "Use a # heading with text for the root and nested - list items for branches, with two spaces per level. Empty list items are hidden. Enter continues the list; Tab and Shift+Tab change levels.",
outlineErrorEmpty: "Keep at least one root topic.",
outlineErrorIndent: "Invalid indentation on line {{line}}. Use tabs or two spaces per level.",
outlineErrorRoot: "Invalid hierarchy on line {{line}}. An outline needs exactly one root topic.",
outlineErrorDepth: "Line {{line}} skips a level. Indent only one level deeper than the previous topic.",
outlineErrorLabel: "The topic on line {{line}} must contain 1 to 500 characters.",
outlineRemovalWarning: "This edit removes {{count}} existing topics. The map will not be saved until you confirm.",
outlineApplyRemoval: "Remove topics and apply",
outlineRestore: "Restore map content",
addTopic: "Add child topic",
addSiblingTopic: "Add sibling topic",
mindMapShortcuts: "Tab adds a child; Enter adds a sibling",
+12
View File
@@ -412,6 +412,18 @@ export const ja = {
back: "ノート一覧に戻る",
title: "図のタイトル",
canvas: "{{type}}キャンバス",
canvasView: "キャンバス表示",
outlineView: "アウトライン表示",
outlineUnavailable: "アウトライン表示にはルートトピックが1つ必要です",
outlineHint: "中心トピックは文字のある # 見出し、分岐は - リストで記述し、階層ごとに2スペース字下げします。空の項目は表示しません。Enterで続け、Tab / Shift+Tabで階層を変更します。",
outlineErrorEmpty: "ルートトピックを1つ以上残してください。",
outlineErrorIndent: "{{line}}行目のインデントが無効です。Tabまたは階層ごとに半角スペース2つを使用してください。",
outlineErrorRoot: "{{line}}行目の階層が無効です。ルートトピックは1つだけにしてください。",
outlineErrorDepth: "{{line}}行目で階層が飛んでいます。前のトピックより深くできるのは1階層までです。",
outlineErrorLabel: "{{line}}行目のトピックは1〜500文字で入力してください。",
outlineRemovalWarning: "この編集では既存のトピックが{{count}}件削除されます。確認するまでマップには保存されません。",
outlineApplyRemoval: "削除して適用",
outlineRestore: "マップの内容を復元",
addTopic: "子トピックを追加",
addSiblingTopic: "兄弟トピックを追加",
mindMapShortcuts: "Tab で子を追加、Enter で兄弟を追加",
+12
View File
@@ -412,6 +412,18 @@ export const pl = {
back: "Wróć do listy notatek",
title: "Tytuł diagramu",
canvas: "Kanwa: {{type}}",
canvasView: "Widok kanwy",
outlineView: "Widok konspektu",
outlineUnavailable: "Widok konspektu wymaga dokładnie jednego tematu głównego",
outlineHint: "Niepusty temat główny zapisz jako nagłówek #, a gałęzie jako zagnieżdżone listy -. Każdy poziom ma dwa odstępy; puste pozycje są pomijane. Enter dodaje element, Tab / Shift+Tab zmienia poziom.",
outlineErrorEmpty: "Zachowaj co najmniej jeden temat główny.",
outlineErrorIndent: "Nieprawidłowe wcięcie w wierszu {{line}}. Użyj tabulatora lub dwóch spacji na poziom.",
outlineErrorRoot: "Nieprawidłowa hierarchia w wierszu {{line}}. Konspekt wymaga dokładnie jednego tematu głównego.",
outlineErrorDepth: "Wiersz {{line}} pomija poziom. Wcięcie może być głębsze tylko o jeden poziom.",
outlineErrorLabel: "Temat w wierszu {{line}} musi mieć od 1 do 500 znaków.",
outlineRemovalWarning: "Ta zmiana usunie {{count}} istniejących tematów. Mapa nie zostanie zapisana bez potwierdzenia.",
outlineApplyRemoval: "Usuń tematy i zastosuj",
outlineRestore: "Przywróć zawartość mapy",
addTopic: "Dodaj temat podrzędny",
addSiblingTopic: "Dodaj temat równorzędny",
mindMapShortcuts: "Tab dodaje element podrzędny; Enter dodaje element równorzędny",
+12
View File
@@ -412,6 +412,18 @@ export const zhCN = {
back: "返回笔记列表",
title: "图表标题",
canvas: "{{type}}画布",
canvasView: "画布视图",
outlineView: "大纲视图",
outlineUnavailable: "此导图需要且只能有一个根主题,才能使用大纲视图",
outlineHint: "用 # 标题写非空中心主题,用 - 列表写分支,每级缩进两个空格;空列表项不会显示。Enter 续写,Tab / Shift+Tab 调整层级。",
outlineErrorEmpty: "请至少保留一个根主题。",
outlineErrorIndent: "第 {{line}} 行缩进无效:请使用 Tab 或每级两个空格。",
outlineErrorRoot: "第 {{line}} 行层级无效:大纲需要且只能有一个顶层主题。",
outlineErrorDepth: "第 {{line}} 行层级跳跃:只能比上一主题深一级。",
outlineErrorLabel: "第 {{line}} 行主题不能为空,且不能超过 500 个字符。",
outlineRemovalWarning: "这次编辑将删除 {{count}} 个原有主题。确认前不会保存到导图。",
outlineApplyRemoval: "确认删除并应用",
outlineRestore: "恢复导图内容",
addTopic: "添加子主题",
addSiblingTopic: "添加同级主题",
mindMapShortcuts: "Tab 添加子主题,Enter 添加同级主题",