dsh-mindmap 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -35,18 +35,185 @@ window.__ModuleLoader__.load({
35
35
 
36
36
  const EMPTY_NODES = [];
37
37
 
38
- // 015 节点颜色主题(三风格:海洋蓝 / 落日橙 / 森林绿)。
39
- // 根盒用主题色淡底 + 半透明描边;标题节点文字用主题主色;背景永远跟随全局。
38
+ //#region 皮肤层:令牌注册表 + 回退链 + 节点样式解析(002 规范 §7/§5)
39
+ /**
40
+ * 令牌注册表(002 §7.2):只登记实际被消费的令牌。
41
+ * default 一律给安全值(多数跟随宿主 --dsw-alias-* 变量,面板亮暗自动跟随);
42
+ * fallback 为可选回退令牌名(回退链:专用 → 通用 → 默认)。
43
+ */
44
+ const TOKEN_REGISTRY = {
45
+ "color.surface.default": { default: "var(--dsw-alias-bg-layer-3)" },
46
+ "color.surface.root": { default: "var(--dsw-alias-bg-module-platform, #eef2ff)", fallback: "color.surface.default" },
47
+ "color.surface.code": { default: "var(--dsw-alias-fill-tsp-secondary)", fallback: "color.surface.default" },
48
+ "color.surface.quote": { default: "var(--dsw-alias-bg-layer-3)", fallback: "color.surface.default" },
49
+ "color.surface.table": { default: "var(--dsw-alias-bg-layer-3)", fallback: "color.surface.default" },
50
+ "color.border.default": { default: "var(--dsw-alias-border-l2)" },
51
+ "color.border.strong": { default: "var(--dsw-alias-border-l2-darkmode-thin, #b9c0cc)", fallback: "color.border.default" },
52
+ "color.border.subtle": { default: "var(--dsw-alias-border-l2)", fallback: "color.border.default" },
53
+ "color.border.root": { default: "var(--dsw-alias-border-l2-darkmode-thin, #b9c0cc)", fallback: "color.border.strong" },
54
+ "color.text.primary": { default: "var(--dsw-alias-label-primary)" },
55
+ "color.text.muted": { default: "var(--dsw-alias-label-tertiary)" },
56
+ // 强调色族:默认值 = 海洋蓝(默认主题),各主题以覆写表换肤。
57
+ "color.accent.root": { default: "#3b5bdb" },
58
+ "color.accent.heading.strong": { default: "#3b5bdb", fallback: "color.accent.root" },
59
+ "color.accent.heading.medium": { default: "#5c7cfa", fallback: "color.accent.heading.strong" },
60
+ "color.accent.heading.subtle": { default: "#91a7ff", fallback: "color.accent.heading.medium" },
61
+ "color.accent.code": { default: "#3b5bdb", fallback: "color.accent.root" },
62
+ "color.accent.quote": { default: "#5c7cfa", fallback: "color.accent.heading.medium" },
63
+ "color.state.selected": { default: "var(--dsw-alias-state-business-primary)" },
64
+ "color.state.hovered": { default: "var(--dsw-alias-interactive-bg-hover)" },
65
+ "connector.color": { default: "var(--dsw-alias-border-l2)", fallback: "color.border.default" },
66
+ "connector.width": { default: 1.5 },
67
+ "shape.radius.node": { default: 10 },
68
+ "effect.shadow.default": { default: "0 1px 2px rgba(16,24,40,0.04)" },
69
+ "effect.shadow.hovered": { default: "0 4px 12px rgba(16,24,40,0.10)", fallback: "effect.shadow.default" },
70
+ };
71
+
72
+ /**
73
+ * 颜色主题 = 令牌覆写表(002 §7.1):三主题只覆写强调色族与根盒表面/描边,
74
+ * 其余令牌走注册表默认值。持久化格式(设置里的名字)不变。
75
+ */
40
76
  const COLOR_THEMES = {
41
- ocean: { rootBg: "rgba(59,91,219,0.10)", rootBorder: "rgba(59,91,219,0.45)", heading: "#3b5bdb" },
42
- sunset: { rootBg: "rgba(232,110,52,0.10)", rootBorder: "rgba(232,110,52,0.45)", heading: "#d96b2a" },
43
- forest: { rootBg: "rgba(42,157,104,0.10)", rootBorder: "rgba(42,157,104,0.45)", heading: "#2a9d68" },
77
+ ocean: {
78
+ "color.accent.root": "#3b5bdb",
79
+ "color.accent.heading.strong": "#3b5bdb",
80
+ "color.accent.heading.medium": "#5c7cfa",
81
+ "color.accent.heading.subtle": "#91a7ff",
82
+ "color.accent.code": "#3b5bdb",
83
+ "color.accent.quote": "#5c7cfa",
84
+ "color.surface.root": "rgba(59,91,219,0.10)",
85
+ "color.border.root": "rgba(59,91,219,0.45)",
86
+ },
87
+ sunset: {
88
+ "color.accent.root": "#d96b2a",
89
+ "color.accent.heading.strong": "#d96b2a",
90
+ "color.accent.heading.medium": "#e8834a",
91
+ "color.accent.heading.subtle": "#f2a26d",
92
+ "color.accent.code": "#d96b2a",
93
+ "color.accent.quote": "#e8834a",
94
+ "color.surface.root": "rgba(232,110,52,0.10)",
95
+ "color.border.root": "rgba(232,110,52,0.45)",
96
+ },
97
+ forest: {
98
+ "color.accent.root": "#2a9d68",
99
+ "color.accent.heading.strong": "#2a9d68",
100
+ "color.accent.heading.medium": "#3db57f",
101
+ "color.accent.heading.subtle": "#6fcf9f",
102
+ "color.accent.code": "#2a9d68",
103
+ "color.accent.quote": "#3db57f",
104
+ "color.surface.root": "rgba(42,157,104,0.10)",
105
+ "color.border.root": "rgba(42,157,104,0.45)",
106
+ },
44
107
  };
45
- /** 颜色主题名 → 色值令牌(未知名回落海洋蓝)。 */
46
- function colorThemeTokens(name) {
47
- return COLOR_THEMES[name] || COLOR_THEMES.ocean;
108
+
109
+ /**
110
+ * 令牌解析(002 §7.4):先沿回退链逐跳找主题覆写(全链优先),
111
+ * 命中即返;全链无覆写再取登记默认值(同样沿链找第一个可用默认)。
112
+ * 这样主题只覆写上级令牌时下级自动跟随(如只覆写 heading.strong
113
+ * 时 medium/subtle 也随之换色)。未登记令牌返回 null(纯函数)。
114
+ */
115
+ function resolveToken(name, overrides) {
116
+ let current = name;
117
+ for (let hop = 0; current && hop < 8; hop++) {
118
+ const entry = TOKEN_REGISTRY[current];
119
+ if (!entry) break;
120
+ if (overrides && Object.prototype.hasOwnProperty.call(overrides, current) && overrides[current] != null) {
121
+ return overrides[current];
122
+ }
123
+ current = entry.fallback;
124
+ }
125
+ current = name;
126
+ for (let hop = 0; current && hop < 8; hop++) {
127
+ const entry = TOKEN_REGISTRY[current];
128
+ if (!entry) return null;
129
+ if (entry.default != null) return entry.default;
130
+ current = entry.fallback;
131
+ }
132
+ return null;
48
133
  }
49
134
 
135
+ /**
136
+ * 节点样式解析(002 §5 语义配方 + §6 状态):纯函数,同输入同输出。
137
+ * 输入 = 节点语义身份(kind / 标题级别)+ 交互状态 + 主题覆写表;
138
+ * 输出 = 可直接铺进节点盒 style 的外观属性(骨架属性不在其中)。
139
+ */
140
+ function resolveNodeStyle(node, options) {
141
+ const opts = options || {};
142
+ const overrides = COLOR_THEMES[opts.colorTheme] || COLOR_THEMES.ocean;
143
+ const kind = node && node.kind;
144
+ const level = node && node.data && node.data.level;
145
+ const states = opts.states || {};
146
+ const radius = opts.cardStyle === "square" ? 0 : resolveToken("shape.radius.node", overrides);
147
+ const style = {
148
+ borderRadius: radius,
149
+ background: resolveToken("color.surface.default", overrides),
150
+ border: `1px solid ${resolveToken("color.border.default", overrides)}`,
151
+ color: resolveToken("color.text.primary", overrides),
152
+ boxShadow: resolveToken("effect.shadow.default", overrides),
153
+ };
154
+ if (kind === "root") {
155
+ style.background = resolveToken("color.surface.root", overrides);
156
+ style.border = `1px solid ${resolveToken("color.border.root", overrides)}`;
157
+ style.color = resolveToken("color.accent.root", overrides);
158
+ style.fontWeight = 700;
159
+ style.fontSize = "14px";
160
+ } else if (kind === "heading") {
161
+ // §5.2:H1-H2 强 / H3-H4 中 / H5-H6 弱。
162
+ const tier = level <= 2 ? "strong" : level <= 4 ? "medium" : "subtle";
163
+ style.color = resolveToken(`color.accent.heading.${tier}`, overrides);
164
+ style.fontWeight = 600;
165
+ } else if (kind === "code") {
166
+ style.background = resolveToken("color.surface.code", overrides);
167
+ style.fontFamily = "Menlo, monospace";
168
+ style.fontSize = "12px";
169
+ } else if (kind === "quote") {
170
+ style.background = resolveToken("color.surface.quote", overrides);
171
+ style.border = `1px solid ${resolveToken("color.border.subtle", overrides)}`;
172
+ style.borderLeft = `3px solid ${resolveToken("color.accent.quote", overrides)}`;
173
+ } else if (kind === "table") {
174
+ style.background = resolveToken("color.surface.table", overrides);
175
+ style.border = `1px solid ${resolveToken("color.border.subtle", overrides)}`;
176
+ } else if (kind === "placeholder") {
177
+ style.background = "none";
178
+ style.border = `1px dashed ${resolveToken("color.border.default", overrides)}`;
179
+ style.color = resolveToken("color.text.muted", overrides);
180
+ style.boxShadow = "none";
181
+ }
182
+ // §6 状态叠加:hovered 抬升阴影;selected 强调环优先(两者并存时环在外)。
183
+ if (states.hovered && kind !== "placeholder") {
184
+ style.boxShadow = resolveToken("effect.shadow.hovered", overrides);
185
+ }
186
+ if (states.selected) {
187
+ const ring = resolveToken("color.state.selected", overrides);
188
+ style.boxShadow = `0 0 0 2px ${ring}${style.boxShadow && style.boxShadow !== "none" ? `, ${style.boxShadow}` : ""}`;
189
+ }
190
+ return style;
191
+ }
192
+
193
+ /**
194
+ * 导出静态亮色快照:导出 SVG 走 data-URL,宿主 CSS 变量在那里不可解析,
195
+ * 只能带静态十六进制色。按主题名取强调色族(未知名回落海洋蓝)。
196
+ */
197
+ function exportPalette(name) {
198
+ const theme = COLOR_THEMES[name] || COLOR_THEMES.ocean;
199
+ return {
200
+ rootBg: "#eef2ff",
201
+ rootBorder: theme["color.accent.root"] || "#7c8cf8",
202
+ rootText: theme["color.accent.heading.strong"] || "#2f3ab2",
203
+ heading: theme["color.accent.heading.strong"] || "#3b5bdb",
204
+ quote: theme["color.accent.quote"] || "#5c7cfa",
205
+ surface: "#f6f7f9",
206
+ surfaceCode: "#f5f2ea",
207
+ border: "#d4d9e0",
208
+ borderSubtle: "#e2e6eb",
209
+ connector: "#c8cdd6",
210
+ text: "#1f2430",
211
+ muted: "#9aa2b1",
212
+ canvasBg: "#ffffff",
213
+ };
214
+ }
215
+ //#endregion
216
+
50
217
  // 015 设置变更总线:设置面板保存成功后 bump;脑图面板订阅 stamp 重读主题。
51
218
  // 面板组件常驻不卸载,open 不变时不会自行重读——靠总线驱动
52
219
  // (闭包实现,不依赖 this)。
@@ -102,12 +269,59 @@ window.__ModuleLoader__.load({
102
269
  for (const ch of raw) width += ch === "\t" ? 4 : 1;
103
270
  return width;
104
271
  }
272
+
273
+ //#region 019 块概念(骨架/血肉/皮肤 的血肉层,规范源:MarkGrove/_arch/003)
274
+ /**
275
+ * 行内格式检测(003 §3 拆分判据):粗体/斜体/删除线/行内代码/链接/图片
276
+ * 都是行内格式——永不拆子节点,只影响块内渲染;含任一即判为 md 块,
277
+ * 否则是 text 块。
278
+ */
279
+ function hasInlineFormat(text) {
280
+ return /(\*\*[^*]+\*\*)|(\*[^*\s][^*]*\*)|(~~[^~]+~~)|(`[^`]+`)|(!?\[[^\]]*\]\([^)]*\))/.test(String(text ?? ""));
281
+ }
282
+
283
+ /** 表格分隔行:由 | - : 空白组成且至少含一个 -。 */
284
+ function isTableSeparator(line) {
285
+ const t = String(line ?? "").trim();
286
+ return /^[|:\s-]+$/.test(t) && t.includes("-");
287
+ }
288
+
289
+ /** 表格行 → 单元格数组(去首尾空段,保留中间空单元格)。
290
+ * 支持 GFM 转义:`\|` 是字面竖线(占位符避位,剥标记后还原),不切单元格。 */
291
+ function parseTableRow(line) {
292
+ return String(line ?? "").trim()
293
+ .replace(/^\|/, "").replace(/\|$/, "")
294
+ .replace(/\\\|/g, "\u0000")
295
+ .split("|")
296
+ .map((c) => c.replace(/\u0000/g, "|").trim());
297
+ }
105
298
 
299
+ /**
300
+ * 020 复制全文(右键菜单):单节点自身内容的完整文本。代码块取围栏全文;
301
+ * 表格块按 Markdown 源码形态输出完整网格;其余块取原文(data.raw 优先,
302
+ * 引用块 raw = 整块引用源码;无 raw 时回退 topic)。
303
+ */
304
+ function nodeFullText(node) {
305
+ if (!node) return "";
306
+ const data = node.data || {};
307
+ if (node.kind === "code") return data.code || node.topic || "";
308
+ if (node.kind === "table" && Array.isArray(data.rows) && data.rows.length > 0) {
309
+ // data.rows 不含分隔行(解析时剔除);复制时补回,粘回 Markdown 仍是合法表格。
310
+ const lines = data.rows.map((row) => `| ${row.join(" | ")} |`);
311
+ if (data.rows.length > 1) lines.splice(1, 0, `| ${data.rows[0].map(() => "---").join(" | ")} |`);
312
+ return lines.join("\n");
313
+ }
314
+ return data.raw || node.topic || "";
315
+ }
316
+ //#endregion
317
+
106
318
  /**
107
319
  * markdown → 脑图树。根节点 topic = 文档名(rootTitle,由调用方从文件路径
108
320
  * 推导——001 决策 2:根节点标题 = markdown 文档名)。
109
- * 节点 kind:root / heading / list / placeholder / code。
110
- * 段落不成为节点,附到最近的标题(或根)的 data.description。
321
+ * 节点 kind:root / heading / list / placeholder / code / text / md / quote / table
322
+ * 019 块概念:段落升格为节点(含行内格式 → md 块,否则 text 块,原文存
323
+ * data.raw);引用块聚合为 quote 节点(首段提升为自身内容,其余成子节点);
324
+ * 连续 | 行解析为 table 节点(data.rows 全量保留,单元格不拆)。
111
325
  */
112
326
  function parseMarkdownToTree(markdown, rootTitle) {
113
327
  const idOf = createIdFactory();
@@ -118,139 +332,211 @@ window.__ModuleLoader__.load({
118
332
  children: [],
119
333
  data: {},
120
334
  };
121
- const headingStack = [];
122
- let listStack = [];
123
335
  // 根标题回声标记:记录文档常以文件名作首行 H1(如 "# 002-spike结论.md"),
124
336
  // 而根节点标题就是文件名——首个 H1 与根标题一致(或仅多 .md 后缀)时
125
- // 并入根节点,避免标题显示两次。
337
+ // 并入根节点,避免标题显示两次。仅文档顶层参与回声(引用内不算)。
126
338
  let firstHeadingSeen = false;
127
339
  const lines = String(markdown ?? "").split(/\r?\n/);
128
-
129
- const parentRec = () => (headingStack.length ? headingStack[headingStack.length - 1] : null);
130
- const parentPathOf = () => {
131
- if (listStack.length) return listStack[listStack.length - 1].path;
132
- const h = parentRec();
133
- return h ? h.path : "";
134
- };
135
- const parentNode = () => {
136
- if (listStack.length) return listStack[listStack.length - 1].node;
137
- const h = parentRec();
138
- return h ? h.node : root;
139
- };
140
- const appendDescription = (node, text) => {
141
- node.data.description = node.data.description ? `${node.data.description}\n${text}` : text;
142
- };
143
-
144
- let i = 0;
340
+
341
+ let start = 0;
145
342
  // 跳过 YAML frontmatter(--- ... ---)
146
343
  if (lines.length > 0 && /^\s*---\s*$/.test(lines[0])) {
147
- for (i = 1; i < lines.length; i++) {
148
- if (/^\s*---\s*$/.test(lines[i])) {
149
- i += 1;
344
+ for (start = 1; start < lines.length; start++) {
345
+ if (/^\s*---\s*$/.test(lines[start])) {
346
+ start += 1;
150
347
  break;
151
348
  }
152
349
  }
153
350
  }
154
-
155
- let paraBuffer = [];
156
- const flushParagraph = () => {
157
- if (paraBuffer.length === 0) return;
158
- appendDescription(parentNode(), paraBuffer.join(" "));
159
- paraBuffer = [];
160
- };
161
-
162
- for (; i < lines.length; i++) {
163
- const line = lines[i];
164
-
165
- // 围栏代码块:整块成为一个叶节点,标题 = [语言] 首行摘要。
166
- if (/^\s*(```|~~~)/.test(line)) {
167
- flushParagraph();
168
- listStack = [];
169
- const lang = line.trim().slice(3).trim();
170
- const buf = [];
171
- for (i += 1; i < lines.length && !/^\s*(```|~~~)/.test(lines[i]); i++) buf.push(lines[i]);
172
- const code = buf.join("\n");
173
- const firstLine = (code.split("\n")[0] || "").trim();
174
- const summary = firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine;
175
- const node = {
176
- id: idOf("code", code, parentPathOf()),
177
- kind: "code",
178
- topic: `[${lang || "code"}] ${summary}`,
351
+
352
+ /**
353
+ * 块级解析循环(标题/列表/代码/引用/表格/段落)。引用块内容递归走本函数,
354
+ * echoRoot=false 时不参与根标题回声。节点挂入 container(顶层 = root.children)。
355
+ */
356
+ function parseBlockLines(container, lineList, echoRoot) {
357
+ const headingStack = [];
358
+ let listStack = [];
359
+ const parentRec = () => (headingStack.length ? headingStack[headingStack.length - 1] : null);
360
+ const parentPathOf = () => {
361
+ if (listStack.length) return listStack[listStack.length - 1].path;
362
+ const h = parentRec();
363
+ return h ? h.path : "";
364
+ };
365
+ const parentNode = () => {
366
+ if (listStack.length) return listStack[listStack.length - 1].node;
367
+ const h = parentRec();
368
+ return h ? h.node : null;
369
+ };
370
+ const appendNode = (node) => {
371
+ const p = parentNode();
372
+ (p ? p.children : container).push(node);
373
+ };
374
+
375
+ let paraBuffer = [];
376
+ // 019 段落升格:段落不再塞 description,自己成为 text/md 块节点。
377
+ const flushParagraph = () => {
378
+ if (paraBuffer.length === 0) return;
379
+ const text = paraBuffer.join(" ");
380
+ paraBuffer = [];
381
+ const kind = hasInlineFormat(text) ? "md" : "text";
382
+ appendNode({
383
+ id: idOf(kind, text, parentPathOf()),
384
+ kind,
385
+ topic: text,
179
386
  children: [],
180
- data: { lang, code, firstLine: firstLine || undefined },
181
- };
182
- parentNode().children.push(node);
183
- continue;
184
- }
185
-
186
- // ATX 标题:按层级入栈挂树(H1 挂根、H2 挂前一个 H1……)。
187
- const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
188
- if (heading) {
189
- flushParagraph();
190
- listStack = [];
191
- const level = heading[1].length;
192
- const text = heading[2].trim() || "(无标题)";
193
- // 首个 H1 与根标题一致(或仅多 .md 后缀)→ 并入根节点,不另建节点。
194
- if (!firstHeadingSeen && level === 1 && (text === root.topic || text === `${root.topic}.md`)) {
387
+ data: { raw: text },
388
+ });
389
+ };
390
+
391
+ for (let i = 0; i < lineList.length; i++) {
392
+ const line = lineList[i];
393
+
394
+ // 围栏代码块:整块成为一个叶节点,标题 = [语言] 首行摘要(盒内紧凑,
395
+ // 悬停浮层看全文——003 §5.3;data.code 全量保存)。
396
+ if (/^\s*(```|~~~)/.test(line)) {
397
+ flushParagraph();
398
+ listStack = [];
399
+ const lang = line.trim().slice(3).trim();
400
+ const buf = [];
401
+ for (i += 1; i < lineList.length && !/^\s*(```|~~~)/.test(lineList[i]); i++) buf.push(lineList[i]);
402
+ const code = buf.join("\n");
403
+ const firstLine = (code.split("\n")[0] || "").trim();
404
+ const summary = firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine;
405
+ appendNode({
406
+ id: idOf("code", code, parentPathOf()),
407
+ kind: "code",
408
+ topic: `[${lang || "code"}] ${summary}`,
409
+ children: [],
410
+ data: { lang, code, firstLine: firstLine || undefined },
411
+ });
412
+ continue;
413
+ }
414
+
415
+ // ATX 标题:按层级入栈挂树(H1 挂根、H2 挂前一个 H1……)。
416
+ const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
417
+ if (heading) {
418
+ flushParagraph();
419
+ listStack = [];
420
+ const level = heading[1].length;
421
+ const text = heading[2].trim() || "(无标题)";
422
+ // 首个 H1 与根标题一致(或仅多 .md 后缀)→ 并入根节点,不另建节点。
423
+ if (echoRoot && !firstHeadingSeen && level === 1 && (text === root.topic || text === `${root.topic}.md`)) {
424
+ firstHeadingSeen = true;
425
+ continue;
426
+ }
195
427
  firstHeadingSeen = true;
428
+ while (headingStack.length > 0 && headingStack[headingStack.length - 1].level >= level) headingStack.pop();
429
+ const basePath = parentRec() ? parentRec().path : "";
430
+ const node = {
431
+ id: idOf("heading", text, basePath),
432
+ kind: "heading",
433
+ topic: text,
434
+ children: [],
435
+ data: { level },
436
+ };
437
+ appendNode(node);
438
+ headingStack.push({ level, node, path: `${basePath}/h${level}-${node.id}` });
196
439
  continue;
197
440
  }
198
- firstHeadingSeen = true;
199
- while (headingStack.length > 0 && headingStack[headingStack.length - 1].level >= level) headingStack.pop();
200
- const basePath = parentRec() ? parentRec().path : "";
201
- const node = {
202
- id: idOf("heading", text, basePath),
203
- kind: "heading",
204
- topic: text,
205
- children: [],
206
- data: { level },
207
- };
208
- parentNode().children.push(node);
209
- headingStack.push({ level, node, path: `${basePath}/h${level}-${node.id}` });
210
- continue;
211
- }
212
-
213
- // 水平分隔线:线性视觉脚手架,跳过。
214
- if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
215
- flushParagraph();
216
- continue;
217
- }
218
-
219
- // 列表项:缩进决定层级(约定每级 2 空格,tab=4);空项=占位节点。
220
- const listItem = /^(\s*)([-*+]|(\d+)[.)])\s+(.*)$/.exec(line);
221
- const listEmpty = /^(\s*)([-*+]|(\d+)[.)])\s*$/.exec(line);
222
- if (listItem || listEmpty) {
223
- flushParagraph();
224
- const m = listItem || listEmpty;
225
- const indent = indentWidth(m[1]);
226
- const text = listItem ? m[4].trim() : "";
227
- const ordered = m[3] !== undefined;
228
- while (listStack.length > 0 && listStack[listStack.length - 1].indent >= indent) listStack.pop();
229
- const topic = ordered && listItem ? `${m[3]}. ${text}` : text;
230
- const node = text === ""
231
- ? { id: idOf("list", "", parentPathOf()), kind: "placeholder", topic: "", children: [], data: {} }
232
- : { id: idOf("list", topic, parentPathOf()), kind: "list", topic, children: [], data: ordered ? { ordered: true } : {} };
233
- parentNode().children.push(node);
234
- listStack.push({ indent, node, path: `${parentPathOf()}/${node.id}` });
235
- continue;
236
- }
237
-
238
- // 引用块:v0 不映射,跳过。
239
- if (/^\s*>/.test(line)) {
240
- flushParagraph();
241
- continue;
242
- }
243
-
244
- if (!line.trim()) {
245
- flushParagraph();
246
- continue;
441
+
442
+ // 水平分隔线:线性视觉脚手架,跳过。
443
+ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
444
+ flushParagraph();
445
+ continue;
446
+ }
447
+
448
+ // 列表项:缩进决定层级(约定每级 2 空格,tab=4);空项=占位节点。
449
+ const listItem = /^(\s*)([-*+]|(\d+)[.)])\s+(.*)$/.exec(line);
450
+ const listEmpty = /^(\s*)([-*+]|(\d+)[.)])\s*$/.exec(line);
451
+ if (listItem || listEmpty) {
452
+ flushParagraph();
453
+ const m = listItem || listEmpty;
454
+ const indent = indentWidth(m[1]);
455
+ const text = listItem ? m[4].trim() : "";
456
+ const ordered = m[3] !== undefined;
457
+ while (listStack.length > 0 && listStack[listStack.length - 1].indent >= indent) listStack.pop();
458
+ const topic = ordered && listItem ? `${m[3]}. ${text}` : text;
459
+ const node = text === ""
460
+ ? { id: idOf("list", "", parentPathOf()), kind: "placeholder", topic: "", children: [], data: {} }
461
+ : { id: idOf("list", topic, parentPathOf()), kind: "list", topic, children: [], data: ordered ? { ordered: true } : {} };
462
+ appendNode(node);
463
+ listStack.push({ indent, node, path: `${parentPathOf()}/${node.id}` });
464
+ continue;
465
+ }
466
+
467
+ // 019 表格块:连续 | 行且第二行为分隔行 → table 节点(003 §5.5,
468
+ // data.rows 全量保留,单元格不拆子节点)。不满足分隔行条件的 | 行
469
+ // 按普通段落处理。
470
+ if (/^\s*\|/.test(line)) {
471
+ const rows = [];
472
+ let j = i;
473
+ for (; j < lineList.length && /^\s*\|/.test(lineList[j]); j++) rows.push(lineList[j]);
474
+ if (rows.length >= 2 && isTableSeparator(rows[1])) {
475
+ flushParagraph();
476
+ listStack = [];
477
+ i = j - 1;
478
+ const header = parseTableRow(rows[0]);
479
+ // GFM 对齐契约:列数钉死在分隔行(=表头)。少列补空、
480
+ // 多列截断——未转义竖线切碎的行顶多内容错位,网格永不参差。
481
+ const toCols = (cells) => {
482
+ if (cells.length >= header.length) return cells.slice(0, header.length);
483
+ return cells.concat(new Array(header.length - cells.length).fill(""));
484
+ };
485
+ const body = rows.slice(2).map((row) => toCols(parseTableRow(row)));
486
+ const tableRows = [header].concat(body);
487
+ appendNode({
488
+ id: idOf("table", tableRows.map((r) => r.join("\u0001")).join("\u0002"), parentPathOf()),
489
+ kind: "table",
490
+ topic: `${tableRows.length}×${header.length} 表格`,
491
+ children: [],
492
+ data: { rows: tableRows },
493
+ });
494
+ continue;
495
+ }
496
+ // 非表格:落入下方段落缓冲。
497
+ }
498
+
499
+ // 019 引用块:连续 > 行聚合为 quote 节点(003 §5.4)。去 > 前缀后
500
+ // 递归走同一套块规则;首个 text/md 段提升为自身内容(001 §3.1),
501
+ // 其余内容成为子节点。
502
+ if (/^\s*>/.test(line)) {
503
+ flushParagraph();
504
+ listStack = [];
505
+ const inner = [];
506
+ let j = i;
507
+ for (; j < lineList.length && /^\s*>/.test(lineList[j]); j++) inner.push(lineList[j].replace(/^\s*>\s?/, ""));
508
+ i = j - 1;
509
+ const innerNodes = [];
510
+ parseBlockLines(innerNodes, inner, false);
511
+ let topic = "";
512
+ const promoteIdx = innerNodes.findIndex((n) => n.kind === "text" || n.kind === "md");
513
+ if (promoteIdx >= 0) {
514
+ topic = innerNodes[promoteIdx].topic;
515
+ innerNodes.splice(promoteIdx, 1);
516
+ }
517
+ appendNode({
518
+ id: idOf("quote", topic || inner.join("\n"), parentPathOf()),
519
+ kind: "quote",
520
+ topic: topic || "(引用)",
521
+ children: innerNodes,
522
+ data: { raw: inner.join("\n") },
523
+ });
524
+ continue;
525
+ }
526
+
527
+ if (!line.trim()) {
528
+ flushParagraph();
529
+ continue;
530
+ }
531
+
532
+ paraBuffer.push(line.trim());
247
533
  }
248
-
249
- paraBuffer.push(line.trim());
534
+ flushParagraph();
250
535
  }
251
- flushParagraph();
252
-
253
- // 病理内容的兜底去重(MarkGrove 同款):重复 id 追加序号。
536
+
537
+ parseBlockLines(root.children, lines.slice(start), true);
538
+
539
+ // 病理内容的兕底去重(MarkGrove 同款):重复 id 追加序号。
254
540
  const seen = new Set();
255
541
  const dedupe = (node) => {
256
542
  if (seen.has(node.id)) {
@@ -266,6 +552,56 @@ window.__ModuleLoader__.load({
266
552
  }
267
553
  //#endregion
268
554
 
555
+ //#region 018 生长动画:树 id 收集 + 渐显计划(纯函数,经 internals 供测试)
556
+ /** 先序收集树的全部节点 id(含根)。 */
557
+ function collectTreeIds(tree) {
558
+ const ids = new Set();
559
+ const walk = (node) => {
560
+ if (!node) return;
561
+ ids.add(node.id);
562
+ for (const child of node.children || []) walk(child);
563
+ };
564
+ walk(tree);
565
+ return ids;
566
+ }
567
+
568
+ /**
569
+ * 生长动画计划:广度优先(根→叶)收集「不在 prevIds 中」的节点,逐个错峰渐显。
570
+ * prevIds = null(首屏/切文档)= 全部节点含根;无新增/变化节点返回 null。
571
+ * 错峰步长按节点数压缩(总时长 = 末节点延迟 + duration ≤ budget,大图不拖沓)。
572
+ * 返回 {nodes: Map(id→delayMs), edges: Map(父id→最早新子节点的 delay), totalMs}。
573
+ */
574
+ function planGrowthReveal(tree, prevIds, options) {
575
+ if (!tree) return null;
576
+ const { budgetMs = 2000, stepMs = 90, durationMs = 320 } = options || {};
577
+ const fresh = [];
578
+ const queue = [tree];
579
+ for (let i = 0; i < queue.length; i++) {
580
+ const node = queue[i];
581
+ if (!prevIds || !prevIds.has(node.id)) fresh.push(node);
582
+ for (const child of node.children || []) queue.push(child);
583
+ }
584
+ if (fresh.length === 0) return null;
585
+ const step = fresh.length > 1
586
+ ? Math.min(stepMs, Math.floor(Math.max(0, budgetMs - durationMs) / (fresh.length - 1)))
587
+ : 0;
588
+ const nodes = new Map();
589
+ const edges = new Map();
590
+ fresh.forEach((node, index) => {
591
+ nodes.set(node.id, index * step);
592
+ });
593
+ for (const node of queue) {
594
+ let earliest = null;
595
+ for (const child of node.children || []) {
596
+ const delay = nodes.get(child.id);
597
+ if (delay !== undefined && (earliest === null || delay < earliest)) earliest = delay;
598
+ }
599
+ if (earliest !== null) edges.set(node.id, earliest);
600
+ }
601
+ return { nodes, edges, totalMs: (fresh.length - 1) * step + durationMs };
602
+ }
603
+ //#endregion
604
+
269
605
  //#region 会话快照 → 文档集
270
606
  /** 取工具结果里 text 块拼接的文本。 */
271
607
  function resultTextOfBlocks(blocks) {
@@ -278,6 +614,52 @@ window.__ModuleLoader__.load({
278
614
  return base.replace(/\.md$/i, "");
279
615
  }
280
616
 
617
+ // 016 起 reduceDocuments 与 nodesFingerprint 共用(从函数体提升到模块层)。
618
+ const MAX_SUBCALL_DEPTH = 100;
619
+
620
+ /**
621
+ * 会话节点的结构指纹(016 可靠性加固):数组长度 + 逐节点结构身份
622
+ * (kind / callId / call.name / isError / subCalls 数,递归子树,深度
623
+ * 上限与 reduceDocuments 一致)。只含结构身份、不含 content 文本——
624
+ * 流式 token 增长不改变指纹,只有新工具结果节点出现才变。
625
+ * 用作 useSession 的第二 selector 返回值:字符串按值比较,天然绕过
626
+ * 「store 原地改数组、引用不变」的相等短路,驱动面板重算快照。
627
+ */
628
+ function nodesFingerprint(nodes) {
629
+ if (!Array.isArray(nodes)) return "[]";
630
+ const parts = [String(nodes.length)];
631
+ const visit = (node, depth) => {
632
+ if (!node || typeof node !== "object") {
633
+ parts.push("·");
634
+ return;
635
+ }
636
+ parts.push(
637
+ String(node.kind ?? ""),
638
+ String(node.callId ?? ""),
639
+ String(node.call?.name ?? ""),
640
+ node.isError ? "E" : "-",
641
+ );
642
+ const subCalls = Array.isArray(node.subCalls) ? node.subCalls : null;
643
+ parts.push(String(subCalls ? subCalls.length : 0));
644
+ if (subCalls && subCalls.length > 0 && depth < MAX_SUBCALL_DEPTH) {
645
+ for (const subCall of subCalls) visit(subCall, depth + 1);
646
+ }
647
+ };
648
+ for (const node of nodes) visit(node, 0);
649
+ return parts.join("|");
650
+ }
651
+
652
+ /** 工具错误消息提取(016):JSON 信封的 error/message 字段优先,回落原始文本。 */
653
+ function extractErrorMessage(parsed, text) {
654
+ if (parsed && typeof parsed === "object") {
655
+ if (typeof parsed.error?.message === "string" && parsed.error.message) return parsed.error.message;
656
+ if (typeof parsed.error === "string" && parsed.error) return parsed.error;
657
+ if (typeof parsed.message === "string" && parsed.message) return parsed.message;
658
+ }
659
+ const raw = String(text ?? "").trim();
660
+ return raw || "mindmap tool failed";
661
+ }
662
+
281
663
  /**
282
664
  * 重放会话快照里的 mindmap_* 工具结果,得到每个脑图文档的最新状态。
283
665
  * nodes: ConversationSnapshot.nodes(ToolResultNode 含 call.name 与渲染后的
@@ -291,32 +673,37 @@ window.__ModuleLoader__.load({
291
673
  let order = [];
292
674
  let latestOpeningPath = null;
293
675
  let latestOpeningEventKey = null;
676
+ // 016 错误捕获(S2 成因):isError / ok!==true 的 mindmap_* 结果不进
677
+ // 文档集(语义不变),但记录为错误信号——errorByPath(可归因路径的
678
+ // 最近错误)+ latestError(最近一次 mindmap 错误,含无路径归因的)。
679
+ const errorByPath = Object.create(null);
680
+ let latestError = null;
294
681
  const visited = new WeakSet();
295
- const MAX_SUBCALL_DEPTH = 100;
296
682
 
297
683
  function replayNode(node, eventPath, depth) {
298
684
  if (!node || typeof node !== "object" || visited.has(node)) return;
299
685
  visited.add(node);
300
686
 
301
- if (node.kind === "tool-result" && !node.isError) {
687
+ if (node.kind === "tool-result") {
302
688
  const name = node.call?.name;
303
689
  if (typeof name === "string" && TOOL_NAMES.has(name)) {
690
+ const text = resultTextOfBlocks(node.content);
304
691
  let parsed;
305
692
  try {
306
- parsed = JSON.parse(resultTextOfBlocks(node.content));
693
+ parsed = JSON.parse(text);
307
694
  } catch {
308
695
  parsed = null;
309
696
  }
310
- if (parsed && parsed.ok === true && typeof parsed.path === "string" && parsed.path) {
697
+ // callId 是实际调用的事件身份;eventPath 是无 callId 时按会话
698
+ // 遍历顺序生成的稳定兜底,避免重复 open 被合并成一个事件。
699
+ const callId = node.callId != null && String(node.callId)
700
+ ? node.callId
701
+ : node.call?.callId;
702
+ const eventKey = callId != null && String(callId)
703
+ ? `call:${String(callId)}`
704
+ : `node:${eventPath}`;
705
+ if (!node.isError && parsed && parsed.ok === true && typeof parsed.path === "string" && parsed.path) {
311
706
  const op = typeof parsed.op === "string" ? parsed.op : name;
312
- // callId 是实际调用的事件身份;eventPath 是无 callId 时按会话
313
- // 遍历顺序生成的稳定兜底,避免重复 open 被合并成一个事件。
314
- const callId = node.callId != null && String(node.callId)
315
- ? node.callId
316
- : node.call?.callId;
317
- const eventKey = callId != null && String(callId)
318
- ? `call:${String(callId)}`
319
- : `node:${eventPath}`;
320
707
  const renamedFrom = typeof parsed.renamedFrom === "string" ? parsed.renamedFrom : null;
321
708
  const previous = byPath[parsed.path];
322
709
  const renamedDocument = renamedFrom && renamedFrom !== parsed.path
@@ -356,6 +743,25 @@ window.__ModuleLoader__.load({
356
743
  // 013:rename 迁移后保留旧路径,供本地直读 tab 清理(mergeDocuments)。
357
744
  renamedFrom,
358
745
  };
746
+ // 016:成功结果清除同路径历史错误(含 rename 旧路径)。
747
+ delete errorByPath[parsed.path];
748
+ if (renamedFrom && renamedFrom !== parsed.path) delete errorByPath[renamedFrom];
749
+ } else if (node.isError || (parsed && typeof parsed === "object" && parsed.ok !== true)) {
750
+ // 016 错误捕获:host 工具抛错时结果通常是纯文本(无 JSON 信封),
751
+ // 有信封但 ok!==true 的同样收集。可归因路径的进 errorByPath;
752
+ // latestError 恒记录最近一次——无路径错误由面板用「点击时刻
753
+ // 基线」(errorEventKeys)归因到在途的打开请求。
754
+ const errPath = parsed && typeof parsed === "object" && typeof parsed.path === "string" && parsed.path
755
+ ? parsed.path
756
+ : null;
757
+ const entry = {
758
+ op: parsed && typeof parsed.op === "string" ? parsed.op : name,
759
+ message: extractErrorMessage(parsed, text),
760
+ callId,
761
+ eventKey,
762
+ };
763
+ if (errPath) errorByPath[errPath] = entry;
764
+ latestError = entry;
359
765
  }
360
766
  }
361
767
  }
@@ -371,33 +777,48 @@ window.__ModuleLoader__.load({
371
777
  for (const [nodeIndex, node] of (Array.isArray(nodes) ? nodes : []).entries()) {
372
778
  replayNode(node, String(nodeIndex), 0);
373
779
  }
374
- return { order, byPath, latestOpeningPath, latestOpeningEventKey };
780
+ return { order, byPath, latestOpeningPath, latestOpeningEventKey, errorByPath, latestError };
375
781
  }
376
782
 
377
783
  /**
378
784
  * 快照文档集(AI 工具结果)与本地直读文档集(read 路由即时打开)合并:
379
785
  * - 快照优先(同 path 覆盖本地占位);
380
786
  * - 本地文档追加在快照 order 之后;
381
- * - 快照里有 renamedFrom 指向某本地路径时,丢弃该本地条目(文件已改名)。
787
+ * - 快照里有 renamedFrom 指向某本地路径时,丢弃该本地条目(文件已改名);
788
+ * - 016 大小写 fallback(S5 成因):本地占位(op:"local")与快照文档仅
789
+ * 大小写不一致时(macOS 大小写不敏感 FS 上,AI 回传的规范 path 与树
790
+ * 点击 key 不同),丢弃占位键、保留快照规范 path——加载态随之解除,
791
+ * 既有 auto-open / 焦点同步机制照常接管。
792
+ * 错误信号(errorByPath / latestError)原样透传,容缺(旧快照无此字段)。
382
793
  */
383
794
  function mergeDocuments(snapshot, localDocs) {
384
- const byPath = { ...localDocs, ...snapshot.byPath };
795
+ const snapByPath = (snapshot && snapshot.byPath) || {};
796
+ const byPath = { ...localDocs, ...snapByPath };
385
797
  const dropped = new Set();
386
- for (const doc of Object.values(snapshot.byPath)) {
798
+ for (const doc of Object.values(snapByPath)) {
387
799
  if (typeof doc.renamedFrom === "string" && doc.renamedFrom && localDocs[doc.renamedFrom]) {
388
800
  dropped.add(doc.renamedFrom);
389
801
  }
390
802
  }
803
+ const snapPaths = Object.keys(snapByPath);
804
+ for (const p of Object.keys(localDocs)) {
805
+ if (snapByPath[p] || dropped.has(p)) continue;
806
+ if (!localDocs[p] || localDocs[p].op !== "local") continue;
807
+ const lower = p.toLowerCase();
808
+ if (snapPaths.some((sp) => sp.toLowerCase() === lower)) dropped.add(p);
809
+ }
391
810
  for (const p of dropped) delete byPath[p];
392
- const order = [...snapshot.order];
811
+ const order = [...(snapshot?.order ?? [])];
393
812
  for (const p of Object.keys(localDocs)) {
394
- if (!snapshot.byPath[p] && !dropped.has(p)) order.push(p);
813
+ if (!snapByPath[p] && !dropped.has(p)) order.push(p);
395
814
  }
396
815
  return {
397
816
  order,
398
817
  byPath,
399
- latestOpeningPath: snapshot.latestOpeningPath ?? null,
400
- latestOpeningEventKey: snapshot.latestOpeningEventKey ?? null,
818
+ latestOpeningPath: snapshot?.latestOpeningPath ?? null,
819
+ latestOpeningEventKey: snapshot?.latestOpeningEventKey ?? null,
820
+ errorByPath: snapshot?.errorByPath ?? {},
821
+ latestError: snapshot?.latestError ?? null,
401
822
  };
402
823
  }
403
824
 
@@ -424,6 +845,45 @@ window.__ModuleLoader__.load({
424
845
  .map((p) => snapshot.byPath[p]?.openingEventKey)
425
846
  .filter((key) => key != null));
426
847
  }
848
+
849
+ /**
850
+ * 找到可归因到某文档路径的错误(016 加载态恢复):优先 errorByPath
851
+ * 精确匹配,其次小写全路径匹配(S5 同源);sinceKeys(openMindmap
852
+ * 点击时刻的错误基线,见 errorEventKeys)提供时只认其后新出现的错误,
853
+ * 且无新路径错误时回落 latestError(无路径归因的最近错误)。
854
+ */
855
+ function matchDocError(snapshot, path, sinceKeys) {
856
+ if (!snapshot || typeof path !== "string" || !path) return null;
857
+ const errors = snapshot.errorByPath ?? {};
858
+ let matched = errors[path] ?? null;
859
+ if (!matched) {
860
+ const lower = path.toLowerCase();
861
+ for (const key of Object.keys(errors)) {
862
+ if (key.toLowerCase() === lower) {
863
+ matched = errors[key];
864
+ break;
865
+ }
866
+ }
867
+ }
868
+ if (sinceKeys) {
869
+ if (matched && sinceKeys.has(matched.eventKey)) matched = null;
870
+ const latest = snapshot.latestError ?? null;
871
+ if (!matched && latest && !sinceKeys.has(latest.eventKey)) matched = latest;
872
+ }
873
+ return matched;
874
+ }
875
+
876
+ /** 当前快照的全部错误事件键(errorByPath + latestError),作「点击时刻基线」。 */
877
+ function errorEventKeys(snapshot) {
878
+ const keys = new Set();
879
+ if (!snapshot) return keys;
880
+ for (const entry of Object.values(snapshot.errorByPath ?? {})) {
881
+ if (entry && entry.eventKey != null) keys.add(entry.eventKey);
882
+ }
883
+ const latest = snapshot.latestError;
884
+ if (latest && latest.eventKey != null) keys.add(latest.eventKey);
885
+ return keys;
886
+ }
427
887
  //#endregion
428
888
 
429
889
  //#region 目录树 tab:懒加载节点表 → 可见行(013)
@@ -467,8 +927,14 @@ window.__ModuleLoader__.load({
467
927
  }
468
928
  //#endregion
469
929
 
470
- //#region PNG 导出(SVG 序列化 → canvas → 下载)
471
- const EXPORT = { nodeW: 200, nodeH: 30, hGap: 48, vGap: 10, pad: 20, fontSize: 13 };
930
+ //#region PNG 导出(SVG 序列化 → canvas → 下载 / 剪贴板)
931
+ // 019 可变盒高布局:盒高按内容估行数(全量换行的导出形态),表格节点加宽;
932
+ // 布局契约不变——叶子自上而下占行、父节点垂直居中于其子块。
933
+ const EXPORT = {
934
+ nodeW: 220, padX: 12, padY: 8, hGap: 48, vGap: 12, pad: 20,
935
+ fontSize: 13, lineHeight: 18,
936
+ tableCellW: 110, tableCellPad: 8, tableMinW: 140, tableMaxW: 480,
937
+ };
472
938
 
473
939
  function escapeXml(text) {
474
940
  return String(text ?? "").replace(/[&<>"']/g, (ch) => ({
@@ -481,68 +947,175 @@ window.__ModuleLoader__.load({
481
947
  return [...s].length > max ? `${[...s].slice(0, max).join("")}…` : s;
482
948
  }
483
949
 
950
+ /** 019 行内格式剥离:导出为纯文本(URL 原样保留——完整不缩减,003 §7)。 */
951
+ function stripInlineForExport(text) {
952
+ return String(text ?? "")
953
+ .replace(/!\[([^\]]*)\]\(([^)]*)\)/g, "$2")
954
+ .replace(/\[([^\]]*)\]\(([^)]*)\)/g, (m, label, url) => (label ? `${label}(${url})` : url))
955
+ .replace(/`([^`]+)`/g, "$1")
956
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
957
+ .replace(/~~([^~]+)~~/g, "$1")
958
+ .replace(/\*([^*\n]+)\*/g, "$1");
959
+ }
960
+
961
+ /** 字符宽度估算:CJK 按一个字号宽,其余按 0.55 折算。 */
962
+ function charW(ch, fontSize) {
963
+ return ch.charCodeAt(0) > 0x2e7f ? fontSize : fontSize * 0.55;
964
+ }
965
+
966
+ /** 按可用宽度贪心折行(尊重显式换行;长串硬折——长 URL 完整呈现不截断)。 */
967
+ function wrapExportText(text, maxWidth, fontSize) {
968
+ const lines = [];
969
+ for (const segment of String(text ?? "").split("\n")) {
970
+ let cur = "";
971
+ let w = 0;
972
+ for (const ch of segment) {
973
+ const cw = charW(ch, fontSize);
974
+ if (w + cw > maxWidth && cur) {
975
+ lines.push(cur);
976
+ cur = ch;
977
+ w = cw;
978
+ } else {
979
+ cur += ch;
980
+ w += cw;
981
+ }
982
+ }
983
+ lines.push(cur);
984
+ }
985
+ return lines.length > 0 ? lines : [""];
986
+ }
987
+
988
+ /** 019 导出块内容:按 kind 取全量呈现的文本与行数。 */
989
+ function exportBlock(node) {
990
+ if (node.kind === "table") {
991
+ const rows = (node.data && node.data.rows) || [];
992
+ const cells = rows.reduce((acc, row) => acc.concat(row), []);
993
+ return { text: cells.map(stripInlineForExport).join("\n"), lines: Math.max(1, rows.length) };
994
+ }
995
+ if (node.kind === "quote") return { text: stripInlineForExport(node.topic), lines: null };
996
+ if (node.kind === "code") return { text: node.topic, lines: 1 };
997
+ return { text: stripInlineForExport(node.topic), lines: null };
998
+ }
999
+
1000
+ /** 019 盒尺寸估算:文本按折行行数生长;表格按行列数算网格尺寸。 */
1001
+ function measureExportBox(node) {
1002
+ if (node.kind === "table") {
1003
+ const rows = (node.data && node.data.rows) || [];
1004
+ const cols = rows.reduce((mx, row) => Math.max(mx, row.length), 0) || 1;
1005
+ const cellInner = EXPORT.tableCellW - EXPORT.tableCellPad * 2;
1006
+ const rowLines = rows.map((row) => row.reduce((mx, cell) => Math.max(mx, wrapExportText(stripInlineForExport(cell), cellInner, EXPORT.fontSize - 1).length), 1));
1007
+ const w = Math.min(EXPORT.tableMaxW, Math.max(EXPORT.tableMinW, cols * EXPORT.tableCellW));
1008
+ const h = Math.max(EXPORT.lineHeight, rowLines.reduce((a, b) => a + b, 0) * EXPORT.lineHeight);
1009
+ return { w, h };
1010
+ }
1011
+ const text = exportBlock(node).text;
1012
+ const inner = EXPORT.nodeW - EXPORT.padX * 2;
1013
+ const lines = node.kind === "code" ? [text] : wrapExportText(text, inner, EXPORT.fontSize);
1014
+ return { w: EXPORT.nodeW, h: EXPORT.padY * 2 + lines.length * EXPORT.lineHeight };
1015
+ }
1016
+
484
1017
  /**
485
- * 布局 + 生成导出用 SVG 字符串。左→右分层:x = 深度列,叶子自上而下占行,
486
- * 父节点垂直居中于其子块;连线为水平贝塞尔。
1018
+ * 布局 + 生成导出用 SVG 字符串。左→右分层:x = 父盒右缘 + 间距(可变盒宽),
1019
+ * 叶子自上而下占行,父节点垂直居中于其子块;连线为水平贝塞尔。
1020
+ * 019:盒高随内容生长(表格/引用画专属形态);色值取主题静态亮色快照
1021
+ *(导出 SVG 走 data-URL,宿主 CSS 变量不可用)。
487
1022
  */
488
- function buildExportSvg(tree) {
1023
+ function buildExportSvg(tree, themeName) {
1024
+ const palette = exportPalette(themeName);
489
1025
  const placed = [];
490
1026
  const edges = [];
491
1027
  let cursor = EXPORT.pad;
492
- let maxDepth = 0;
493
- const place = (node, depth, parent) => {
494
- const entry = { node, depth, x: EXPORT.pad + depth * (EXPORT.nodeW + EXPORT.hGap), y: 0 };
1028
+ const place = (node, parentEntry) => {
1029
+ const size = measureExportBox(node);
1030
+ const entry = {
1031
+ node,
1032
+ size,
1033
+ x: parentEntry ? parentEntry.x + parentEntry.size.w + EXPORT.hGap : EXPORT.pad,
1034
+ y: 0,
1035
+ };
495
1036
  placed.push(entry);
496
- if (depth > maxDepth) maxDepth = depth;
497
- if (parent) edges.push({ from: parent, to: entry });
1037
+ if (parentEntry) edges.push({ from: parentEntry, to: entry });
498
1038
  if (node.children && node.children.length > 0) {
499
1039
  let first = null;
500
1040
  let last = null;
501
1041
  for (const child of node.children) {
502
- const childEntry = place(child, depth + 1, entry);
1042
+ const childEntry = place(child, entry);
503
1043
  if (!first) first = childEntry;
504
1044
  last = childEntry;
505
1045
  }
506
1046
  entry.y = (first.y + last.y) / 2;
507
1047
  } else {
508
- entry.y = cursor + EXPORT.nodeH / 2;
509
- cursor += EXPORT.nodeH + EXPORT.vGap;
1048
+ entry.y = cursor + size.h / 2;
1049
+ cursor += size.h + EXPORT.vGap;
510
1050
  }
511
1051
  return entry;
512
1052
  };
513
- place(tree, 0, null);
514
- const width = EXPORT.pad * 2 + (maxDepth + 1) * EXPORT.nodeW + maxDepth * EXPORT.hGap;
515
- const height = Math.max(EXPORT.pad * 2 + EXPORT.nodeH, cursor - EXPORT.vGap + EXPORT.pad);
1053
+ place(tree, null);
1054
+ const width = placed.reduce((mx, p) => Math.max(mx, p.x + p.size.w), 0) + EXPORT.pad;
1055
+ const height = Math.max(EXPORT.pad * 2 + EXPORT.lineHeight, cursor - EXPORT.vGap + EXPORT.pad);
516
1056
  const parts = [];
517
1057
  parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" font-family="-apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif">`);
518
- parts.push(`<rect x="0" y="0" width="${width}" height="${height}" fill="#ffffff"/>`);
1058
+ parts.push(`<rect x="0" y="0" width="${width}" height="${height}" fill="${palette.canvasBg}"/>`);
519
1059
  for (const e of edges) {
520
- const x1 = e.from.x + EXPORT.nodeW;
1060
+ const x1 = e.from.x + e.from.size.w;
521
1061
  const y1 = e.from.y;
522
1062
  const x2 = e.to.x;
523
1063
  const y2 = e.to.y;
524
1064
  const mid = (x1 + x2) / 2;
525
- parts.push(`<path d="M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}" fill="none" stroke="#c8cdd6" stroke-width="1.5"/>`);
1065
+ parts.push(`<path d="M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}" fill="none" stroke="${palette.connector}" stroke-width="1.5"/>`);
526
1066
  }
527
1067
  for (const p of placed) {
528
- const isRoot = p.depth === 0;
529
- const isPlaceholder = p.node.kind === "placeholder";
530
- const isCode = p.node.kind === "code";
531
- const boxY = p.y - EXPORT.nodeH / 2;
532
- const fill = isRoot ? "#eef2ff" : isCode ? "#f5f2ea" : "#f6f7f9";
533
- parts.push(`<rect x="${p.x}" y="${boxY}" width="${EXPORT.nodeW}" height="${EXPORT.nodeH}" rx="7" fill="${isPlaceholder ? "none" : fill}" stroke="${isRoot ? "#7c8cf8" : isPlaceholder ? "#b9c0cc" : "#d4d9e0"}" stroke-width="${isRoot ? 1.6 : 1}"${isPlaceholder ? ' stroke-dasharray="5,4"' : ""}/>`);
534
- const label = isPlaceholder ? "待填写" : truncateForExport(p.node.topic);
535
- const color = isRoot ? "#2f3ab2" : isPlaceholder ? "#9aa2b1" : "#1f2430";
536
- const weight = isRoot ? 700 : p.node.kind === "heading" ? 600 : 400;
537
- parts.push(`<text x="${p.x + 10}" y="${p.y + 4.5}" font-size="${EXPORT.fontSize}" font-weight="${weight}" font-family="${isCode ? "Menlo, monospace" : "inherit"}" fill="${color}">${escapeXml(label)}</text>`);
1068
+ const node = p.node;
1069
+ const kind = node.kind;
1070
+ const isRoot = !edges.some((e) => e.to === p);
1071
+ const isPlaceholder = kind === "placeholder";
1072
+ const isCode = kind === "code";
1073
+ const isQuote = kind === "quote";
1074
+ const isTable = kind === "table";
1075
+ const boxY = p.y - p.size.h / 2;
1076
+ const fill = isRoot ? palette.rootBg : isCode ? palette.surfaceCode : palette.surface;
1077
+ parts.push(`<rect x="${p.x}" y="${boxY}" width="${p.size.w}" height="${p.size.h}" rx="7" fill="${isPlaceholder ? "none" : fill}" stroke="${isRoot ? palette.rootBorder : isPlaceholder ? palette.border : palette.border}" stroke-width="${isRoot ? 1.6 : 1}"${isPlaceholder ? ' stroke-dasharray="5,4"' : ""}/>`);
1078
+ if (isQuote) {
1079
+ parts.push(`<rect x="${p.x}" y="${boxY}" width="3" height="${p.size.h}" fill="${palette.quote}"/>`);
1080
+ }
1081
+ if (isTable) {
1082
+ // 表格块:完整网格(全量行列、单元格换行、不缩减,003 §5.5)。
1083
+ const rows = (node.data && node.data.rows) || [];
1084
+ const cols = rows.reduce((mx, row) => Math.max(mx, row.length), 0) || 1;
1085
+ const colW = p.size.w / cols;
1086
+ const rowLines = rows.map((row) => row.reduce((mx, cell) => Math.max(mx, wrapExportText(stripInlineForExport(cell), colW - EXPORT.tableCellPad * 2, EXPORT.fontSize - 1).length), 1));
1087
+ const rowH = rowLines.map((n) => n * EXPORT.lineHeight);
1088
+ let ry = boxY;
1089
+ rows.forEach((row, ri) => {
1090
+ row.forEach((cell, ci) => {
1091
+ const cx = p.x + ci * colW;
1092
+ parts.push(`<rect x="${cx}" y="${ry}" width="${colW}" height="${rowH[ri]}" fill="${ri === 0 ? palette.surfaceCode : "none"}" stroke="${palette.borderSubtle}" stroke-width="1"/>`);
1093
+ const cellLines = wrapExportText(stripInlineForExport(cell), colW - EXPORT.tableCellPad * 2, EXPORT.fontSize - 1);
1094
+ cellLines.forEach((ln, li) => {
1095
+ const ty = ry + (li + 0.5) * EXPORT.lineHeight + (EXPORT.fontSize - 1) * 0.35;
1096
+ parts.push(`<text x="${cx + EXPORT.tableCellPad}" y="${ty.toFixed(1)}" font-size="${EXPORT.fontSize - 1}" font-weight="${ri === 0 ? 600 : 400}" fill="${palette.text}">${escapeXml(ln)}</text>`);
1097
+ });
1098
+ });
1099
+ ry += rowH[ri];
1100
+ });
1101
+ continue;
1102
+ }
1103
+ const label = isPlaceholder ? "待填写" : exportBlock(node).text;
1104
+ const color = isPlaceholder ? palette.muted : isRoot ? palette.rootText : kind === "heading" ? palette.heading : palette.text;
1105
+ const weight = isRoot ? 700 : kind === "heading" ? 600 : 400;
1106
+ const inner = p.size.w - EXPORT.padX * 2 - (isQuote ? 3 : 0);
1107
+ const lines = isCode ? [label] : wrapExportText(label, inner, EXPORT.fontSize);
1108
+ const startY = p.y - (lines.length - 1) * EXPORT.lineHeight / 2 + EXPORT.fontSize * 0.35;
1109
+ lines.forEach((ln, li) => {
1110
+ parts.push(`<text x="${p.x + EXPORT.padX + (isQuote ? 3 : 0)}" y="${(startY + li * EXPORT.lineHeight).toFixed(1)}" font-size="${EXPORT.fontSize}" font-weight="${weight}" font-family="${isCode ? "Menlo, monospace" : "inherit"}" fill="${color}">${escapeXml(ln)}</text>`);
1111
+ });
538
1112
  }
539
1113
  parts.push("</svg>");
540
1114
  return { svg: parts.join(""), width, height };
541
1115
  }
542
1116
 
543
- /** 浏览器侧导出:SVG → Image → canvas PNG 下载。 */
544
- async function exportPng(tree, rootTitle) {
545
- const { svg, width, height } = buildExportSvg(tree);
1117
+ /** SVG → Image → 白底 canvas(下载 / 剪贴板共用,017 抽出)。 */
1118
+ async function renderSvgToCanvas(svg, width, height) {
546
1119
  const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
547
1120
  const img = new Image();
548
1121
  await new Promise((resolve, reject) => {
@@ -557,6 +1130,13 @@ window.__ModuleLoader__.load({
557
1130
  ctx2d.fillStyle = "#ffffff";
558
1131
  ctx2d.fillRect(0, 0, canvas.width, canvas.height);
559
1132
  ctx2d.drawImage(img, 0, 0);
1133
+ return canvas;
1134
+ }
1135
+
1136
+ /** 浏览器侧导出:SVG → canvas → PNG 下载。tree 可为整树或任意子树(017)。 */
1137
+ async function exportPng(tree, rootTitle, themeName) {
1138
+ const { svg, width, height } = buildExportSvg(tree, themeName);
1139
+ const canvas = await renderSvgToCanvas(svg, width, height);
560
1140
  const dataUrl = canvas.toDataURL("image/png");
561
1141
  const a = document.createElement("a");
562
1142
  a.href = dataUrl;
@@ -565,6 +1145,47 @@ window.__ModuleLoader__.load({
565
1145
  a.click();
566
1146
  a.remove();
567
1147
  }
1148
+
1149
+ /**
1150
+ * 020 复制全文:纯文本写系统剪贴板。优先 Clipboard API;老环境回退
1151
+ * 临时 textarea + execCommand(宿主 webview 权限不齐时的保底)。
1152
+ */
1153
+ async function copyPlainText(text) {
1154
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
1155
+ await navigator.clipboard.writeText(String(text ?? ""));
1156
+ return;
1157
+ }
1158
+ const ta = document.createElement("textarea");
1159
+ ta.value = String(text ?? "");
1160
+ ta.style.position = "fixed";
1161
+ ta.style.opacity = "0";
1162
+ document.body.appendChild(ta);
1163
+ ta.select();
1164
+ const ok = document.execCommand && document.execCommand("copy");
1165
+ ta.remove();
1166
+ if (!ok) throw new Error("当前环境不支持复制文本");
1167
+ }
1168
+
1169
+ /**
1170
+ * 浏览器侧复制(017 节点右键菜单):tree 渲染成 PNG 写入系统剪贴板,
1171
+ * 可直接粘贴到聊天 / 文档 / 微信等。ClipboardItem 携带 Blob Promise——
1172
+ * 异步渲染期间保持用户激活态(Chrome 契约);环境不支持(非安全
1173
+ * 上下文等)或写入被拒时抛错,由菜单提示改用「导出为图片」。
1174
+ */
1175
+ async function copyPng(tree, themeName) {
1176
+ if (typeof ClipboardItem === "undefined" || !navigator.clipboard || typeof navigator.clipboard.write !== "function") {
1177
+ throw new Error("当前环境不支持复制图片,请改用「导出为图片」");
1178
+ }
1179
+ const { svg, width, height } = buildExportSvg(tree, themeName);
1180
+ const blobPromise = renderSvgToCanvas(svg, width, height).then((canvas) => new Promise((resolve, reject) => {
1181
+ canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("PNG 生成失败"))), "image/png");
1182
+ }));
1183
+ try {
1184
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": blobPromise })]);
1185
+ } catch (error) {
1186
+ throw new Error(`复制图片失败:${error?.message ?? error}。可改用「导出为图片」`);
1187
+ }
1188
+ }
568
1189
  //#endregion
569
1190
 
570
1191
  //#region React 组件
@@ -593,6 +1214,10 @@ window.__ModuleLoader__.load({
593
1214
  // 013:tab 秒建后的加载态(内容要等 AI 工具结果才渲染)。
594
1215
  loadingWrap: { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "12px", height: "100%", minHeight: 0 },
595
1216
  loadingText: { color: "var(--dsw-alias-label-secondary)", fontSize: "13px", margin: "0" },
1217
+ // 016 加载态三态:错误/超时标记与文案、重试按钮(沿用面板语义色变量)。
1218
+ loadingErrorText: { color: "var(--dsw-alias-label-error)", fontSize: "13px", lineHeight: 1.6, margin: "0", textAlign: "center", wordBreak: "break-word", maxWidth: "90%" },
1219
+ loadingFailMark: { flex: "none", fontSize: "22px", lineHeight: 1, color: "var(--dsw-alias-label-error)" },
1220
+ retryBtn: { flex: "none", border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", color: "var(--dsw-alias-label-primary)", cursor: "pointer", font: "inherit", fontSize: "13px", padding: "6px 18px", borderRadius: "8px", lineHeight: "20px" },
596
1221
  // 013 目录树 tab:树容器/行样式。视觉自成一套:emoji 图标 + M 徽标 +
597
1222
  // 悬停高亮 + 激活指示条,不做 VSCode 式 chevron/线框。
598
1223
  // 树容器 -2px 负边距抵消 body 16px 内距:树左缘 = 头部「目录」tab 左缘(14px)。
@@ -614,6 +1239,12 @@ window.__ModuleLoader__.load({
614
1239
  treeError: { color: "var(--dsw-alias-label-error)", fontSize: "12px", lineHeight: 1.6, margin: "0" },
615
1240
  treeMenu: { position: "fixed", zIndex: 60, minWidth: "210px", background: "var(--dsw-alias-bg-layer-3)", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "10px", padding: "6px", boxShadow: "var(--dsw-shadow-lv2)" },
616
1241
  treeMenuItem: { display: "block", width: "100%", boxSizing: "border-box", textAlign: "left", border: "none", background: "none", cursor: "pointer", padding: "7px 12px", borderRadius: "8px", font: "inherit", fontSize: "13px", color: "var(--dsw-alias-label-primary)" },
1242
+ // 017 画布节点右键菜单:标题行(节点主题)+ 错误行 + 菜单项禁用态。
1243
+ nodeMenuHeader: { padding: "4px 12px 6px", fontSize: "12px", color: "var(--dsw-alias-label-tertiary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: "220px", boxSizing: "border-box" },
1244
+ // 020:标题(节点主题)与动作项之间的分隔线,拉开层次。
1245
+ nodeMenuDivider: { height: "1px", background: "var(--dsw-alias-border-l2)", margin: "4px 6px" },
1246
+ nodeMenuError: { color: "var(--dsw-alias-label-error)", fontSize: "12px", lineHeight: 1.5, margin: "0", padding: "2px 12px 4px", wordBreak: "break-word" },
1247
+ treeMenuItemDisabled: { opacity: 0.5, cursor: "default" },
617
1248
  // 015 设置面板(settings.section 页面内容)。
618
1249
  settingsWrap: { display: "flex", flexDirection: "column", gap: "12px", padding: "16px", maxWidth: "480px" },
619
1250
  settingsGroup: { display: "flex", flexDirection: "column", gap: "14px", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "12px", padding: "14px", background: "var(--dsw-alias-bg-layer-3)" },
@@ -641,11 +1272,33 @@ window.__ModuleLoader__.load({
641
1272
  // 百分比高度解析为 auto 会让 SVG 坐标系塌缩,连线与节点像素错位。
642
1273
  // 改为由 TreeRow 在 measure 里同步记下实际像素,作 SVG 属性直传。
643
1274
  edgeLayer: { position: "absolute", top: 0, left: 0, display: "block", pointerEvents: "none", overflow: "visible" },
644
- box: { padding: "6px 12px", borderRadius: "10px", border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", maxWidth: "240px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "0 0 auto", boxShadow: "0 1px 2px rgba(16,24,40,0.04)" },
645
- rootBox: { fontWeight: 700, fontSize: "14px", border: "1px solid var(--dsw-alias-border-l2-darkmode-thin, #b9c0cc)", background: "var(--dsw-alias-bg-module-platform, #eef2ff)" },
646
- headingBox: { fontWeight: 600 },
647
- placeholderBox: { padding: "6px 12px", borderRadius: "10px", border: "1px dashed var(--dsw-alias-border-l2)", color: "var(--dsw-alias-label-tertiary)", background: "none" },
648
- codeBox: { fontFamily: "Menlo, monospace", fontSize: "12px" },
1275
+ // 019 节点盒骨架(002 三层模型:骨架/血肉/皮肤):只留内距与换行契约,
1276
+ // 颜色/圆角/阴影由 resolveNodeStyle 生成。020 长度治理:320px 宽上限
1277
+ // 强制盒内折行(废除横条);长 URL 用 overflowWrap:anywhere 保证可折行不截断。
1278
+ box: { padding: "6px 12px", flex: "0 0 auto", whiteSpace: "pre-wrap", overflowWrap: "anywhere", lineHeight: "20px", fontSize: "13px", boxSizing: "border-box", maxWidth: 320 },
1279
+ // 020 长度治理:散文类块(text/md/list/quote)折行后仍超 6 行即截断+
1280
+ // 省略号,全文走悬停浮层(复用代码浮层通道,缩减≠阉割)。仅散文类套用。
1281
+ // 行高必须整数像素(20px):小数行高会让 line-clamp 裁切边界与行盒
1282
+ // 错位,露出下一行半截字;maxHeight = 6×20 + 上下内距 12 作硬上限双保险。
1283
+ boxClamp: { display: "-webkit-box", WebkitLineClamp: 6, WebkitBoxOrient: "vertical", overflow: "hidden", maxHeight: 132 },
1284
+ // 019 代码块悬停浮层:看全文的浮起面板(position:fixed 不进测量链,
1285
+ // 不影响行测量;等宽全文、可滚动、移开即收)。
1286
+ codePanel: { position: "fixed", zIndex: 70, maxWidth: "440px", maxHeight: "340px", overflow: "auto", padding: "10px 12px", background: "var(--dsw-alias-bg-layer-3)", color: "var(--dsw-alias-label-primary)", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "10px", boxShadow: "var(--dsw-shadow-lv2)", fontFamily: "Menlo, monospace", fontSize: "12px", lineHeight: 1.6, whiteSpace: "pre", boxSizing: "border-box" },
1287
+ codePanelLang: { margin: "0 0 6px", fontSize: "11px", color: "var(--dsw-alias-label-tertiary)", fontFamily: "inherit" },
1288
+ codePanelCode: { margin: "0", fontFamily: "inherit", fontSize: "inherit", whiteSpace: "pre" },
1289
+ // 020 散文类全文浮层:与代码浮层共用定位/翻转/宽限逻辑,换等宽为等线、
1290
+ // pre 为 pre-wrap(正文不是代码)。
1291
+ textPanel: { position: "fixed", zIndex: 70, maxWidth: "440px", maxHeight: "340px", overflow: "auto", padding: "10px 12px", background: "var(--dsw-alias-bg-layer-3)", color: "var(--dsw-alias-label-primary)", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "10px", boxShadow: "var(--dsw-shadow-lv2)", fontSize: "12px", lineHeight: 1.6, boxSizing: "border-box" },
1292
+ textPanelBody: { margin: "0", whiteSpace: "pre-wrap", overflowWrap: "anywhere" },
1293
+ // 019 表格块:完整网格(全量行列、单元格内换行、弱边框)。020:表格不
1294
+ // 参与截断,超宽时盒内横向滚动,网格与单元格完整保留。
1295
+ tableWrap: { fontSize: "12px", lineHeight: 1.5, overflowX: "auto", maxWidth: "100%" },
1296
+ tableGrid: { borderCollapse: "collapse" },
1297
+ tableCell: { border: "1px solid var(--dsw-alias-border-l2)", padding: "3px 8px", whiteSpace: "pre-wrap", overflowWrap: "anywhere", verticalAlign: "top", textAlign: "left", minWidth: "64px", maxWidth: "240px" },
1298
+ tableHeaderCell: { fontWeight: 600, background: "var(--dsw-alias-fill-tsp-secondary)" },
1299
+ // 019 大一统可点击链接:任何块里的任何 URL 完整呈现、永不缩减。
1300
+ inlineLink: { color: "var(--dsw-alias-state-business-primary)", textDecoration: "underline", textUnderlineOffset: "2px", overflowWrap: "anywhere", cursor: "pointer" },
1301
+ inlineCode: { fontFamily: "Menlo, monospace", fontSize: "12px", background: "var(--dsw-alias-fill-tsp-secondary)", borderRadius: "4px", padding: "0 3px" },
649
1302
  // 016 脑图画布:滚动区 + 居中层 + 右上角浮动缩放控制条。
650
1303
  canvasWrap: { flex: "1 1 auto", minHeight: 0, minWidth: 0, position: "relative", display: "flex", flexDirection: "column" },
651
1304
  canvasScroll: { flex: "1 1 auto", minHeight: 0, minWidth: 0, overflow: "auto" },
@@ -700,6 +1353,8 @@ window.__ModuleLoader__.load({
700
1353
  cardStyle: v && v.cardStyle === "square" ? "square" : "rounded",
701
1354
  colorTheme: v && COLOR_THEMES[v.colorTheme] ? v.colorTheme : "ocean",
702
1355
  defaultPanelWidth: v && typeof v.defaultPanelWidth === "number" ? v.defaultPanelWidth : 42,
1356
+ // 018:旧设置/读不到都默认开(!== false 语义)。
1357
+ growthAnimation: !(v && v.growthAnimation === false),
703
1358
  });
704
1359
  } catch (err) {
705
1360
  if (alive) setError(String(err?.message ?? err));
@@ -773,7 +1428,8 @@ window.__ModuleLoader__.load({
773
1428
  { value: "sunset", label: "落日橙" },
774
1429
  { value: "forest", label: "森林绿" },
775
1430
  ].map((t) => {
776
- const tokens = colorThemeTokens(t.value);
1431
+ // 019:色点读令牌表(该主题的强标题强调色)。
1432
+ const dot = resolveToken("color.accent.heading.strong", COLOR_THEMES[t.value]);
777
1433
  return (0, react_jsx_runtime.jsxs)("button", {
778
1434
  key: t.value,
779
1435
  type: "button",
@@ -781,7 +1437,7 @@ window.__ModuleLoader__.load({
781
1437
  disabled: saving,
782
1438
  onClick: () => setField({ colorTheme: t.value }),
783
1439
  children: [
784
- (0, react_jsx_runtime.jsx)("span", { style: { ...S.swatchDot, background: tokens.heading } }),
1440
+ (0, react_jsx_runtime.jsx)("span", { style: { ...S.swatchDot, background: dot } }),
785
1441
  t.label,
786
1442
  ],
787
1443
  }, t.value);
@@ -805,6 +1461,17 @@ window.__ModuleLoader__.load({
805
1461
  }),
806
1462
  ] }),
807
1463
  (0, react_jsx_runtime.jsx)("p", { style: S.settingsHint, children: "范围 20~80。拖拽面板后的宽度会记住(localStorage);清除本地记忆后回到这里配置的默认值。" }),
1464
+ // 018 生长动画开关:每次更新后新增/变化节点逐个渐显;关掉即整棵直出。
1465
+ (0, react_jsx_runtime.jsxs)("div", { style: S.settingsRow, children: [
1466
+ (0, react_jsx_runtime.jsx)("span", { style: S.settingsLabel, children: "生长动画" }),
1467
+ (0, react_jsx_runtime.jsx)(Segmented, {
1468
+ options: [{ value: true, label: "开" }, { value: false, label: "关" }],
1469
+ value: value.growthAnimation,
1470
+ disabled: saving,
1471
+ onChange: (v) => setField({ growthAnimation: v }),
1472
+ }),
1473
+ ] }),
1474
+ (0, react_jsx_runtime.jsx)("p", { style: S.settingsHint, children: "开启后,脑图每次更新的新增/变化节点会逐个渐显长出(总时长不超过 2 秒);关闭则整棵树立刻完整显示。" }),
808
1475
  ] }),
809
1476
  saving ? (0, react_jsx_runtime.jsx)("p", { style: S.settingsHint, children: "保存中…" }) : null,
810
1477
  notice ? (0, react_jsx_runtime.jsx)("p", { style: S.settingsNotice, children: notice }) : null,
@@ -821,6 +1488,11 @@ window.__ModuleLoader__.load({
821
1488
  function MindmapSlot(props) {
822
1489
  const { useSession, sessionId, inputActions, mindmapFace } = props;
823
1490
  const nodes = useSession ? useSession((s) => (s && s.nodes) || EMPTY_NODES) : EMPTY_NODES;
1491
+ // 016 可靠性加固:结构指纹作第二 selector。store 原地改数组(引用
1492
+ // 不变)时,nodes prop 不换、memo 命中缓存、auto-open effect 永不
1493
+ // 重跑——「AI 打开了脑图但面板不展开」的根因。指纹是原始值字符串,
1494
+ // 值比较天然绕过引用相等短路;useSession 不可用时回退空串。
1495
+ const nodesVersion = useSession ? useSession((s) => nodesFingerprint((s && s.nodes) || EMPTY_NODES)) : "";
824
1496
  const [open, setOpen] = react.useState(false);
825
1497
  return (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [
826
1498
  (0, react_jsx_runtime.jsxs)("button", {
@@ -856,6 +1528,7 @@ window.__ModuleLoader__.load({
856
1528
  sessionId,
857
1529
  inputActions,
858
1530
  nodes,
1531
+ nodesVersion,
859
1532
  mindmapFace,
860
1533
  onOpen: () => setOpen(true),
861
1534
  onClose: () => setOpen(false),
@@ -863,31 +1536,168 @@ window.__ModuleLoader__.load({
863
1536
  ] });
864
1537
  }
865
1538
 
1539
+ //#region 019 血肉渲染:行内格式 + 大一统链接 + 表格块(规范源:003)
1540
+ // 行内格式统一扫描序:图片/链接 → 行内代码 → 粗体 → 删除线 → 斜体 → 裸链接。
1541
+ // 先命中先生效,裸链接放最后,避免吞掉已被 [文字](url) 消费的 URL。
1542
+ const INLINE_PATTERN = /(!?\[[^\]]*\]\([^)]*\))|(`[^`]+`)|(\*\*[^*]+\*\*)|(~~[^~]+~~)|(\*[^*\s][^*]*\*)|(https?:\/\/[^\s)]+)/g;
1543
+
1544
+ /** 大一统链接点击:在机器浏览器打开(新标签页),不触发画布聚焦缩放。 */
1545
+ function openLink(event, url) {
1546
+ event.preventDefault();
1547
+ event.stopPropagation();
1548
+ try {
1549
+ window.open(url, "_blank", "noopener");
1550
+ } catch {
1551
+ // 宿主环境拦截时退化为浏览器默认行为(不静默吞链接)。
1552
+ event.defaultPrevented = false;
1553
+ }
1554
+ }
1555
+
1556
+ /**
1557
+ * 零依赖行内渲染器:任何块(文本/Markdown/列表/表格单元格)的内容都走这里。
1558
+ * 链接完整呈现、永不缩减(缩减=阉割信息,003 §7);无预览、无加载态。
1559
+ * 返回 React 子节点数组(无格式时原样返回字符串)。
1560
+ */
1561
+ function renderInline(text, keyPrefix) {
1562
+ const source = String(text ?? "");
1563
+ INLINE_PATTERN.lastIndex = 0;
1564
+ const out = [];
1565
+ let last = 0;
1566
+ let k = 0;
1567
+ let m;
1568
+ while ((m = INLINE_PATTERN.exec(source)) !== null) {
1569
+ if (m.index > last) out.push(source.slice(last, m.index));
1570
+ const token = m[0];
1571
+ const key = `${keyPrefix || "i"}-${k++}`;
1572
+ if (m[1]) {
1573
+ // [文字](url) 或 ![alt](url)。图片块暂缓(003 §9):图语法退化为
1574
+ // 指向原图的链接,同时把 alt 与原图地址都完整呈现(不缩减)。
1575
+ const parsed = /^(!?)\[([^\]]*)\]\(([^)]*)\)$/.exec(token);
1576
+ // 普通链接标签取文字(无文字显地址);图语法带 alt 时两者都完整呈现。
1577
+ const label = parsed[1]
1578
+ ? (parsed[2] ? `${parsed[2]} (${parsed[3]})` : parsed[3])
1579
+ : (parsed[2] || parsed[3]);
1580
+ out.push((0, react_jsx_runtime.jsx)("a", {
1581
+ key,
1582
+ href: parsed[3],
1583
+ target: "_blank",
1584
+ rel: "noopener noreferrer",
1585
+ style: S.inlineLink,
1586
+ title: parsed[3],
1587
+ onClick: (e) => openLink(e, parsed[3]),
1588
+ children: label,
1589
+ }, key));
1590
+ } else if (m[2]) {
1591
+ out.push((0, react_jsx_runtime.jsx)("code", { key, style: S.inlineCode, children: token.slice(1, -1) }, key));
1592
+ } else if (m[3]) {
1593
+ out.push((0, react_jsx_runtime.jsx)("strong", { key, children: token.slice(2, -2) }, key));
1594
+ } else if (m[4]) {
1595
+ out.push((0, react_jsx_runtime.jsx)("s", { key, children: token.slice(2, -2) }, key));
1596
+ } else if (m[5]) {
1597
+ out.push((0, react_jsx_runtime.jsx)("em", { key, children: token.slice(1, -1) }, key));
1598
+ } else {
1599
+ // 裸链接:完整显示、可点击。
1600
+ out.push((0, react_jsx_runtime.jsx)("a", {
1601
+ key,
1602
+ href: token,
1603
+ target: "_blank",
1604
+ rel: "noopener noreferrer",
1605
+ style: S.inlineLink,
1606
+ title: token,
1607
+ onClick: (e) => openLink(e, token),
1608
+ children: token,
1609
+ }, key));
1610
+ }
1611
+ last = m.index + token.length;
1612
+ }
1613
+ if (last < source.length) out.push(source.slice(last));
1614
+ return out.length > 1 || (out.length === 1 && typeof out[0] !== "string") ? out : source;
1615
+ }
1616
+
1617
+ /** 表格块渲染:完整网格(全量行列、单元格内换行、表头加重),单元格不拆。 */
1618
+ function renderTableBlock(node) {
1619
+ const rows = (node.data && node.data.rows) || [];
1620
+ if (rows.length === 0) return renderInline(node.topic, node.id);
1621
+ return (0, react_jsx_runtime.jsx)("div", { style: S.tableWrap, children: (0, react_jsx_runtime.jsx)("table", { style: S.tableGrid, children: (0, react_jsx_runtime.jsx)("tbody", { children: rows.map((row, ri) => (0, react_jsx_runtime.jsx)("tr", { children: row.map((cell, ci) => (0, react_jsx_runtime.jsx)(ri === 0 ? "th" : "td", {
1622
+ style: ri === 0 ? { ...S.tableCell, ...S.tableHeaderCell } : S.tableCell,
1623
+ children: renderInline(cell, `${node.id}-${ri}-${ci}`),
1624
+ }, ci)) }, ri)) }) }) });
1625
+ }
1626
+ //#endregion
1627
+
866
1628
  function NodeBox(props) {
867
- const { node, theme } = props;
868
- // 015 节点主题:卡片圆角(圆角/直角)+ 颜色主题令牌(根盒/标题用主题色)。
869
- const tokens = colorThemeTokens(theme && theme.colorTheme);
870
- const radius = theme && theme.cardStyle === "square" ? 0 : 10;
871
- const style = node.kind === "root"
872
- ? { ...S.box, ...S.rootBox, borderRadius: radius, borderColor: tokens.rootBorder, background: tokens.rootBg }
873
- : node.kind === "heading"
874
- ? { ...S.box, ...S.headingBox, borderRadius: radius, color: tokens.heading }
875
- : node.kind === "placeholder"
876
- ? { ...S.placeholderBox, borderRadius: radius }
877
- : node.kind === "code"
878
- ? { ...S.box, ...S.codeBox, borderRadius: radius }
879
- : { ...S.box, borderRadius: radius };
880
- const title = node.data?.description
881
- ? `${node.topic}\n\n${node.data.description}`
882
- : node.data?.code
883
- ? `${node.topic}\n\n${node.data.code}`
884
- : node.topic;
885
- return (0, react_jsx_runtime.jsx)("div", { style, title, children: node.kind === "placeholder" ? "待填写" : node.topic });
1629
+ const { node, theme, revealDelay, selectedId, onCodePanel } = props;
1630
+ const [hovered, setHovered] = react.useState(false);
1631
+ const boxRef = react.useRef(null);
1632
+ // 020 长度治理:散文类块(text/md/list/quote)套 6 行截断;clamped = 实测
1633
+ // 真的溢出了(scrollHeight>clientHeight),悬停浮层看全文(复用代码浮层)。
1634
+ const isProse = node.kind === "text" || node.kind === "md" || node.kind === "list" || node.kind === "quote";
1635
+ const [clamped, setClamped] = react.useState(false);
1636
+ react.useLayoutEffect(() => {
1637
+ const el = boxRef.current;
1638
+ if (!el || !isProse) {
1639
+ if (clamped) setClamped(false);
1640
+ return;
1641
+ }
1642
+ const overflow = el.scrollHeight > el.clientHeight + 1;
1643
+ if (overflow !== clamped) setClamped(overflow);
1644
+ }, [node.topic, node.kind]);
1645
+ // 019 皮肤层:颜色/圆角/阴影/状态全部由 resolveNodeStyle 纯函数生成。
1646
+ const style = {
1647
+ ...S.box,
1648
+ ...(isProse ? S.boxClamp : null),
1649
+ ...resolveNodeStyle(node, {
1650
+ colorTheme: theme && theme.colorTheme,
1651
+ cardStyle: theme && theme.cardStyle,
1652
+ states: { hovered, selected: selectedId === node.id },
1653
+ }),
1654
+ };
1655
+ // 020 表格块不参与散文 320px 宽上限:完整网格需要更宽书写面,
1656
+ // 单独放到 680;超出部分盒内横滚(003 §8 结构类保留形态)。
1657
+ if (node.kind === "table") style.maxWidth = 680;
1658
+ // 018 生长动画:动画挂在内层节点盒(外层被连线测量,不能带 transform)。
1659
+ if (revealDelay !== undefined) style.animationDelay = `${revealDelay}ms`;
1660
+
1661
+ function handleEnter() {
1662
+ setHovered(true);
1663
+ // 019 代码块 / 020 截断散文块:盒内紧凑,悬停浮起面板看全文(003 §5.3)。
1664
+ if ((node.kind === "code" || clamped) && onCodePanel && boxRef.current) {
1665
+ onCodePanel({ node, anchor: boxRef.current.getBoundingClientRect() });
1666
+ }
1667
+ }
1668
+ function handleLeave() {
1669
+ setHovered(false);
1670
+ if ((node.kind === "code" || clamped) && onCodePanel) onCodePanel(null);
1671
+ }
1672
+
1673
+ const children = node.kind === "placeholder"
1674
+ ? "待填写"
1675
+ : node.kind === "table"
1676
+ ? renderTableBlock(node)
1677
+ : renderInline(node.topic, node.id);
1678
+ // 截断块的全文走浮层,原生 title 气泡会与之重复,故截断时不挂 title。
1679
+ const title = node.kind === "code" ? `${node.topic}\n\n(悬停看全文)` : clamped ? undefined : node.topic;
1680
+ return (0, react_jsx_runtime.jsx)("div", {
1681
+ ref: boxRef,
1682
+ style,
1683
+ title,
1684
+ className: revealDelay !== undefined ? "dsh-mm-reveal" : undefined,
1685
+ onMouseEnter: handleEnter,
1686
+ onMouseLeave: handleLeave,
1687
+ children,
1688
+ });
886
1689
  }
887
1690
 
888
1691
  /** 左→右递归树:节点盒 + 右侧子节点列 + 连线层(015 支持折线/曲线两种线型)。 */
889
1692
  function TreeRow(props) {
890
- const { node, theme } = props;
1693
+ const { node, theme, onNodeContextMenu, reveal, selectedId, onCodePanel } = props;
1694
+ // 018 生长动画:本节点渐显延迟(新节点盒)与本行连线渐显延迟(有新子节点)。
1695
+ const revealDelay = reveal && reveal.nodes ? reveal.nodes.get(node.id) : undefined;
1696
+ const edgeRevealDelay = reveal && reveal.edges ? reveal.edges.get(node.id) : undefined;
1697
+ // 019 连线外观走令牌(皮肤层),不再硬编码。
1698
+ const overrides = COLOR_THEMES[theme && theme.colorTheme] || COLOR_THEMES.ocean;
1699
+ const connectorColor = resolveToken("connector.color", overrides);
1700
+ const connectorWidth = resolveToken("connector.width", overrides);
891
1701
  const rowRef = react.useRef(null);
892
1702
  const boxWrapRef = react.useRef(null);
893
1703
  const childRefs = react.useRef([]);
@@ -964,12 +1774,14 @@ window.__ModuleLoader__.load({
964
1774
  height: layout.h,
965
1775
  viewBox: `0 0 ${layout.w} ${layout.h}`,
966
1776
  preserveAspectRatio: "none",
967
- style: S.edgeLayer,
1777
+ style: edgeRevealDelay !== undefined ? { ...S.edgeLayer, animationDelay: `${edgeRevealDelay}ms` } : S.edgeLayer,
1778
+ // 018:新子节点出现时连线同步浮现(延迟 = 最早新子节点的错峰)。
1779
+ className: edgeRevealDelay !== undefined ? "dsh-mm-edge-reveal" : undefined,
968
1780
  children: layout.edges.map((d, i) => (0, react_jsx_runtime.jsx)("path", {
969
1781
  key: i,
970
1782
  d,
971
- stroke: "var(--dsw-alias-border-l2)",
972
- strokeWidth: 1.5,
1783
+ stroke: connectorColor,
1784
+ strokeWidth: connectorWidth,
973
1785
  fill: "none",
974
1786
  // 016:CSS zoom 缩放下 strokeWidth 会被一并缩放,缩小后线变
975
1787
  // 亚像素、模糊看不清;vectorEffect=non-scaling-stroke 让线宽
@@ -978,14 +1790,28 @@ window.__ModuleLoader__.load({
978
1790
  }, i)),
979
1791
  })
980
1792
  : null,
981
- (0, react_jsx_runtime.jsx)("div", { ref: boxWrapRef, "data-mindmap-node": "", style: { flex: "0 0 auto", cursor: "pointer" }, children: (0, react_jsx_runtime.jsx)(NodeBox, { node, theme }) }),
1793
+ (0, react_jsx_runtime.jsx)("div", {
1794
+ ref: boxWrapRef,
1795
+ "data-mindmap-node": "",
1796
+ // 019:节点 id 挂在盒包裹上,画布点击聚焦时据此记选中态。
1797
+ "data-mindmap-node-id": node.id,
1798
+ style: { flex: "0 0 auto", cursor: "pointer" },
1799
+ // 017 节点右键:弹「复制/导出为图片」菜单;stopPropagation 免触
1800
+ // 画布空白拦截(空白处只拦默认菜单、不弹自己的)。
1801
+ onContextMenu: onNodeContextMenu ? (e) => {
1802
+ e.preventDefault();
1803
+ e.stopPropagation();
1804
+ onNodeContextMenu(e, node);
1805
+ } : undefined,
1806
+ children: (0, react_jsx_runtime.jsx)(NodeBox, { node, theme, revealDelay, selectedId, onCodePanel }),
1807
+ }),
982
1808
  node.children && node.children.length > 0
983
1809
  ? (0, react_jsx_runtime.jsx)("div", { style: S.childrenColumn, children: node.children.map((child, idx) => (0, react_jsx_runtime.jsx)("div", {
984
1810
  key: child.id,
985
1811
  ref: (el) => {
986
1812
  childRefs.current[idx] = el;
987
1813
  },
988
- children: (0, react_jsx_runtime.jsx)(TreeRow, { node: child, theme }),
1814
+ children: (0, react_jsx_runtime.jsx)(TreeRow, { node: child, theme, onNodeContextMenu, reveal, selectedId, onCodePanel }),
989
1815
  }, child.id)) })
990
1816
  : null,
991
1817
  ] });
@@ -1035,7 +1861,7 @@ window.__ModuleLoader__.load({
1035
1861
  * 修正 scroll,视图不跳变(内容回到视口内时浏览器会自动钳制回 0)。
1036
1862
  */
1037
1863
  function MindmapCanvas(props) {
1038
- const { node, theme, fitKey } = props;
1864
+ const { node, theme, fitKey, reveal } = props;
1039
1865
  const scrollRef = react.useRef(null);
1040
1866
  const contentRef = react.useRef(null);
1041
1867
  const zoomRef = react.useRef(1);
@@ -1059,6 +1885,29 @@ window.__ModuleLoader__.load({
1059
1885
  const fitStampRef = react.useRef([]);
1060
1886
  const [zoom, setZoomState] = react.useState(1);
1061
1887
  const [hover, setHover] = react.useState(null);
1888
+ // 017 节点右键菜单:{x, y, node};null = 关闭。busy = "copy" |
1889
+ // "export" 表示对应动作进行中(两项都禁用),error 展示失败原因。
1890
+ const [nodeMenu, setNodeMenu] = react.useState(null);
1891
+ const [nodeMenuBusy, setNodeMenuBusy] = react.useState(null);
1892
+ const [nodeMenuError, setNodeMenuError] = react.useState("");
1893
+ const nodeMenuRef = react.useRef(null);
1894
+ // 019 选中态:点击聚焦的节点下选选中环(002 §6 状态体系)。
1895
+ const [selectedId, setSelectedId] = react.useState(null);
1896
+ // 019 代码块悬停浮层:{node, anchor};null = 关闭。延迟关闭(150ms
1897
+ // 宽限)让鼠标能从节点盒移到面板上滚动全文,不闪灭。
1898
+ const [codePanel, setCodePanel] = react.useState(null);
1899
+ const codePanelTimerRef = react.useRef(null);
1900
+ function handleCodePanel(panel) {
1901
+ if (codePanelTimerRef.current) {
1902
+ clearTimeout(codePanelTimerRef.current);
1903
+ codePanelTimerRef.current = null;
1904
+ }
1905
+ if (panel) {
1906
+ setCodePanel(panel);
1907
+ return;
1908
+ }
1909
+ codePanelTimerRef.current = setTimeout(() => setCodePanel(null), 150);
1910
+ }
1062
1911
 
1063
1912
  // 测量并适配:自然尺寸 = getBoundingClientRect ÷ 已提交 zoom(与
1064
1913
  // DOM 实际状态严格同步,无竞态)。值不变不动 state(bail-out),
@@ -1179,7 +2028,13 @@ window.__ModuleLoader__.load({
1179
2028
  const target = e.target;
1180
2029
  if (!target || typeof target.closest !== "function") return;
1181
2030
  const boxEl = target.closest("[data-mindmap-node]");
1182
- if (!boxEl || !boxEl.isConnected) return;
2031
+ if (!boxEl || !boxEl.isConnected) {
2032
+ // 019 空白处点击:取消选中环(链接点击已 stopPropagation,不走这里)。
2033
+ setSelectedId(null);
2034
+ return;
2035
+ }
2036
+ // 019:聚焦同时记选中态(盒包裹上挂了 data-mindmap-node-id)。
2037
+ setSelectedId(boxEl.getAttribute("data-mindmap-node-id"));
1183
2038
  const rowEl = boxEl.closest("[data-mindmap-row]");
1184
2039
  if (!rowEl) return;
1185
2040
  const scroller = scrollRef.current;
@@ -1213,6 +2068,61 @@ window.__ModuleLoader__.load({
1213
2068
  scroller.scrollTop += curY - scroller.clientHeight / 2;
1214
2069
  }
1215
2070
 
2071
+ // 017 右键菜单开合:点其它地方/失焦/改窗口即关闭(目录树菜单同款);
2072
+ // 点菜单内部(复制/导出按钮)不关——菜单里要展示「复制中…」与失败
2073
+ // 原因。节点右键经 stopPropagation 不会触发这里的 contextmenu 关闭,
2074
+ // 空白处右键则顺带收掉旧菜单。文档内容变化(树重解析、节点对象
2075
+ // 失效)也关闭,防导出过期子树。
2076
+ react.useEffect(() => {
2077
+ if (!nodeMenu) return;
2078
+ const close = (e) => {
2079
+ if (e && e.type === "click" && nodeMenuRef.current && e.target && nodeMenuRef.current.contains(e.target)) return;
2080
+ setNodeMenu(null);
2081
+ };
2082
+ window.addEventListener("click", close);
2083
+ window.addEventListener("blur", close);
2084
+ window.addEventListener("resize", close);
2085
+ window.addEventListener("contextmenu", close);
2086
+ return () => {
2087
+ window.removeEventListener("click", close);
2088
+ window.removeEventListener("blur", close);
2089
+ window.removeEventListener("resize", close);
2090
+ window.removeEventListener("contextmenu", close);
2091
+ };
2092
+ }, [nodeMenu]);
2093
+ react.useEffect(() => {
2094
+ setNodeMenu(null);
2095
+ // 019:文档内容变化时同步收掉代码浮层(节点对象已失效)。
2096
+ setCodePanel(null);
2097
+ }, [node]);
2098
+
2099
+ // 017 右键节点:记录菜单锚点与目标子树(清掉上次的忙碌/错误态)。
2100
+ function onNodeContextMenu(e, target) {
2101
+ setNodeMenuBusy(null);
2102
+ setNodeMenuError("");
2103
+ setNodeMenu({ x: e.clientX, y: e.clientY, node: target });
2104
+ }
2105
+
2106
+ // 017 菜单动作:text = 节点全文写剪贴板(020);copy = PNG 写系统剪贴板
2107
+ // (可粘贴到聊天/文档);export = PNG 下载为文件。图片范围 = 该节点
2108
+ // 及其全部子孙(buildExportSvg 以任意节点为根重排布局,根样式随深度判定)。
2109
+ async function onNodeMenuAction(mode) {
2110
+ const target = nodeMenu && nodeMenu.node;
2111
+ if (!target || nodeMenuBusy) return;
2112
+ setNodeMenuBusy(mode);
2113
+ setNodeMenuError("");
2114
+ try {
2115
+ if (mode === "text") await copyPlainText(nodeFullText(target));
2116
+ else if (mode === "copy") await copyPng(target, theme && theme.colorTheme);
2117
+ else await exportPng(target, target.topic, theme && theme.colorTheme);
2118
+ setNodeMenu(null);
2119
+ } catch (error) {
2120
+ setNodeMenuError(String(error?.message ?? error));
2121
+ } finally {
2122
+ setNodeMenuBusy(null);
2123
+ }
2124
+ }
2125
+
1216
2126
  // 边界反馈:到达上下限时对应按钮置灰(fit 值与边界精确相等时也命中)。
1217
2127
  const atMin = zoom <= ZOOM.min;
1218
2128
  const atMax = zoom >= ZOOM.max;
@@ -1220,11 +2130,15 @@ window.__ModuleLoader__.load({
1220
2130
  ? { ...S.zoomBtn, ...S.zoomBtnDisabled }
1221
2131
  : (hover === key ? { ...S.zoomBtn, ...S.zoomBtnHover } : S.zoomBtn));
1222
2132
 
1223
- return (0, react_jsx_runtime.jsxs)("div", { style: S.canvasWrap, children: [
1224
- (0, react_jsx_runtime.jsx)("div", { ref: scrollRef, style: S.canvasScroll, onClick: onCanvasClick, children:
1225
- (0, react_jsx_runtime.jsx)("div", { style: S.canvasCenter, children:
1226
- (0, react_jsx_runtime.jsx)("div", { ref: contentRef, style: { margin: "auto", zoom }, children:
1227
- (0, react_jsx_runtime.jsx)(TreeRow, { node, theme })
2133
+ return (0, react_jsx_runtime.jsxs)("div", {
2134
+ style: S.canvasWrap,
2135
+ // 017 空白处/缩放条右键只拦浏览器默认菜单(节点右键已 stopPropagation)。
2136
+ onContextMenu: (e) => e.preventDefault(),
2137
+ children: [
2138
+ (0, react_jsx_runtime.jsx)("div", { ref: scrollRef, style: S.canvasScroll, onClick: onCanvasClick, children:
2139
+ (0, react_jsx_runtime.jsx)("div", { style: S.canvasCenter, children:
2140
+ (0, react_jsx_runtime.jsx)("div", { ref: contentRef, style: { margin: "auto", zoom }, children:
2141
+ (0, react_jsx_runtime.jsx)(TreeRow, { node, theme, onNodeContextMenu, reveal, selectedId, onCodePanel: handleCodePanel })
1228
2142
  })
1229
2143
  })
1230
2144
  }),
@@ -1260,6 +2174,73 @@ window.__ModuleLoader__.load({
1260
2174
  children: "适配",
1261
2175
  }),
1262
2176
  ] }),
2177
+ // 017 节点右键菜单:标题行(节点主题)+ 复制全文/复制为图片/导出为
2178
+ // 图片三动作 + 错误行。复用目录树菜单容器样式(fixed 定位,left/top = 视口坐标)。
2179
+ nodeMenu ? (0, react_jsx_runtime.jsxs)("div", {
2180
+ ref: nodeMenuRef,
2181
+ style: { ...S.treeMenu, left: nodeMenu.x, top: nodeMenu.y },
2182
+ onContextMenu: (e) => e.preventDefault(),
2183
+ children: [
2184
+ (0, react_jsx_runtime.jsx)("div", {
2185
+ style: S.nodeMenuHeader,
2186
+ title: nodeMenu.node.topic || "待填写",
2187
+ children: truncateForExport(nodeMenu.node.topic, 18) || "待填写",
2188
+ }),
2189
+ // 020:标题与动作项之间拉一根分隔线。
2190
+ (0, react_jsx_runtime.jsx)("div", { style: S.nodeMenuDivider }),
2191
+ (0, react_jsx_runtime.jsx)("button", {
2192
+ type: "button",
2193
+ style: nodeMenuBusy ? { ...S.treeMenuItem, ...S.treeMenuItemDisabled } : S.treeMenuItem,
2194
+ disabled: Boolean(nodeMenuBusy),
2195
+ title: "把该节点自身内容的完整文本复制到剪贴板(截断块/代码块/表格块均取全文)",
2196
+ onClick: () => onNodeMenuAction("text"),
2197
+ children: nodeMenuBusy === "text" ? "复制中…" : "复制全文",
2198
+ }),
2199
+ (0, react_jsx_runtime.jsx)("button", {
2200
+ type: "button",
2201
+ style: nodeMenuBusy ? { ...S.treeMenuItem, ...S.treeMenuItemDisabled } : S.treeMenuItem,
2202
+ disabled: Boolean(nodeMenuBusy),
2203
+ title: "把该节点及其全部子孙渲染为 PNG 并复制到剪贴板",
2204
+ onClick: () => onNodeMenuAction("copy"),
2205
+ children: nodeMenuBusy === "copy" ? "复制中…" : "复制为图片",
2206
+ }),
2207
+ (0, react_jsx_runtime.jsx)("button", {
2208
+ type: "button",
2209
+ style: nodeMenuBusy ? { ...S.treeMenuItem, ...S.treeMenuItemDisabled } : S.treeMenuItem,
2210
+ disabled: Boolean(nodeMenuBusy),
2211
+ title: "把该节点及其全部子孙渲染为 PNG 并下载为文件",
2212
+ onClick: () => onNodeMenuAction("export"),
2213
+ children: nodeMenuBusy === "export" ? "导出中…" : "导出为图片",
2214
+ }),
2215
+ nodeMenuError ? (0, react_jsx_runtime.jsx)("p", { style: S.nodeMenuError, children: nodeMenuError }) : null,
2216
+ ],
2217
+ }) : null,
2218
+ // 019 代码块 / 020 截断散文块 悬停浮层:position:fixed 挂在画布外层
2219
+ // (不进 zoom 内容,不影响行测量)。恒贴节点盒右侧——溢出视口右缘
2220
+ // 也不翻左(翻左会盖住左侧内容、位置跳):用户点节点自动聚焦,
2221
+ // 节点进视口后再悬停即看全(020 作者拍板)。可滚动全文;代码走等宽
2222
+ // pre,散文走等线 pre-wrap 且行内链接可点。
2223
+ codePanel ? (() => {
2224
+ const panelH = 340;
2225
+ const anchor = codePanel.anchor || { left: 0, right: 0, top: 0 };
2226
+ const left = anchor.right + 8;
2227
+ const top = Math.min(anchor.top, Math.max(8, window.innerHeight - panelH - 8));
2228
+ const pnode = codePanel.node;
2229
+ const isCode = pnode.kind === "code";
2230
+ const label = isCode
2231
+ ? ((pnode.data && pnode.data.lang) || "code")
2232
+ : `${({ text: "文本块", md: "Markdown 块", list: "列表块", quote: "引用块" })[pnode.kind] || pnode.kind} · 全文`;
2233
+ return (0, react_jsx_runtime.jsxs)("div", {
2234
+ style: { ...(isCode ? S.codePanel : S.textPanel), left, top },
2235
+ onMouseLeave: () => handleCodePanel(null),
2236
+ children: [
2237
+ (0, react_jsx_runtime.jsx)("p", { style: S.codePanelLang, children: label }),
2238
+ isCode
2239
+ ? (0, react_jsx_runtime.jsx)("pre", { style: S.codePanelCode, children: (pnode.data && pnode.data.code) || "" })
2240
+ : (0, react_jsx_runtime.jsx)("div", { style: S.textPanelBody, children: renderInline(pnode.topic, `panel-${pnode.id}`) }),
2241
+ ],
2242
+ });
2243
+ })() : null,
1263
2244
  ] });
1264
2245
  }
1265
2246
  //#endregion
@@ -1268,13 +2249,26 @@ window.__ModuleLoader__.load({
1268
2249
  // 014:面板与 M 按钮同槽位(conversation.session.header.actions),
1269
2250
  // 会话能力(sessionId/inputActions/nodes)与开合回调全部由 MindmapSlot
1270
2251
  // 经 props 直给(无桥、无 useSyncExternalStore)。
1271
- const { mindmapFace, open, sessionId, inputActions, nodes, onOpen, onClose } = props;
1272
- const docs = react.useMemo(() => reduceDocuments(nodes), [nodes]);
2252
+ const { mindmapFace, open, sessionId, inputActions, nodes, nodesVersion, onOpen, onClose } = props;
2253
+ // 016:nodesVersion(结构指纹)作副依赖——nodes 引用不变但内容已变
2254
+ // (新工具结果原地落地)时强制重算;docs 新引用带动 merged →
2255
+ // auto-open effect 重跑(对已消费事件幂等 no-op),面板必达展开。
2256
+ const docs = react.useMemo(() => reduceDocuments(nodes), [nodes, nodesVersion]);
1273
2257
  // 013:本地加载占位文档(左键点 .md 秒建 tab、内容为空),与快照文档
1274
2258
  // 合并显示;快照优先(AI 结果覆盖占位)。
1275
2259
  const [localDocs, setLocalDocs] = react.useState({});
1276
2260
  const merged = react.useMemo(() => mergeDocuments(docs, localDocs), [docs, localDocs]);
2261
+ // 016 加载态恢复(S2/S3 成因):openMindmap 点击时刻记录错误事件键
2262
+ // 基线——只有其后新出现的错误才归因到该次打开(matchDocError 的
2263
+ // sinceKeys);重试时基线随新占位重建(当前错误已含其中,不重复弹)。
2264
+ const localErrorBaseRef = react.useRef(null);
2265
+ // 016 看门狗:本地占位约 30s 无结果 → 超时态(提示 + 重试)。
2266
+ // AI 没调工具(S3)或任何未知成因卡住时的兜底恢复路径。
2267
+ const OPEN_TIMEOUT_MS = 30000;
2268
+ const [openTimedOut, setOpenTimedOut] = react.useState(false);
1277
2269
  // 014 overlay 宽度:localStorage 持久化,拖拽钳制 [280, 视口 80%]。
2270
+ // 窗口尺寸变化时持续钳制——只在挂载时压一次的话,窗口先放大→拖宽
2271
+ // 面板→再缩小会让面板保持旧像素宽,聊天区被挤没。
1278
2272
  const WIDTH_KEY = "dsh-mindmap.overlay-width";
1279
2273
  const [panelWidth, setPanelWidth] = react.useState(() => {
1280
2274
  try {
@@ -1286,9 +2280,15 @@ window.__ModuleLoader__.load({
1286
2280
  return Math.round(window.innerWidth * 0.42);
1287
2281
  });
1288
2282
  react.useEffect(() => {
1289
- if (panelWidth > Math.round(window.innerWidth * 0.8)) {
1290
- setPanelWidth(Math.round(window.innerWidth * 0.8));
1291
- }
2283
+ const clamp = () => {
2284
+ setPanelWidth((prev) => {
2285
+ const max = Math.round(window.innerWidth * 0.8);
2286
+ return prev > max ? max : prev;
2287
+ });
2288
+ };
2289
+ clamp();
2290
+ window.addEventListener("resize", clamp);
2291
+ return () => window.removeEventListener("resize", clamp);
1292
2292
  }, []);
1293
2293
  // 015 设置面板:没有本地拖拽记忆时,用 settings 里的默认宽度。
1294
2294
  react.useEffect(() => {
@@ -1312,7 +2312,7 @@ window.__ModuleLoader__.load({
1312
2312
  // 015 节点主题:面板每次打开、或设置总线 bump(设置页保存)时重读
1313
2313
  // settings——面板常驻不卸载,光靠 open 变化会漏掉「开着面板改设置」。
1314
2314
  const settingsStamp = react.useSyncExternalStore(settingsBus.subscribe, settingsBus.get);
1315
- const [theme, setTheme] = react.useState({ lineStyle: "elbow", cardStyle: "rounded", colorTheme: "ocean" });
2315
+ const [theme, setTheme] = react.useState({ lineStyle: "elbow", cardStyle: "rounded", colorTheme: "ocean", growthAnimation: true });
1316
2316
  react.useEffect(() => {
1317
2317
  if (!open) return;
1318
2318
  if (!mindmapFace || typeof mindmapFace.readSettings !== "function") return;
@@ -1322,6 +2322,8 @@ window.__ModuleLoader__.load({
1322
2322
  lineStyle: v.lineStyle === "curve" ? "curve" : "elbow",
1323
2323
  cardStyle: v.cardStyle === "square" ? "square" : "rounded",
1324
2324
  colorTheme: COLOR_THEMES[v.colorTheme] ? v.colorTheme : "ocean",
2325
+ // 018 生长动画开关:旧设置/读不到都默认开(!== false 语义)。
2326
+ growthAnimation: v.growthAnimation !== false,
1325
2327
  });
1326
2328
  }).catch(() => {
1327
2329
  // 读设置失败:保持当前主题
@@ -1432,6 +2434,47 @@ window.__ModuleLoader__.load({
1432
2434
  [doc && doc.content, doc && doc.rootTitle],
1433
2435
  );
1434
2436
 
2437
+ // 018 生长动画调度:新树与上一版(同 path)的稳定 id 集做 diff,只对新增/
2438
+ // 变化节点出渐显计划(planGrowthReveal 广度优先错峰、总时长 ≤ 2s);播完定时清空,
2439
+ // 节点转静态渲染——之后切 tab/重挂载不重播;连续更新时 cleanup 清旧定时器、
2440
+ // 新计划直接替换,不堆积。跨文档/首屏(prev path 不同或无旧集)= 全量生长。
2441
+ const prevIdsRef = react.useRef({ path: null, ids: null });
2442
+ const [reveal, setReveal] = react.useState(null);
2443
+ react.useEffect(() => {
2444
+ const path = doc ? doc.path : null;
2445
+ const prev = prevIdsRef.current;
2446
+ const prevIds = prev.path === path ? prev.ids : null;
2447
+ prevIdsRef.current = { path, ids: tree ? collectTreeIds(tree) : null };
2448
+ if (!tree || !theme.growthAnimation) {
2449
+ setReveal(null);
2450
+ return;
2451
+ }
2452
+ const plan = planGrowthReveal(tree, prevIds);
2453
+ setReveal(plan);
2454
+ if (!plan) return;
2455
+ const timer = setTimeout(() => setReveal(null), plan.totalMs + 50);
2456
+ return () => clearTimeout(timer);
2457
+ }, [tree, doc && doc.path, theme.growthAnimation]);
2458
+
2459
+ // 016 看门狗:当前显示本地占位(等待 AI 打开结果)时计时;doc 被快照
2460
+ // 结果覆盖 / 切走 / 重开(openMindmap 重建占位 → 新 doc 引用)时自动
2461
+ // 重置。超时转超时态,渲染重试入口。
2462
+ react.useEffect(() => {
2463
+ if (!doc || doc.op !== "local") {
2464
+ setOpenTimedOut(false);
2465
+ return;
2466
+ }
2467
+ setOpenTimedOut(false);
2468
+ const id = setTimeout(() => setOpenTimedOut(true), OPEN_TIMEOUT_MS);
2469
+ return () => clearTimeout(id);
2470
+ }, [doc]);
2471
+
2472
+ // 016:当前加载占位的归因错误(精确/小写路径匹配优先,点击时刻基线
2473
+ // 之后新出现的 latestError 兜底——host 抛错结果常无路径可归因)。
2474
+ const docError = doc && doc.op === "local" && localErrorBaseRef.current
2475
+ ? matchDocError(merged, doc.path, localErrorBaseRef.current)
2476
+ : null;
2477
+
1435
2478
  // AI 自动打开:create/open 代表用户明确的「创建 / 打开 / 查看」意图。
1436
2479
  // 无论面板当前是否收起,都展开并切到这次意图对应的文档;首次挂载的
1437
2480
  // 历史快照也照常显示最近一次打开的脑图,避免出现「AI 说已打开但面板没了」。
@@ -1475,7 +2518,7 @@ window.__ModuleLoader__.load({
1475
2518
  setExporting(true);
1476
2519
  setExportError("");
1477
2520
  try {
1478
- await exportPng(tree, doc.rootTitle);
2521
+ await exportPng(tree, doc.rootTitle, theme && theme.colorTheme);
1479
2522
  } catch (error) {
1480
2523
  setExportError(String(error?.message ?? error));
1481
2524
  } finally {
@@ -1521,6 +2564,9 @@ window.__ModuleLoader__.load({
1521
2564
  // 就位——AI 工具结果到达后同 path 覆盖占位,节点才渲染;随后用户接着
1522
2565
  // 说即可继续编辑(002 数据流不变:内容只来自 AI 工具结果)。
1523
2566
  function openMindmap(entry) {
2567
+ // 016:记录点击时刻的错误基线(errorByPath 与 latestError 的全部
2568
+ // 事件键)——只有其后新出现的错误才归因本次打开,旧错误不打扰。
2569
+ localErrorBaseRef.current = errorEventKeys(merged);
1524
2570
  const text = `用 mindmap_open 打开 ${relPathWithin(fsTree.cwd, entry.path, entry.name)}`;
1525
2571
  // ① 本地占位:脑图 tab 立即切过去、内容为空(op:"local" 触发加载态);
1526
2572
  // 新打开的脑图替换旧的那颗(单脑图模式)。
@@ -1565,8 +2611,17 @@ window.__ModuleLoader__.load({
1565
2611
  setFilledHint(text);
1566
2612
  }
1567
2613
 
2614
+ /** 016 加载态恢复:错误/超时后重试——重发打开指令并重启看门狗。 */
2615
+ function retryOpen() {
2616
+ if (!doc || doc.op !== "local") return;
2617
+ // openMindmap 无条件重发指令、重建本地占位(新 doc 引用 → 看门狗
2618
+ // 重启),并重建错误基线(当前错误已含其中,不会重复弹出)。
2619
+ openMindmap({ path: doc.path, name: `${stemOf(doc.path)}.md` });
2620
+ }
2621
+
1568
2622
  // 拉取一层目录(path 缺省 = 会话 cwd 根)。host 路由 /mindmap/api/tree
1569
2623
  // 只读;返回 {path, cwd, entries:[{name,path,isDir,hidden}], truncated}。
2624
+ // 返回 true/false 供调用方决定是否标记展开(失败时不要把目录标成已展开)。
1570
2625
  async function loadTree(path) {
1571
2626
  const key = path === undefined || path === null ? "" : path;
1572
2627
  setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: true }, error: null }));
@@ -1593,8 +2648,10 @@ window.__ModuleLoader__.load({
1593
2648
  loading: { ...prev.loading, [key]: false },
1594
2649
  };
1595
2650
  });
2651
+ return true;
1596
2652
  } catch (error) {
1597
2653
  setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: false }, error: String(error?.message ?? error) }));
2654
+ return false;
1598
2655
  }
1599
2656
  }
1600
2657
 
@@ -1607,7 +2664,11 @@ window.__ModuleLoader__.load({
1607
2664
  });
1608
2665
  return;
1609
2666
  }
1610
- if (!fsTree.nodes[entry.path]) await loadTree(entry.path);
2667
+ // 加载失败不标记展开:箭头/子项状态与真实数据保持一致。
2668
+ if (!fsTree.nodes[entry.path]) {
2669
+ const ok = await loadTree(entry.path);
2670
+ if (!ok) return;
2671
+ }
1611
2672
  setFsTree((prev) => ({ ...prev, expanded: { ...prev.expanded, [entry.path]: true } }));
1612
2673
  }
1613
2674
 
@@ -1762,29 +2823,47 @@ window.__ModuleLoader__.load({
1762
2823
  }, [treeMenu, tabMenu]);
1763
2824
 
1764
2825
  function renderLoading() {
2826
+ // 016 三态流转:加载中 →(错误 | 超时)——错误优先于超时;失败态
2827
+ // 提供「重试」一键重发打开指令(openMindmap 同款通路 + 降级链)。
2828
+ const failed = Boolean(docError) || openTimedOut;
2829
+ const message = docError
2830
+ ? `AI 打开失败:${docError.message}`
2831
+ : openTimedOut
2832
+ ? "等待 AI 打开超时(约 30 秒无结果)"
2833
+ : "AI 正在打开脑图…";
1765
2834
  return (0, react_jsx_runtime.jsxs)("div", { style: S.loadingWrap, children: [
1766
- (0, react_jsx_runtime.jsxs)("svg", { width: 22, height: 22, viewBox: "0 0 22 22", children: [
1767
- (0, react_jsx_runtime.jsx)("circle", { cx: 11, cy: 11, r: 8, fill: "none", stroke: "var(--dsw-alias-border-l2)", strokeWidth: 2 }),
1768
- (0, react_jsx_runtime.jsx)("circle", {
1769
- cx: 11,
1770
- cy: 11,
1771
- r: 8,
1772
- fill: "none",
1773
- stroke: "var(--dsw-alias-state-business-primary)",
1774
- strokeWidth: 2,
1775
- strokeLinecap: "round",
1776
- strokeDasharray: "12 38",
1777
- children: (0, react_jsx_runtime.jsx)("animateTransform", {
1778
- attributeName: "transform",
1779
- type: "rotate",
1780
- from: "0 11 11",
1781
- to: "360 11 11",
1782
- dur: "0.9s",
1783
- repeatCount: "indefinite",
2835
+ failed
2836
+ ? (0, react_jsx_runtime.jsx)("span", { style: S.loadingFailMark, children: "" })
2837
+ : (0, react_jsx_runtime.jsxs)("svg", { width: 22, height: 22, viewBox: "0 0 22 22", children: [
2838
+ (0, react_jsx_runtime.jsx)("circle", { cx: 11, cy: 11, r: 8, fill: "none", stroke: "var(--dsw-alias-border-l2)", strokeWidth: 2 }),
2839
+ (0, react_jsx_runtime.jsx)("circle", {
2840
+ cx: 11,
2841
+ cy: 11,
2842
+ r: 8,
2843
+ fill: "none",
2844
+ stroke: "var(--dsw-alias-state-business-primary)",
2845
+ strokeWidth: 2,
2846
+ strokeLinecap: "round",
2847
+ strokeDasharray: "12 38",
2848
+ children: (0, react_jsx_runtime.jsx)("animateTransform", {
2849
+ attributeName: "transform",
2850
+ type: "rotate",
2851
+ from: "0 11 11",
2852
+ to: "360 11 11",
2853
+ dur: "0.9s",
2854
+ repeatCount: "indefinite",
2855
+ }),
1784
2856
  }),
1785
- }),
1786
- ] }),
1787
- (0, react_jsx_runtime.jsx)("p", { style: S.loadingText, children: "AI 正在打开脑图…" }),
2857
+ ] }),
2858
+ (0, react_jsx_runtime.jsx)("p", { style: failed ? S.loadingErrorText : S.loadingText, children: message }),
2859
+ failed
2860
+ ? (0, react_jsx_runtime.jsx)("button", {
2861
+ type: "button",
2862
+ style: S.retryBtn,
2863
+ onClick: retryOpen,
2864
+ children: "重试打开",
2865
+ })
2866
+ : null,
1788
2867
  (0, react_jsx_runtime.jsx)("p", { style: S.emptyHint, children: "若长时间未打开,可直接在聊天里说「打开 <文件名>」" }),
1789
2868
  ] });
1790
2869
  }
@@ -1933,7 +3012,7 @@ window.__ModuleLoader__.load({
1933
3012
  : (doc && doc.op === "local")
1934
3013
  ? renderLoading()
1935
3014
  : renderTree() })
1936
- : (0, react_jsx_runtime.jsx)(MindmapCanvas, { node: tree, theme, fitKey: doc && doc.path }),
3015
+ : (0, react_jsx_runtime.jsx)(MindmapCanvas, { node: tree, theme, fitKey: doc && doc.path, reveal }),
1937
3016
  tabMenu ? (0, react_jsx_runtime.jsxs)("div", {
1938
3017
  style: { ...S.treeMenu, left: tabMenu.x, top: tabMenu.y },
1939
3018
  onContextMenu: (e) => e.preventDefault(),
@@ -1976,6 +3055,22 @@ window.__ModuleLoader__.load({
1976
3055
  document.head.appendChild(style);
1977
3056
  }
1978
3057
 
3058
+ // 018 生长动画:新增/变化节点错峰渐显(节点盒 = 淡入 + 左移浮现,
3059
+ // 连线 = 淡入;延迟由内联 animationDelay 提供)。fill mode both 保证
3060
+ // 延迟期间保持隐藏;动画只挂新节点,旧节点不受影响。尊重系统减弱动效。
3061
+ if (typeof document !== "undefined") {
3062
+ const animStyle = document.createElement("style");
3063
+ animStyle.setAttribute("data-dsh-mindmap", "growth-anim");
3064
+ animStyle.textContent = [
3065
+ "@keyframes dsh-mm-node-in{from{opacity:0;transform:translateX(-10px)}to{opacity:1;transform:none}}",
3066
+ ".dsh-mm-reveal{opacity:0;animation:dsh-mm-node-in 320ms ease-out both}",
3067
+ "@keyframes dsh-mm-fade-in{from{opacity:0}to{opacity:1}}",
3068
+ ".dsh-mm-edge-reveal{opacity:0;animation:dsh-mm-fade-in 320ms ease-out both}",
3069
+ "@media (prefers-reduced-motion: reduce){.dsh-mm-reveal,.dsh-mm-edge-reveal{animation:none;opacity:1}}",
3070
+ ].join("");
3071
+ document.head.appendChild(animStyle);
3072
+ }
3073
+
1979
3074
  // 013 目录树 tab:host 自建只读路由 /mindmap/api/tree(dsh-better-sidebar
1980
3075
  // 同款机制——官方 host.listDirectory 在 native picker 环境必挂,见 013)。
1981
3076
  // 客户端只读目录,仍无任何写文件通道。
@@ -2043,13 +3138,30 @@ window.__ModuleLoader__.load({
2043
3138
  mergeDocuments,
2044
3139
  autoOpenTarget,
2045
3140
  openingEventKeys,
3141
+ nodesFingerprint,
3142
+ matchDocError,
3143
+ errorEventKeys,
2046
3144
  resultTextOfBlocks,
2047
3145
  stemOf,
2048
3146
  buildExportSvg,
2049
3147
  createIdFactory,
3148
+ collectTreeIds,
3149
+ planGrowthReveal,
2050
3150
  relPathWithin,
2051
3151
  visibleTreeRows,
2052
- colorThemeTokens,
3152
+ // 019 皮肤层与血肉层纯函数(供测试)。
3153
+ resolveToken,
3154
+ resolveNodeStyle,
3155
+ exportPalette,
3156
+ hasInlineFormat,
3157
+ isTableSeparator,
3158
+ parseTableRow,
3159
+ nodeFullText,
3160
+ renderInline,
3161
+ stripInlineForExport,
3162
+ wrapExportText,
3163
+ COLOR_THEMES,
3164
+ TOKEN_REGISTRY,
2053
3165
  clampZoom,
2054
3166
  stepZoom,
2055
3167
  fitZoom,