dsh-mindmap 0.3.0 → 0.5.0

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;
133
+ }
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;
48
191
  }
49
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)" },
@@ -637,12 +1268,52 @@ window.__ModuleLoader__.load({
637
1268
  childrenColumn: { display: "flex", flexDirection: "column", gap: "8px", marginLeft: "40px", minWidth: 0 },
638
1269
  // 面板树连线层:正交折线(MarkGrove 的 orthogonalPath 风格),
639
1270
  // 覆盖整行、点击穿透、置于节点盒之下。
640
- edgeLayer: { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", pointerEvents: "none", overflow: "visible" },
641
- 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)" },
642
- rootBox: { fontWeight: 700, fontSize: "14px", border: "1px solid var(--dsw-alias-border-l2-darkmode-thin, #b9c0cc)", background: "var(--dsw-alias-bg-module-platform, #eef2ff)" },
643
- headingBox: { fontWeight: 600 },
644
- placeholderBox: { padding: "6px 12px", borderRadius: "10px", border: "1px dashed var(--dsw-alias-border-l2)", color: "var(--dsw-alias-label-tertiary)", background: "none" },
645
- codeBox: { fontFamily: "Menlo, monospace", fontSize: "12px" },
1271
+ // 016:去掉 CSS width/height 百分比——在 auto-height flex 行内,
1272
+ // 百分比高度解析为 auto 会让 SVG 坐标系塌缩,连线与节点像素错位。
1273
+ // 改为由 TreeRow measure 里同步记下实际像素,作 SVG 属性直传。
1274
+ edgeLayer: { position: "absolute", top: 0, left: 0, display: "block", pointerEvents: "none", overflow: "visible" },
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" },
1302
+ // 016 脑图画布:滚动区 + 居中层 + 右上角浮动缩放控制条。
1303
+ canvasWrap: { flex: "1 1 auto", minHeight: 0, minWidth: 0, position: "relative", display: "flex", flexDirection: "column" },
1304
+ canvasScroll: { flex: "1 1 auto", minHeight: 0, minWidth: 0, overflow: "auto" },
1305
+ // 居中层:内容小则铺满视口(100%),大则撑到内容尺寸(max-content);
1306
+ // 子项用 margin:auto——空间充足双向居中,溢出时 margin 归零、从滚动
1307
+ // 原点排布(flexbox 溢出居中裁剪的标准解法,无左/上侧裁剪)。
1308
+ canvasCenter: { display: "flex", width: "100%", height: "100%", minWidth: "max-content", minHeight: "max-content", boxSizing: "border-box", padding: "16px" },
1309
+ // 缩放控制条:绝对定位于 canvasWrap(滚动区外,不随内容滚动)。
1310
+ zoomBar: { position: "absolute", top: "10px", right: "12px", zIndex: 5, display: "inline-flex", alignItems: "center", gap: "2px", padding: "3px", borderRadius: "8px", background: "var(--dsw-alias-bg-layer-3)", border: "1px solid var(--dsw-alias-border-l2)", boxShadow: "var(--dsw-shadow-lv2)" },
1311
+ zoomBtn: { border: "none", background: "none", cursor: "pointer", font: "inherit", fontSize: "13px", lineHeight: "20px", height: "22px", minWidth: "22px", padding: "0 4px", borderRadius: "6px", color: "var(--dsw-alias-label-secondary)", display: "inline-flex", alignItems: "center", justifyContent: "center", flex: "none" },
1312
+ zoomBtnHover: { background: "var(--dsw-alias-interactive-bg-hover)", color: "var(--dsw-alias-label-primary)" },
1313
+ zoomBtnDisabled: { opacity: 0.4, cursor: "default" },
1314
+ // 百分比标签:tabular-nums 防数字抖动。
1315
+ zoomLabel: { flex: "none", minWidth: "38px", textAlign: "center", fontSize: "11px", lineHeight: "20px", color: "var(--dsw-alias-label-secondary)", fontVariantNumeric: "tabular-nums", userSelect: "none" },
1316
+ zoomFitBtn: { border: "none", background: "none", cursor: "pointer", font: "inherit", fontSize: "12px", lineHeight: "20px", height: "22px", padding: "0 8px", borderRadius: "6px", color: "var(--dsw-alias-label-secondary)", flex: "none" },
646
1317
  };
647
1318
 
648
1319
  /** 015 分段选择控件(线型/卡片风格)。 */
@@ -682,6 +1353,8 @@ window.__ModuleLoader__.load({
682
1353
  cardStyle: v && v.cardStyle === "square" ? "square" : "rounded",
683
1354
  colorTheme: v && COLOR_THEMES[v.colorTheme] ? v.colorTheme : "ocean",
684
1355
  defaultPanelWidth: v && typeof v.defaultPanelWidth === "number" ? v.defaultPanelWidth : 42,
1356
+ // 018:旧设置/读不到都默认开(!== false 语义)。
1357
+ growthAnimation: !(v && v.growthAnimation === false),
685
1358
  });
686
1359
  } catch (err) {
687
1360
  if (alive) setError(String(err?.message ?? err));
@@ -755,7 +1428,8 @@ window.__ModuleLoader__.load({
755
1428
  { value: "sunset", label: "落日橙" },
756
1429
  { value: "forest", label: "森林绿" },
757
1430
  ].map((t) => {
758
- const tokens = colorThemeTokens(t.value);
1431
+ // 019:色点读令牌表(该主题的强标题强调色)。
1432
+ const dot = resolveToken("color.accent.heading.strong", COLOR_THEMES[t.value]);
759
1433
  return (0, react_jsx_runtime.jsxs)("button", {
760
1434
  key: t.value,
761
1435
  type: "button",
@@ -763,7 +1437,7 @@ window.__ModuleLoader__.load({
763
1437
  disabled: saving,
764
1438
  onClick: () => setField({ colorTheme: t.value }),
765
1439
  children: [
766
- (0, react_jsx_runtime.jsx)("span", { style: { ...S.swatchDot, background: tokens.heading } }),
1440
+ (0, react_jsx_runtime.jsx)("span", { style: { ...S.swatchDot, background: dot } }),
767
1441
  t.label,
768
1442
  ],
769
1443
  }, t.value);
@@ -787,6 +1461,17 @@ window.__ModuleLoader__.load({
787
1461
  }),
788
1462
  ] }),
789
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 秒);关闭则整棵树立刻完整显示。" }),
790
1475
  ] }),
791
1476
  saving ? (0, react_jsx_runtime.jsx)("p", { style: S.settingsHint, children: "保存中…" }) : null,
792
1477
  notice ? (0, react_jsx_runtime.jsx)("p", { style: S.settingsNotice, children: notice }) : null,
@@ -803,6 +1488,11 @@ window.__ModuleLoader__.load({
803
1488
  function MindmapSlot(props) {
804
1489
  const { useSession, sessionId, inputActions, mindmapFace } = props;
805
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)) : "";
806
1496
  const [open, setOpen] = react.useState(false);
807
1497
  return (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [
808
1498
  (0, react_jsx_runtime.jsxs)("button", {
@@ -838,6 +1528,7 @@ window.__ModuleLoader__.load({
838
1528
  sessionId,
839
1529
  inputActions,
840
1530
  nodes,
1531
+ nodesVersion,
841
1532
  mindmapFace,
842
1533
  onOpen: () => setOpen(true),
843
1534
  onClose: () => setOpen(false),
@@ -845,36 +1536,180 @@ window.__ModuleLoader__.load({
845
1536
  ] });
846
1537
  }
847
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
+
848
1628
  function NodeBox(props) {
849
- const { node, theme } = props;
850
- // 015 节点主题:卡片圆角(圆角/直角)+ 颜色主题令牌(根盒/标题用主题色)。
851
- const tokens = colorThemeTokens(theme && theme.colorTheme);
852
- const radius = theme && theme.cardStyle === "square" ? 0 : 10;
853
- const style = node.kind === "root"
854
- ? { ...S.box, ...S.rootBox, borderRadius: radius, borderColor: tokens.rootBorder, background: tokens.rootBg }
855
- : node.kind === "heading"
856
- ? { ...S.box, ...S.headingBox, borderRadius: radius, color: tokens.heading }
857
- : node.kind === "placeholder"
858
- ? { ...S.placeholderBox, borderRadius: radius }
859
- : node.kind === "code"
860
- ? { ...S.box, ...S.codeBox, borderRadius: radius }
861
- : { ...S.box, borderRadius: radius };
862
- const title = node.data?.description
863
- ? `${node.topic}\n\n${node.data.description}`
864
- : node.data?.code
865
- ? `${node.topic}\n\n${node.data.code}`
866
- : node.topic;
867
- 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
+ });
868
1689
  }
869
1690
 
870
1691
  /** 左→右递归树:节点盒 + 右侧子节点列 + 连线层(015 支持折线/曲线两种线型)。 */
871
1692
  function TreeRow(props) {
872
- 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);
873
1701
  const rowRef = react.useRef(null);
874
1702
  const boxWrapRef = react.useRef(null);
875
1703
  const childRefs = react.useRef([]);
876
- const [edges, setEdges] = react.useState([]);
877
- const prevEdgesRef = react.useRef("");
1704
+ // 016:一次测量 = 连线 + 行本地尺寸,单键防抖。坐标全部换算到行
1705
+ // 「本地空间」(视觉像素 ÷ 缩放因子,由隐形探针实测),SVG 用
1706
+ // viewBox 把用户空间钉在本地空间——CSS zoom 新旧实现都精确对齐,
1707
+ // 且 SVG 盒子恒等于行的视觉尺寸(绝不撑出滚动区)。
1708
+ const [layout, setLayout] = react.useState({ edges: [], w: 0, h: 0 });
1709
+ const prevKeyRef = react.useRef("");
1710
+ // 缩放探针:本地 10×10(小于最小节点盒,永不溢出行),visibility
1711
+ // 隐藏。getBoundingClientRect 返回 10×zoom → 实测缩放因子。
1712
+ const probeRef = react.useRef(null);
878
1713
 
879
1714
  // 测量父盒右缘与各子节点包裹块的几何位置,画连线
880
1715
  // (折线 = M x1 y1 H midX V y2 H x2;曲线 = 贝塞尔水平切出);
@@ -882,28 +1717,35 @@ window.__ModuleLoader__.load({
882
1717
  react.useLayoutEffect(() => {
883
1718
  const rowEl = rowRef.current;
884
1719
  const boxEl = boxWrapRef.current;
885
- if (!rowEl || !boxEl) return;
1720
+ const probeEl = probeRef.current;
1721
+ if (!rowEl || !boxEl || !probeEl) return;
886
1722
  const curve = theme && theme.lineStyle === "curve";
887
1723
  const measure = () => {
1724
+ // 探针实测缩放因子(视觉/本地)——与 DOM 当前状态同步,
1725
+ // 对 CSS zoom 的新旧实现(渲染期缩放 / used 值缩放)都成立。
1726
+ const scale = probeEl.getBoundingClientRect().width / 10 || 1;
888
1727
  const rowRect = rowEl.getBoundingClientRect();
889
1728
  const boxRect = boxEl.getBoundingClientRect();
890
1729
  const next = [];
891
1730
  for (const ref of childRefs.current) {
892
1731
  if (!ref) continue;
893
1732
  const c = ref.getBoundingClientRect();
894
- const x1 = boxRect.right - rowRect.left;
895
- const y1 = boxRect.top - rowRect.top + boxRect.height / 2;
896
- const x2 = c.left - rowRect.left;
897
- const y2 = c.top - rowRect.top + c.height / 2;
1733
+ // 视觉像素 行本地坐标(SVG 用户空间 = 本地空间)。
1734
+ const x1 = (boxRect.right - rowRect.left) / scale;
1735
+ const y1 = (boxRect.top - rowRect.top + boxRect.height / 2) / scale;
1736
+ const x2 = (c.left - rowRect.left) / scale;
1737
+ const y2 = (c.top - rowRect.top + c.height / 2) / scale;
898
1738
  const midX = (x1 + x2) / 2;
899
1739
  next.push(curve
900
1740
  ? `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`
901
1741
  : `M ${x1} ${y1} H ${midX} V ${y2} H ${x2}`);
902
1742
  }
903
- const key = next.join("|");
904
- if (prevEdgesRef.current === key) return;
905
- prevEdgesRef.current = key;
906
- setEdges(next);
1743
+ const w = rowRect.width / scale;
1744
+ const h = rowRect.height / scale;
1745
+ const key = `${w.toFixed(2)}x${h.toFixed(2)}|${next.join("|")}`;
1746
+ if (prevKeyRef.current === key) return;
1747
+ prevKeyRef.current = key;
1748
+ setLayout({ edges: next, w, h });
907
1749
  };
908
1750
  measure();
909
1751
  let observer = null;
@@ -919,43 +1761,514 @@ window.__ModuleLoader__.load({
919
1761
  };
920
1762
  });
921
1763
 
922
- return (0, react_jsx_runtime.jsxs)("div", { ref: rowRef, style: { ...S.row, position: "relative" }, children: [
923
- edges.length > 0
1764
+ // 016 点击聚焦标记:row = 该节点的整棵子树边界(盒+子列),node = 节点盒本身;
1765
+ // 画布层用事件委托 closest 定位(递归树不逐层传回调)。
1766
+ return (0, react_jsx_runtime.jsxs)("div", { ref: rowRef, "data-mindmap-row": "", style: { ...S.row, position: "relative" }, children: [
1767
+ // 缩放探针(本地 10×10,隐形,点击穿透)。
1768
+ (0, react_jsx_runtime.jsx)("div", { ref: probeRef, style: { position: "absolute", top: 0, left: 0, width: 10, height: 10, visibility: "hidden", pointerEvents: "none" } }),
1769
+ layout.edges.length > 0 && layout.w > 0 && layout.h > 0
924
1770
  ? (0, react_jsx_runtime.jsx)("svg", {
925
- style: S.edgeLayer,
926
- children: edges.map((d, i) => (0, react_jsx_runtime.jsx)("path", {
1771
+ // width/height = 本地尺寸 → 视觉 = 本地×zoom,恒等于行尺寸;
1772
+ // viewBox 把用户空间钉在本地空间,路径坐标(本地)精确落位。
1773
+ width: layout.w,
1774
+ height: layout.h,
1775
+ viewBox: `0 0 ${layout.w} ${layout.h}`,
1776
+ preserveAspectRatio: "none",
1777
+ style: edgeRevealDelay !== undefined ? { ...S.edgeLayer, animationDelay: `${edgeRevealDelay}ms` } : S.edgeLayer,
1778
+ // 018:新子节点出现时连线同步浮现(延迟 = 最早新子节点的错峰)。
1779
+ className: edgeRevealDelay !== undefined ? "dsh-mm-edge-reveal" : undefined,
1780
+ children: layout.edges.map((d, i) => (0, react_jsx_runtime.jsx)("path", {
927
1781
  key: i,
928
1782
  d,
929
- stroke: "var(--dsw-alias-border-l2)",
930
- strokeWidth: 1.5,
1783
+ stroke: connectorColor,
1784
+ strokeWidth: connectorWidth,
931
1785
  fill: "none",
1786
+ // 016:CSS zoom 缩放下 strokeWidth 会被一并缩放,缩小后线变
1787
+ // 亚像素、模糊看不清;vectorEffect=non-scaling-stroke 让线宽
1788
+ // 保持不变(任何缩放下都是 1.5px 视觉宽度)。
1789
+ vectorEffect: "non-scaling-stroke",
932
1790
  }, i)),
933
1791
  })
934
1792
  : null,
935
- (0, react_jsx_runtime.jsx)("div", { ref: boxWrapRef, style: { flex: "0 0 auto" }, 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
+ }),
936
1808
  node.children && node.children.length > 0
937
1809
  ? (0, react_jsx_runtime.jsx)("div", { style: S.childrenColumn, children: node.children.map((child, idx) => (0, react_jsx_runtime.jsx)("div", {
938
1810
  key: child.id,
939
1811
  ref: (el) => {
940
1812
  childRefs.current[idx] = el;
941
1813
  },
942
- 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 }),
943
1815
  }, child.id)) })
944
1816
  : null,
945
- ] });
946
- }
1817
+ ] });
1818
+ }
1819
+
1820
+ //#region 016 脑图画布:居中呈现 + 缩放控制(右上角)
1821
+ // 缩放契约:范围 [0.25, 3],每级 ×1.2;适配计算四周留 48px 余量
1822
+ //(16px 视觉内距 + 经典滚动条占位,避免「适配→滚动条出现→视口变
1823
+ // 窄→再适配」的抖动循环)。
1824
+ const ZOOM = { min: 0.25, max: 3, step: 1.2, padding: 48, focusMax: 1 };
1825
+
1826
+ /** 缩放夹取:非有限值/≤0 回退 1,否则夹到 [min, max]。 */
1827
+ function clampZoom(value) {
1828
+ if (!Number.isFinite(value) || value <= 0) return 1;
1829
+ return Math.min(ZOOM.max, Math.max(ZOOM.min, value));
1830
+ }
1831
+
1832
+ /** 步进缩放:direction>0 放大(×step),否则缩小(÷step),结果夹取。 */
1833
+ function stepZoom(value, direction) {
1834
+ const base = clampZoom(value);
1835
+ return clampZoom(direction > 0 ? base * ZOOM.step : base / ZOOM.step);
1836
+ }
1837
+
1838
+ /** 适配比例:min((view-padding)/tree, 1) 再夹取——小图不放大、巨图夹下限;零/非法尺寸返回 1。 */
1839
+ function fitZoom(treeW, treeH, viewW, viewH) {
1840
+ if (!(treeW > 0) || !(treeH > 0) || !(viewW > 0) || !(viewH > 0)) return 1;
1841
+ return clampZoom(Math.min((viewW - ZOOM.padding) / treeW, (viewH - ZOOM.padding) / treeH, 1));
1842
+ }
1843
+
1844
+ /**
1845
+ * 子树聚焦比例:适配整棵子树(区别于全局适配,允许放大到 focusMax),
1846
+ * 叶子/小子树不会怼脸、巨子树夹下限;零/非法尺寸返回 1。
1847
+ */
1848
+ function focusZoom(treeW, treeH, viewW, viewH) {
1849
+ if (!(treeW > 0) || !(treeH > 0) || !(viewW > 0) || !(viewH > 0)) return 1;
1850
+ return clampZoom(Math.min((viewW - ZOOM.padding) / treeW, (viewH - ZOOM.padding) / treeH, ZOOM.focusMax));
1851
+ }
1852
+
1853
+ /**
1854
+ * 脑图画布:滚动区(canvasScroll)+ 居中层(canvasCenter,100%/max-content
1855
+ * 双下限)+ 缩放内容(margin:auto + CSS zoom)。zoom 用 CSS zoom 而非
1856
+ * transform:scale——它影响布局,滚动范围随缩放自动正确;TreeRow 连线是
1857
+ * getBoundingClientRect 相对测量,父子同因子缩放,几何保持一致。
1858
+ * 打开(fitKey=文档路径)时自动测量并应用适配比例;用户未手动缩放前
1859
+ * ResizeObserver 持续再适配(AI 编辑改树尺寸、面板拖宽);手动缩放后以
1860
+ * 用户为准,点「适配」或切换文档恢复自动。缩放时记录视口中心并按比例
1861
+ * 修正 scroll,视图不跳变(内容回到视口内时浏览器会自动钳制回 0)。
1862
+ */
1863
+ function MindmapCanvas(props) {
1864
+ const { node, theme, fitKey, reveal } = props;
1865
+ const scrollRef = react.useRef(null);
1866
+ const contentRef = react.useRef(null);
1867
+ const zoomRef = react.useRef(1);
1868
+ const userZoomedRef = react.useRef(false);
1869
+ const anchorRef = react.useRef(null);
1870
+ // 016:上次适配时的脑图自然尺寸(getBoundingClientRect / 当前 zoom)。
1871
+ // ResizeObserver 回调里对比当前自然尺寸,差异 ≤ 2px 视为「滚动条抖动」
1872
+ // 引发的同树再测,跳过避免「适配→横向滚动条出现→视口变窄→再适配」
1873
+ // 的 33%/34%/30% 不停跳变。
1874
+ const lastNaturalRef = react.useRef(null);
1875
+ // 016 点击聚焦:记录待定位的节点盒元素。zoom 生效后的 [zoom]
1876
+ // layout effect 里量新布局,把节点滚到「水平 25% / 垂直居中」。
1877
+ const focusRef = react.useRef(null);
1878
+ // 016 已提交 zoom:DOM 真正渲染到的缩放值([zoom] layout effect
1879
+ // 里同步)。zoomRef 可能领先于渲染提交(setState 异步),测量
1880
+ // 一律除以 committed——否则「新 zoom ÷ 旧尺寸」得到错误自然
1881
+ // 尺寸,适配值来回跳、停不下来(跳闪根源)。
1882
+ const committedZoomRef = react.useRef(1);
1883
+ // 016 熔断器:观察器触发的适配时间戳。1.5s 内第 5 次 → 判定
1884
+ // 反馈循环,自动停手(保险丝,任何未知循环都最多闪几下)。
1885
+ const fitStampRef = react.useRef([]);
1886
+ const [zoom, setZoomState] = react.useState(1);
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
+ }
1911
+
1912
+ // 测量并适配:自然尺寸 = getBoundingClientRect ÷ 已提交 zoom(与
1913
+ // DOM 实际状态严格同步,无竞态)。值不变不动 state(bail-out),
1914
+ // 值变化才重置滚动到原点让树回到居中;同步记录自然尺寸供防抖。
1915
+ function applyFit() {
1916
+ const scroller = scrollRef.current;
1917
+ const content = contentRef.current;
1918
+ if (!scroller || !content) return;
1919
+ const rect = content.getBoundingClientRect();
1920
+ if (!(rect.width > 0) || !(rect.height > 0)) return;
1921
+ const committed = committedZoomRef.current;
1922
+ const naturalW = rect.width / committed;
1923
+ const naturalH = rect.height / committed;
1924
+ lastNaturalRef.current = { w: naturalW, h: naturalH };
1925
+ const fit = fitZoom(naturalW, naturalH, scroller.clientWidth, scroller.clientHeight);
1926
+ zoomRef.current = fit;
1927
+ if (fit !== committed) {
1928
+ scroller.scrollLeft = 0;
1929
+ scroller.scrollTop = 0;
1930
+ setZoomState(fit);
1931
+ }
1932
+ }
1933
+
1934
+ // 挂载 / 文档切换:清除「用户已手动缩放」标记与熔断计数,
1935
+ // 布局稳定后(rAF)适配一次。
1936
+ react.useLayoutEffect(() => {
1937
+ userZoomedRef.current = false;
1938
+ lastNaturalRef.current = null;
1939
+ fitStampRef.current = [];
1940
+ const id = requestAnimationFrame(applyFit);
1941
+ return () => cancelAnimationFrame(id);
1942
+ }, [fitKey]);
1943
+
1944
+ // 内容 / 画布尺寸变化(AI 编辑、面板拖宽)→ 未手动缩放则再适配。
1945
+ // 区分触发源:仅内容变化(多为 zoom 引发的重排)时对比自然尺寸
1946
+ // (÷ 已提交 zoom),差异 ≤ 2px 视为「滚动条抖动/亚像素重测」跳过;
1947
+ // 画布变化(拖宽、滚动条出现)正常再适配。熔断器兜底:1.5s 内
1948
+ // 第 5 次观察器适配 → 判定循环,自动停手(点适配/切文档可恢复)。
1949
+ react.useEffect(() => {
1950
+ const scroller = scrollRef.current;
1951
+ const content = contentRef.current;
1952
+ if (!scroller || !content || typeof ResizeObserver === "undefined") return;
1953
+ const observer = new ResizeObserver((entries) => {
1954
+ if (userZoomedRef.current) return;
1955
+ const scrollerChanged = entries.some((e) => e.target === scroller);
1956
+ if (!scrollerChanged) {
1957
+ const last = lastNaturalRef.current;
1958
+ if (last) {
1959
+ const rect = content.getBoundingClientRect();
1960
+ const w = rect.width / committedZoomRef.current;
1961
+ const h = rect.height / committedZoomRef.current;
1962
+ if (Math.abs(w - last.w) <= 2 && Math.abs(h - last.h) <= 2) return;
1963
+ }
1964
+ }
1965
+ const now = Date.now();
1966
+ const stamps = fitStampRef.current = fitStampRef.current.filter((t) => now - t < 1500);
1967
+ if (stamps.length >= 5) {
1968
+ userZoomedRef.current = true;
1969
+ return;
1970
+ }
1971
+ stamps.push(now);
1972
+ applyFit();
1973
+ });
1974
+ observer.observe(content);
1975
+ observer.observe(scroller);
1976
+ return () => observer.disconnect();
1977
+ }, []);
1978
+
1979
+ function setZoom(next, anchorViewport) {
1980
+ const value = clampZoom(next);
1981
+ anchorRef.current = anchorViewport && scrollRef.current
1982
+ ? { prevZoom: zoomRef.current, scrollLeft: scrollRef.current.scrollLeft, scrollTop: scrollRef.current.scrollTop }
1983
+ : null;
1984
+ zoomRef.current = value;
1985
+ setZoomState(value);
1986
+ }
1987
+
1988
+ // 用户缩放后保持视口中心稳定:内容坐标按 new/old 比例缩放,scroll 同步修正。
1989
+ // fit 路径 anchorRef 为 null,不锚定。
1990
+ react.useLayoutEffect(() => {
1991
+ // DOM 已提交到该 zoom,测量换算基准同步(applyFit 依赖)。
1992
+ committedZoomRef.current = zoom;
1993
+ if (focusRef.current) {
1994
+ positionFocus();
1995
+ return;
1996
+ }
1997
+ const scroller = scrollRef.current;
1998
+ const anchor = anchorRef.current;
1999
+ if (!scroller || !anchor) return;
2000
+ anchorRef.current = null;
2001
+ const ratio = anchor.prevZoom > 0 ? zoom / anchor.prevZoom : 1;
2002
+ if (!(ratio > 0) || ratio === 1) return;
2003
+ scroller.scrollLeft = (anchor.scrollLeft + scroller.clientWidth / 2) * ratio - scroller.clientWidth / 2;
2004
+ scroller.scrollTop = (anchor.scrollTop + scroller.clientHeight / 2) * ratio - scroller.clientHeight / 2;
2005
+ }, [zoom]);
2006
+
2007
+ function zoomIn() {
2008
+ userZoomedRef.current = true;
2009
+ setZoom(stepZoom(zoomRef.current, 1), true);
2010
+ }
2011
+
2012
+ function zoomOut() {
2013
+ userZoomedRef.current = true;
2014
+ setZoom(stepZoom(zoomRef.current, -1), true);
2015
+ }
2016
+
2017
+ function refit() {
2018
+ userZoomedRef.current = false;
2019
+ fitStampRef.current = [];
2020
+ applyFit();
2021
+ }
2022
+
2023
+ // 016 点击节点聚焦:节点滚到「垂直居中、水平约 25%」(树向右生长,
2024
+ // 左侧锚点让子级铺满右侧视野),缩放比例取 focusZoom(整棵子树适配、
2025
+ // 上限 focusMax)。事件委托:closest 找节点盒与所在子树 row,无需给
2026
+ // 递归 TreeRow 传回调。聚焦视为用户手动缩放(停自动再适配)。
2027
+ function onCanvasClick(e) {
2028
+ const target = e.target;
2029
+ if (!target || typeof target.closest !== "function") return;
2030
+ const boxEl = target.closest("[data-mindmap-node]");
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"));
2038
+ const rowEl = boxEl.closest("[data-mindmap-row]");
2039
+ if (!rowEl) return;
2040
+ const scroller = scrollRef.current;
2041
+ if (!scroller) return;
2042
+ const current = zoomRef.current;
2043
+ const rowRect = rowEl.getBoundingClientRect();
2044
+ const focus = focusZoom(rowRect.width / current, rowRect.height / current, scroller.clientWidth, scroller.clientHeight);
2045
+ userZoomedRef.current = true;
2046
+ focusRef.current = { boxEl };
2047
+ if (focus !== current) {
2048
+ anchorRef.current = null;
2049
+ zoomRef.current = focus;
2050
+ setZoomState(focus);
2051
+ } else {
2052
+ positionFocus();
2053
+ }
2054
+ }
2055
+
2056
+ // 聚焦定位:DOM 更新后量节点盒当前位置,滚动增量 = 当前位置 − 期望位置
2057
+ //(scroll 增量与视口位移 1:1,浏览器自动钳制滚动范围;比例不变时同步调用)。
2058
+ function positionFocus() {
2059
+ const focus = focusRef.current;
2060
+ focusRef.current = null;
2061
+ const scroller = scrollRef.current;
2062
+ if (!scroller || !focus || !focus.boxEl || !focus.boxEl.isConnected) return;
2063
+ const boxRect = focus.boxEl.getBoundingClientRect();
2064
+ const scrollerRect = scroller.getBoundingClientRect();
2065
+ const curX = boxRect.left + boxRect.width / 2 - scrollerRect.left;
2066
+ const curY = boxRect.top + boxRect.height / 2 - scrollerRect.top;
2067
+ scroller.scrollLeft += curX - scroller.clientWidth * 0.25;
2068
+ scroller.scrollTop += curY - scroller.clientHeight / 2;
2069
+ }
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
+
2126
+ // 边界反馈:到达上下限时对应按钮置灰(fit 值与边界精确相等时也命中)。
2127
+ const atMin = zoom <= ZOOM.min;
2128
+ const atMax = zoom >= ZOOM.max;
2129
+ const zoomBtnStyle = (key, disabled) => (disabled
2130
+ ? { ...S.zoomBtn, ...S.zoomBtnDisabled }
2131
+ : (hover === key ? { ...S.zoomBtn, ...S.zoomBtnHover } : S.zoomBtn));
2132
+
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 })
2142
+ })
2143
+ })
2144
+ }),
2145
+ (0, react_jsx_runtime.jsxs)("div", { style: S.zoomBar, children: [
2146
+ (0, react_jsx_runtime.jsx)("button", {
2147
+ type: "button",
2148
+ title: "缩小",
2149
+ style: zoomBtnStyle("out", atMin),
2150
+ disabled: atMin,
2151
+ onClick: zoomOut,
2152
+ onMouseEnter: () => setHover("out"),
2153
+ onMouseLeave: () => setHover((h) => (h === "out" ? null : h)),
2154
+ children: "−",
2155
+ }),
2156
+ (0, react_jsx_runtime.jsx)("span", { style: S.zoomLabel, children: `${Math.round(zoom * 100)}%` }),
2157
+ (0, react_jsx_runtime.jsx)("button", {
2158
+ type: "button",
2159
+ title: "放大",
2160
+ style: zoomBtnStyle("in", atMax),
2161
+ disabled: atMax,
2162
+ onClick: zoomIn,
2163
+ onMouseEnter: () => setHover("in"),
2164
+ onMouseLeave: () => setHover((h) => (h === "in" ? null : h)),
2165
+ children: "+",
2166
+ }),
2167
+ (0, react_jsx_runtime.jsx)("button", {
2168
+ type: "button",
2169
+ title: "适配画布(重新计算合适比例)",
2170
+ style: hover === "fit" ? { ...S.zoomFitBtn, ...S.zoomBtnHover } : S.zoomFitBtn,
2171
+ onClick: refit,
2172
+ onMouseEnter: () => setHover("fit"),
2173
+ onMouseLeave: () => setHover((h) => (h === "fit" ? null : h)),
2174
+ children: "适配",
2175
+ }),
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,
2244
+ ] });
2245
+ }
2246
+ //#endregion
947
2247
 
948
2248
  function MindmapDetailsPanel(props) {
949
2249
  // 014:面板与 M 按钮同槽位(conversation.session.header.actions),
950
2250
  // 会话能力(sessionId/inputActions/nodes)与开合回调全部由 MindmapSlot
951
2251
  // 经 props 直给(无桥、无 useSyncExternalStore)。
952
- const { mindmapFace, open, sessionId, inputActions, nodes, onOpen, onClose } = props;
953
- 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]);
954
2257
  // 013:本地加载占位文档(左键点 .md 秒建 tab、内容为空),与快照文档
955
2258
  // 合并显示;快照优先(AI 结果覆盖占位)。
956
2259
  const [localDocs, setLocalDocs] = react.useState({});
957
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);
958
2269
  // 014 overlay 宽度:localStorage 持久化,拖拽钳制 [280, 视口 80%]。
2270
+ // 窗口尺寸变化时持续钳制——只在挂载时压一次的话,窗口先放大→拖宽
2271
+ // 面板→再缩小会让面板保持旧像素宽,聊天区被挤没。
959
2272
  const WIDTH_KEY = "dsh-mindmap.overlay-width";
960
2273
  const [panelWidth, setPanelWidth] = react.useState(() => {
961
2274
  try {
@@ -967,9 +2280,15 @@ window.__ModuleLoader__.load({
967
2280
  return Math.round(window.innerWidth * 0.42);
968
2281
  });
969
2282
  react.useEffect(() => {
970
- if (panelWidth > Math.round(window.innerWidth * 0.8)) {
971
- setPanelWidth(Math.round(window.innerWidth * 0.8));
972
- }
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);
973
2292
  }, []);
974
2293
  // 015 设置面板:没有本地拖拽记忆时,用 settings 里的默认宽度。
975
2294
  react.useEffect(() => {
@@ -993,7 +2312,7 @@ window.__ModuleLoader__.load({
993
2312
  // 015 节点主题:面板每次打开、或设置总线 bump(设置页保存)时重读
994
2313
  // settings——面板常驻不卸载,光靠 open 变化会漏掉「开着面板改设置」。
995
2314
  const settingsStamp = react.useSyncExternalStore(settingsBus.subscribe, settingsBus.get);
996
- 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 });
997
2316
  react.useEffect(() => {
998
2317
  if (!open) return;
999
2318
  if (!mindmapFace || typeof mindmapFace.readSettings !== "function") return;
@@ -1003,6 +2322,8 @@ window.__ModuleLoader__.load({
1003
2322
  lineStyle: v.lineStyle === "curve" ? "curve" : "elbow",
1004
2323
  cardStyle: v.cardStyle === "square" ? "square" : "rounded",
1005
2324
  colorTheme: COLOR_THEMES[v.colorTheme] ? v.colorTheme : "ocean",
2325
+ // 018 生长动画开关:旧设置/读不到都默认开(!== false 语义)。
2326
+ growthAnimation: v.growthAnimation !== false,
1006
2327
  });
1007
2328
  }).catch(() => {
1008
2329
  // 读设置失败:保持当前主题
@@ -1113,6 +2434,47 @@ window.__ModuleLoader__.load({
1113
2434
  [doc && doc.content, doc && doc.rootTitle],
1114
2435
  );
1115
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
+
1116
2478
  // AI 自动打开:create/open 代表用户明确的「创建 / 打开 / 查看」意图。
1117
2479
  // 无论面板当前是否收起,都展开并切到这次意图对应的文档;首次挂载的
1118
2480
  // 历史快照也照常显示最近一次打开的脑图,避免出现「AI 说已打开但面板没了」。
@@ -1156,7 +2518,7 @@ window.__ModuleLoader__.load({
1156
2518
  setExporting(true);
1157
2519
  setExportError("");
1158
2520
  try {
1159
- await exportPng(tree, doc.rootTitle);
2521
+ await exportPng(tree, doc.rootTitle, theme && theme.colorTheme);
1160
2522
  } catch (error) {
1161
2523
  setExportError(String(error?.message ?? error));
1162
2524
  } finally {
@@ -1202,6 +2564,9 @@ window.__ModuleLoader__.load({
1202
2564
  // 就位——AI 工具结果到达后同 path 覆盖占位,节点才渲染;随后用户接着
1203
2565
  // 说即可继续编辑(002 数据流不变:内容只来自 AI 工具结果)。
1204
2566
  function openMindmap(entry) {
2567
+ // 016:记录点击时刻的错误基线(errorByPath 与 latestError 的全部
2568
+ // 事件键)——只有其后新出现的错误才归因本次打开,旧错误不打扰。
2569
+ localErrorBaseRef.current = errorEventKeys(merged);
1205
2570
  const text = `用 mindmap_open 打开 ${relPathWithin(fsTree.cwd, entry.path, entry.name)}`;
1206
2571
  // ① 本地占位:脑图 tab 立即切过去、内容为空(op:"local" 触发加载态);
1207
2572
  // 新打开的脑图替换旧的那颗(单脑图模式)。
@@ -1246,8 +2611,17 @@ window.__ModuleLoader__.load({
1246
2611
  setFilledHint(text);
1247
2612
  }
1248
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
+
1249
2622
  // 拉取一层目录(path 缺省 = 会话 cwd 根)。host 路由 /mindmap/api/tree
1250
2623
  // 只读;返回 {path, cwd, entries:[{name,path,isDir,hidden}], truncated}。
2624
+ // 返回 true/false 供调用方决定是否标记展开(失败时不要把目录标成已展开)。
1251
2625
  async function loadTree(path) {
1252
2626
  const key = path === undefined || path === null ? "" : path;
1253
2627
  setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: true }, error: null }));
@@ -1274,8 +2648,10 @@ window.__ModuleLoader__.load({
1274
2648
  loading: { ...prev.loading, [key]: false },
1275
2649
  };
1276
2650
  });
2651
+ return true;
1277
2652
  } catch (error) {
1278
2653
  setFsTree((prev) => ({ ...prev, loading: { ...prev.loading, [key]: false }, error: String(error?.message ?? error) }));
2654
+ return false;
1279
2655
  }
1280
2656
  }
1281
2657
 
@@ -1288,7 +2664,11 @@ window.__ModuleLoader__.load({
1288
2664
  });
1289
2665
  return;
1290
2666
  }
1291
- 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
+ }
1292
2672
  setFsTree((prev) => ({ ...prev, expanded: { ...prev.expanded, [entry.path]: true } }));
1293
2673
  }
1294
2674
 
@@ -1443,29 +2823,47 @@ window.__ModuleLoader__.load({
1443
2823
  }, [treeMenu, tabMenu]);
1444
2824
 
1445
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 正在打开脑图…";
1446
2834
  return (0, react_jsx_runtime.jsxs)("div", { style: S.loadingWrap, children: [
1447
- (0, react_jsx_runtime.jsxs)("svg", { width: 22, height: 22, viewBox: "0 0 22 22", children: [
1448
- (0, react_jsx_runtime.jsx)("circle", { cx: 11, cy: 11, r: 8, fill: "none", stroke: "var(--dsw-alias-border-l2)", strokeWidth: 2 }),
1449
- (0, react_jsx_runtime.jsx)("circle", {
1450
- cx: 11,
1451
- cy: 11,
1452
- r: 8,
1453
- fill: "none",
1454
- stroke: "var(--dsw-alias-state-business-primary)",
1455
- strokeWidth: 2,
1456
- strokeLinecap: "round",
1457
- strokeDasharray: "12 38",
1458
- children: (0, react_jsx_runtime.jsx)("animateTransform", {
1459
- attributeName: "transform",
1460
- type: "rotate",
1461
- from: "0 11 11",
1462
- to: "360 11 11",
1463
- dur: "0.9s",
1464
- 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
+ }),
1465
2856
  }),
1466
- }),
1467
- ] }),
1468
- (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,
1469
2867
  (0, react_jsx_runtime.jsx)("p", { style: S.emptyHint, children: "若长时间未打开,可直接在聊天里说「打开 <文件名>」" }),
1470
2868
  ] });
1471
2869
  }
@@ -1606,13 +3004,15 @@ window.__ModuleLoader__.load({
1606
3004
  }, shown) : null,
1607
3005
  ] }),
1608
3006
  ] }),
1609
- (0, react_jsx_runtime.jsx)("div", { style: S.body, children: active === TREE_TAB
1610
- ? renderTree()
1611
- : (doc && doc.op === "local")
1612
- ? renderLoading()
1613
- : tree
1614
- ? (0, react_jsx_runtime.jsx)(TreeRow, { node: tree, theme })
1615
- : renderTree() }),
3007
+ // 016:脑图视图走 MindmapCanvas(自带滚动 + 居中 + 右上角缩放控制条),
3008
+ // 不再套 S.body(避免嵌套滚动容器与双重 padding);目录/加载/空态保持原样。
3009
+ active === TREE_TAB || (doc && doc.op === "local") || !tree
3010
+ ? (0, react_jsx_runtime.jsx)("div", { style: S.body, children: active === TREE_TAB
3011
+ ? renderTree()
3012
+ : (doc && doc.op === "local")
3013
+ ? renderLoading()
3014
+ : renderTree() })
3015
+ : (0, react_jsx_runtime.jsx)(MindmapCanvas, { node: tree, theme, fitKey: doc && doc.path, reveal }),
1616
3016
  tabMenu ? (0, react_jsx_runtime.jsxs)("div", {
1617
3017
  style: { ...S.treeMenu, left: tabMenu.x, top: tabMenu.y },
1618
3018
  onContextMenu: (e) => e.preventDefault(),
@@ -1655,6 +3055,22 @@ window.__ModuleLoader__.load({
1655
3055
  document.head.appendChild(style);
1656
3056
  }
1657
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
+
1658
3074
  // 013 目录树 tab:host 自建只读路由 /mindmap/api/tree(dsh-better-sidebar
1659
3075
  // 同款机制——官方 host.listDirectory 在 native picker 环境必挂,见 013)。
1660
3076
  // 客户端只读目录,仍无任何写文件通道。
@@ -1722,13 +3138,34 @@ window.__ModuleLoader__.load({
1722
3138
  mergeDocuments,
1723
3139
  autoOpenTarget,
1724
3140
  openingEventKeys,
3141
+ nodesFingerprint,
3142
+ matchDocError,
3143
+ errorEventKeys,
1725
3144
  resultTextOfBlocks,
1726
3145
  stemOf,
1727
3146
  buildExportSvg,
1728
3147
  createIdFactory,
3148
+ collectTreeIds,
3149
+ planGrowthReveal,
1729
3150
  relPathWithin,
1730
3151
  visibleTreeRows,
1731
- 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,
3165
+ clampZoom,
3166
+ stepZoom,
3167
+ fitZoom,
3168
+ focusZoom,
1732
3169
  TOOL_NAMES,
1733
3170
  OPENING_OPS,
1734
3171
  });