dsh-obsidian-notes 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -85,10 +85,14 @@ window.__ModuleLoader__.load({
85
85
  return null;
86
86
  }
87
87
 
88
- // ── 行内解析(code > img > embed > link > wikilink > strong > em,取最左匹配)──
88
+ // ── 行内解析(escape > code > img > embed > link > wikilink > strong > emph >
89
+ // strike > highlight > comment > autolink > barelink,取最左匹配;
90
+ // 同一位置按数组顺序)──
89
91
  // M7:双链/embed 分开捕获 target(m[1])、章节(m[2])、别名(m[3]),
90
92
  // 显示时别名优先、其次「target › 章节」,偏移各自映射到源码切片。
91
93
  const INLINE_PATTERNS = [
94
+ // 反斜杠转义:`\*` 等只显示被转义的字符本身(偏移只覆盖该字符,不含反斜杠)
95
+ ["escape", /\\([\\`*_{}\[\]()#+\-.!|>~=])/],
92
96
  ["code", /`([^`\n]+)`/],
93
97
  ["img", /!\[([^\]]*)\]\(([^)\s]+)\)/],
94
98
  ["embed", /!\[\[([^\]|#\n]*)(?:#([^\]|\n]*))?(?:\|([^\]\n]*))?\]\]/],
@@ -96,6 +100,13 @@ window.__ModuleLoader__.load({
96
100
  ["wiki", /\[\[([^\]|#\n]*)(?:#([^\]|\n]*))?(?:\|([^\]\n]*))?\]\]/],
97
101
  ["strong", /\*\*([^*\n]+)\*\*|__([^_\n]+)__/],
98
102
  ["em", /(^|[^*])\*([^*\n]+)\*(?!\*)|(^|[^_])_([^_\n]+)_(?!_)/],
103
+ ["strike", /~~([^~\n]+)~~/],
104
+ ["highlight", /==([^=\n]+)==/],
105
+ // Obsidian 注释 %%…%%:预览里不显示(单行;跨行注释留到 P2 block 阶段)
106
+ ["comment", /%%([^%\n]*)%%/],
107
+ ["autolink", /<(https?:[^\s<>]+)>/],
108
+ // 裸 URL:只吃 ASCII URL 字符,中文正文/中文标点自然截断(尾部标点下面再收一次)
109
+ ["barelink", /https?:\/\/[A-Za-z0-9\-._~:/?#@!$&'*+,;=%]+/],
99
110
  ];
100
111
  const patternIndex = (name) => INLINE_PATTERNS.findIndex((p) => p[0] === name);
101
112
 
@@ -201,21 +212,39 @@ window.__ModuleLoader__.load({
201
212
  if (m.index > 0) pushText(rest.slice(0, m.index));
202
213
 
203
214
  const contentLen = (() => {
204
- if (name === "code") return m[1].length;
205
- if (name === "img") return 0; // alt 不渲染为可选中文本
206
- if (name === "embed") return m[0].length; // 按钮/图占整个 ![[...]] 源码
207
- if (name === "link" || name === "wiki") return m[1].length;
208
- if (name === "strong") return (m[1] ?? m[2]).length;
209
- return (m[2] ?? m[4]).length; // em(m[0] 含前缀组)
215
+ switch (name) {
216
+ case "code": return m[1].length;
217
+ case "img": return 0; // alt 不渲染为可选中文本
218
+ case "embed": return m[0].length; // 按钮/图占整个 ![[...]] 源码
219
+ case "link":
220
+ case "wiki": return m[1].length;
221
+ case "strong": return (m[1] ?? m[2]).length;
222
+ case "em": return (m[2] ?? m[4]).length; // em(m[0] 含前缀组)
223
+ case "escape": return 1; // 只显示被转义的那一个字符
224
+ case "strike":
225
+ case "highlight": return m[1].length;
226
+ case "comment": return m[1].length;
227
+ case "autolink": return m[1].length;
228
+ case "barelink": return m[0].length;
229
+ default: return 0;
230
+ }
210
231
  })();
211
232
  const openLen = (() => {
212
- if (name === "code") return 1; // `
213
- if (name === "link") return 1; // [
214
- if (name === "wiki") return 2; // [[
215
- if (name === "embed") return 0; // 以整段 ![[...]] 作为可选中源码
216
- if (name === "strong") return 2; // ** 或 __
217
- if (name === "em") return m[0].length - contentLen - 1; // 前缀 + *
218
- return 0;
233
+ switch (name) {
234
+ case "code": return 1; // `
235
+ case "link": return 1; // [
236
+ case "wiki": return 2; // [[
237
+ case "embed": return 0; // 以整段 ![[...]] 作为可选中源码
238
+ case "strong": return 2; // ** 或 __
239
+ case "em": return m[0].length - contentLen - 1; // 前缀 + *
240
+ case "escape": return 1; // 反斜杠
241
+ case "strike":
242
+ case "highlight": return 2; // ~~ 或 ==
243
+ case "comment": return 2; // %%
244
+ case "autolink": return 1; // <
245
+ case "barelink": return 0;
246
+ default: return 0;
247
+ }
219
248
  })();
220
249
  const bodyStart = cur + openLen; // cur 已含 m.index
221
250
 
@@ -288,6 +317,63 @@ window.__ModuleLoader__.load({
288
317
  },
289
318
  wikilinkDisplayNodes(parts, m, cur, key, handlers),
290
319
  ));
320
+ } else if (name === "escape") {
321
+ // 反斜杠转义:只把被转义的字符渲染成文本(偏移只覆盖该字符,不含反斜杠)
322
+ const n = textNode(m[1], bodyStart, bodyStart + 1, `${keyBase}-x${keyIndex++}`, handlers);
323
+ if (n) nodes.push(n);
324
+ } else if (name === "strike" || name === "highlight") {
325
+ // ~~删除线~~ / ==高亮==:内容继续走行内解析(可嵌套粗体/链接/代码)
326
+ nodes.push(createElement(
327
+ name === "strike" ? "del" : "mark",
328
+ {
329
+ key: `${keyBase}-${name === "strike" ? "d" : "m"}${keyIndex++}`,
330
+ ...(name === "highlight" ? { className: "dsh-obs-mark" } : {}),
331
+ },
332
+ renderInline(m[1], handlers, `${keyBase}-${name}`, bodyStart),
333
+ ));
334
+ } else if (name === "comment") {
335
+ // Obsidian 的 %%注释%% 在预览里不显示:整段不产生节点(源码偏移留空,
336
+ // 不影响其它片段的单调性;选中范围也不会把它带进提问内容)。
337
+ } else if (name === "autolink") {
338
+ // <https://…> → 链接;显示文本是尖括号里的 URL(偏移不含尖括号)
339
+ const href = safeUrl(m[1]);
340
+ if (href === null) {
341
+ const n = textNode(m[0], cur, cur + m[0].length, `${keyBase}-al${keyIndex++}`, handlers);
342
+ if (n) nodes.push(n);
343
+ } else {
344
+ nodes.push(createElement("a", {
345
+ key: `${keyBase}-al${keyIndex++}`,
346
+ href,
347
+ target: "_blank",
348
+ rel: "noreferrer",
349
+ className: "dsh-obs-autolink",
350
+ }, textNode(m[1], bodyStart, bodyStart + m[1].length, `${keyBase}-alt${keyIndex}`, handlers)));
351
+ }
352
+ } else if (name === "barelink") {
353
+ // 裸 URL:尾部标点与不配对的右括号留给普通文本,不算进链接地址
354
+ const raw = m[0];
355
+ let url = raw;
356
+ while (url.length > 0 && /[.,;:!?'",。;:!?、]/.test(url[url.length - 1])) url = url.slice(0, -1);
357
+ while (url.endsWith(")") && (url.match(/\(/g) ?? []).length < (url.match(/\)/g) ?? []).length) {
358
+ url = url.slice(0, -1);
359
+ }
360
+ const href = safeUrl(url);
361
+ if (href === null) {
362
+ const n = textNode(raw, cur, cur + raw.length, `${keyBase}-bl${keyIndex++}`, handlers);
363
+ if (n) nodes.push(n);
364
+ } else {
365
+ nodes.push(createElement("a", {
366
+ key: `${keyBase}-bl${keyIndex++}`,
367
+ href,
368
+ target: "_blank",
369
+ rel: "noreferrer",
370
+ className: "dsh-obs-autolink",
371
+ }, textNode(url, cur, cur + url.length, `${keyBase}-blt${keyIndex}`, handlers)));
372
+ if (url.length < raw.length) {
373
+ const n = textNode(raw.slice(url.length), cur + url.length, cur + raw.length, `${keyBase}-bltail${keyIndex}`, handlers);
374
+ if (n) nodes.push(n);
375
+ }
376
+ }
291
377
  } else {
292
378
  const body = name === "strong" ? (m[1] ?? m[2]) : (m[2] ?? m[4]);
293
379
  nodes.push(createElement(
@@ -795,6 +881,8 @@ window.__ModuleLoader__.load({
795
881
  createElement("div", {
796
882
  key: `n${keyIdx++}`,
797
883
  className: `dsh-obs-md-row${rowClass ? ` ${rowClass}` : ""}`,
884
+ // 源码行号(1 起):思维导图「点节点跳到源码行」用它定位,测试不依赖
885
+ "data-line": num,
798
886
  ...(extraProps ?? {}),
799
887
  },
800
888
  createElement("span", { className: "dsh-obs-md-ln" }, String(num)),
@@ -1095,6 +1183,563 @@ window.__ModuleLoader__.load({
1095
1183
 
1096
1184
  const { renderMarkdown, renderNumbered } = renderMarkdownModule;
1097
1185
 
1186
+ /**
1187
+ * 思维导图(B1):把 Markdown 笔记的「标题 + 列表」解析成层级树,并做横向树布局。
1188
+ *
1189
+ * 纯函数、零 React 依赖:client 半用它渲染 SVG,测试直接断言树/布局/分支区间。
1190
+ * 只收标题(#~######)与列表项(- * + 1.);代码围栏内的内容不算;段落忽略。
1191
+ *
1192
+ * 设计取舍:
1193
+ * - 节点行号(line / endLine,1 起)是「点节点跳到源码行」与「针对分支提问」的锚;
1194
+ * 分支区间 = 该节点行到其子树最大行,因此引用分支时能把整段内容一起带进对话。
1195
+ * - 布局用横向树:x 由深度决定,y 由叶子顺序决定(父节点居中于子节点),
1196
+ * 节点矩形固定高度、宽度按列宽;长文本截断显示(完整文本在 title)。
1197
+ */
1198
+ const mindMapModule = (() => {
1199
+ /** 节点上限:超大笔记只画前 N 个,避免一次性渲染几万个矩形。 */
1200
+ const MAX_NODES = 400;
1201
+ /** 节点显示文本上限(超出截断,完整文本放 title 属性)。 */
1202
+ const MAX_LABEL = 80;
1203
+
1204
+ /** 缩进折算成列数:tab 按 4 空格(与 markdown.js 的列表层级口径一致)。 */
1205
+ function indentCols(s) {
1206
+ let n = 0;
1207
+ for (let k = 0; k < s.length; k += 1) n += s[k] === "\t" ? 4 : 1;
1208
+ return n;
1209
+ }
1210
+
1211
+ /** 行内标记剥掉后作为节点文本:`**粗**`、`[[A|别名]]`、`==高亮==` 等只留可读文字。 */
1212
+ function stripInlineMarkers(s) {
1213
+ let out = String(s ?? "");
1214
+ out = out.replace(/!\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g, "$1");
1215
+ out = out.replace(/\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]*))?\]\]/g, (m, target, alias) => (alias ? alias : target));
1216
+ out = out.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
1217
+ out = out.replace(/`([^`]*)`/g, "$1");
1218
+ out = out.replace(/\*\*([^*]+)\*\*/g, "$1");
1219
+ out = out.replace(/__([^_]+)__/g, "$1");
1220
+ out = out.replace(/~~([^~]+)~~/g, "$1");
1221
+ out = out.replace(/==([^=]+)==/g, "$1");
1222
+ out = out.replace(/\s*\^[A-Za-z0-9-]+\s*$/, "");
1223
+ return out.trim();
1224
+ }
1225
+
1226
+ /** 节点显示文本:剥标记 + 截断。 */
1227
+ function nodeLabel(s) {
1228
+ const text = stripInlineMarkers(s);
1229
+ return text.length > MAX_LABEL ? `${text.slice(0, MAX_LABEL - 1)}…` : text;
1230
+ }
1231
+
1232
+ /**
1233
+ * 解析笔记为树。
1234
+ * @param markdown - 笔记源码(正文,frontmatter 已由 host 剥离)。
1235
+ * @param options - `title` 作根节点文本(通常是笔记文件名);`maxNodes` 覆盖节点上限。
1236
+ * @returns 根节点 `{ id, kind: "root", text, line: 0, endLine, children }`。
1237
+ */
1238
+ function buildTree(markdown, options = {}) {
1239
+ const lines = String(markdown ?? "").replace(/\r\n/g, "\n").split("\n");
1240
+ const limit = Number.isInteger(options.maxNodes) && options.maxNodes > 0 ? options.maxNodes : MAX_NODES;
1241
+ const root = {
1242
+ id: "n0",
1243
+ kind: "root",
1244
+ text: nodeLabel(options.title ?? "笔记"),
1245
+ line: 0,
1246
+ endLine: lines.length,
1247
+ children: [],
1248
+ };
1249
+ let seq = 1;
1250
+ let headingStack = [];
1251
+ let itemStack = [];
1252
+ let inFence = false;
1253
+ let fenceChar = "";
1254
+ for (let i = 0; i < lines.length; i += 1) {
1255
+ const raw = lines[i];
1256
+ const lineNo = i + 1;
1257
+ const fence = /^ {0,3}(`{3,}|~{3,})/.exec(raw);
1258
+ if (fence) {
1259
+ if (!inFence) {
1260
+ inFence = true;
1261
+ fenceChar = fence[1][0];
1262
+ } else if (raw.trim().startsWith(fenceChar)) {
1263
+ inFence = false;
1264
+ }
1265
+ continue;
1266
+ }
1267
+ if (inFence) continue;
1268
+ if (seq >= limit) break;
1269
+
1270
+ const heading = /^(#{1,6})[ \t]+(.*)$/.exec(raw);
1271
+ if (heading) {
1272
+ const level = heading[1].length;
1273
+ const text = nodeLabel(heading[2].replace(/\s+#+\s*$/, ""));
1274
+ while (headingStack.length > 0 && headingStack[headingStack.length - 1].level >= level) headingStack.pop();
1275
+ itemStack = [];
1276
+ const parent = headingStack.length > 0 ? headingStack[headingStack.length - 1].node : root;
1277
+ const node = { id: `n${seq++}`, kind: "heading", level, text, line: lineNo, endLine: lineNo, children: [] };
1278
+ parent.children.push(node);
1279
+ headingStack.push({ node, level });
1280
+ continue;
1281
+ }
1282
+
1283
+ const item = /^([ \t]*)([-*+]|\d{1,9}[.)])[ \t]+([\s\S]*)$/.exec(raw);
1284
+ if (item) {
1285
+ const indent = indentCols(item[1]);
1286
+ while (itemStack.length > 0 && itemStack[itemStack.length - 1].indent >= indent) itemStack.pop();
1287
+ const body = item[3].replace(/^\[[ xX]\][ \t]+/, "");
1288
+ const text = nodeLabel(body);
1289
+ const parent = itemStack.length > 0
1290
+ ? itemStack[itemStack.length - 1].node
1291
+ : headingStack.length > 0
1292
+ ? headingStack[headingStack.length - 1].node
1293
+ : root;
1294
+ if (text === "") continue;
1295
+ const node = { id: `n${seq++}`, kind: "item", level: 0, text, line: lineNo, endLine: lineNo, children: [] };
1296
+ parent.children.push(node);
1297
+ itemStack.push({ node, indent });
1298
+ continue;
1299
+ }
1300
+
1301
+ // 空行结束当前列表;普通段落不是节点,但不清标题作用域。
1302
+ if (raw.trim() === "") itemStack = [];
1303
+ }
1304
+
1305
+ const computeEnd = (node) => {
1306
+ let end = node.line;
1307
+ for (const child of node.children) end = Math.max(end, computeEnd(child));
1308
+ node.endLine = end;
1309
+ return end;
1310
+ };
1311
+ computeEnd(root);
1312
+ root.endLine = lines.length;
1313
+ return root;
1314
+ }
1315
+
1316
+ /**
1317
+ * 横向树布局。
1318
+ * @param tree - {@link buildTree} 的根节点。
1319
+ * @param options - rowH/colW/padX/padY/nodeH 覆盖默认尺寸(测试可传小值)。
1320
+ * @returns `{ nodes, edges, width, height, byId }`;nodes 按 y/x 排序,byId 为 id → 节点。
1321
+ */
1322
+ function layout(tree, options = {}) {
1323
+ const rowH = options.rowH ?? 44;
1324
+ const colW = options.colW ?? 248;
1325
+ const padX = options.padX ?? 36;
1326
+ const padY = options.padY ?? 36;
1327
+ const nodeH = options.nodeH ?? 30;
1328
+ const nodes = [];
1329
+ const edges = [];
1330
+ let leafCursor = 0;
1331
+
1332
+ const walk = (node, depth) => {
1333
+ let centerY;
1334
+ if (node.children.length === 0) {
1335
+ centerY = leafCursor * rowH;
1336
+ leafCursor += 1;
1337
+ } else {
1338
+ const centers = node.children.map((child) => walk(child, depth + 1));
1339
+ centerY = (centers[0] + centers[centers.length - 1]) / 2;
1340
+ }
1341
+ nodes.push({
1342
+ id: node.id,
1343
+ kind: node.kind,
1344
+ text: node.text,
1345
+ line: node.line,
1346
+ endLine: node.endLine,
1347
+ depth,
1348
+ x: padX + depth * colW,
1349
+ y: padY + centerY,
1350
+ w: colW - 40,
1351
+ h: nodeH,
1352
+ });
1353
+ for (const child of node.children) edges.push({ from: node.id, to: child.id });
1354
+ return centerY;
1355
+ };
1356
+ walk(tree, 0);
1357
+
1358
+ const maxDepth = nodes.reduce((max, node) => Math.max(max, node.depth), 0);
1359
+ const byId = Object.fromEntries(nodes.map((node) => [node.id, node]));
1360
+ return {
1361
+ nodes: nodes.sort((a, b) => (a.y - b.y) || (a.x - b.x)),
1362
+ edges,
1363
+ byId,
1364
+ width: padX * 2 + maxDepth * colW + (colW - 40),
1365
+ height: Math.max(1, leafCursor) * rowH + padY * 2,
1366
+ };
1367
+ }
1368
+
1369
+ /**
1370
+ * 把「某节点 + 全部后代」映射回源码区间,供「针对分支提问」使用。
1371
+ * @param markdown - 笔记源码。
1372
+ * @param node - 树节点(需要 line/endLine,1 起)。
1373
+ * @returns `{ start, end, text, startLine, endLine }`;start/end 是整篇源码的字符偏移。
1374
+ */
1375
+ function branchSpan(markdown, node) {
1376
+ const norm = String(markdown ?? "").replace(/\r\n/g, "\n");
1377
+ const lines = norm.split("\n");
1378
+ const startLine = Math.max(1, node && node.line ? node.line : 1);
1379
+ const endLine = Math.max(startLine, node && node.endLine ? node.endLine : startLine);
1380
+ let start = 0;
1381
+ for (let i = 1; i < startLine && i <= lines.length; i += 1) start += lines[i - 1].length + 1;
1382
+ let end = start;
1383
+ const lastLine = Math.min(endLine, lines.length);
1384
+ for (let i = startLine; i <= lastLine; i += 1) {
1385
+ end += lines[i - 1].length;
1386
+ if (i < lastLine) end += 1;
1387
+ }
1388
+ return { start, end, text: norm.slice(start, end), startLine, endLine };
1389
+ }
1390
+
1391
+ return { buildTree, layout, branchSpan, nodeLabel, stripInlineMarkers, indentCols, MAX_NODES, MAX_LABEL };
1392
+ })();
1393
+
1394
+ /**
1395
+ * 关系图谱(Obsidian Graph view 的产品化版本,纯函数、零依赖):
1396
+ * - `layoutGraph(nodes, edges, opts)`:确定性力导向布局(同一 seed 结果可复现,
1397
+ * 便于截图/GIF 与测试);
1398
+ * - `localSubgraph(graph, noteId, depth)`:以某篇笔记为中心截 N 跳子图(Obsidian 的 local graph)。
1399
+ *
1400
+ * 节点/连线由 host 侧 `/obsidian/graph` 计算(全库 .md + [[双链]] + 标签),
1401
+ * 这里只负责「怎么摆」和「怎么截」。布局在 >maxNodes 时按度数保留最重要的节点,
1402
+ * 避免一次渲染上千个圆点卡住 UI。
1403
+ */
1404
+ const graphModule = (() => {
1405
+ /** 可复现的伪随机数(mulberry32):同一 seed 得到同一布局。 */
1406
+ function rng(seed) {
1407
+ let state = seed >>> 0;
1408
+ return () => {
1409
+ state = (state + 0x6d2b79f5) >>> 0;
1410
+ let t = state;
1411
+ t = Math.imul(t ^ (t >>> 15), t | 1);
1412
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
1413
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
1414
+ };
1415
+ }
1416
+
1417
+ /** 邻接表(双向),用于度数、局部子图与渲染高亮。 */
1418
+ function adjacency(nodes, edges) {
1419
+ const map = new Map(nodes.map((node) => [node.id, []]));
1420
+ for (const edge of edges) {
1421
+ if (!map.has(edge.source) || !map.has(edge.target)) continue;
1422
+ map.get(edge.source).push(edge.target);
1423
+ map.get(edge.target).push(edge.source);
1424
+ }
1425
+ return map;
1426
+ }
1427
+
1428
+ /**
1429
+ * 力导向布局。
1430
+ * @param nodes - `[{ id, kind, label, path? }]`。
1431
+ * @param edges - `[{ source, target }]`(id 对)。
1432
+ * @param options - width/height/iterations/maxNodes/seed。
1433
+ * @returns `{ nodes: [{id,x,y,degree}], width, height, truncated }`。
1434
+ */
1435
+ function layoutGraph(nodes, edges, options = {}) {
1436
+ const width = options.width ?? 1400;
1437
+ const height = options.height ?? 960;
1438
+ const iterations = options.iterations ?? 220;
1439
+ const maxNodes = options.maxNodes ?? 500;
1440
+ const seed = options.seed ?? 1;
1441
+ const padding = options.padding ?? 60;
1442
+ const rand = rng(seed);
1443
+
1444
+ const adj = adjacency(nodes, edges);
1445
+ let list = nodes;
1446
+ let links = edges;
1447
+ let truncated = false;
1448
+ if (nodes.length > maxNodes) {
1449
+ truncated = true;
1450
+ list = [...nodes]
1451
+ .sort((a, b) => (adj.get(b.id).length - adj.get(a.id).length) || String(a.id).localeCompare(String(b.id)))
1452
+ .slice(0, maxNodes);
1453
+ }
1454
+ const index = new Map(list.map((node, i) => [node.id, i]));
1455
+ links = links.filter((edge) => index.has(edge.source) && index.has(edge.target));
1456
+
1457
+ const n = list.length;
1458
+ const xs = new Float64Array(n);
1459
+ const ys = new Float64Array(n);
1460
+ const vx = new Float64Array(n);
1461
+ const vy = new Float64Array(n);
1462
+ const cx = width / 2;
1463
+ const cy = height / 2;
1464
+ // 初始点:圆周分布 + 抖动,避免完全对称的力平衡(那样图形会挤成一团)
1465
+ for (let i = 0; i < n; i += 1) {
1466
+ const angle = (i / Math.max(1, n)) * Math.PI * 2;
1467
+ const radius = Math.min(width, height) * 0.36 * (0.55 + rand() * 0.45);
1468
+ xs[i] = cx + Math.cos(angle) * radius;
1469
+ ys[i] = cy + Math.sin(angle) * radius;
1470
+ }
1471
+ const linkIndex = links.map((edge) => ({ a: index.get(edge.source), b: index.get(edge.target) }));
1472
+ const degree = new Int32Array(n);
1473
+ for (const link of linkIndex) {
1474
+ degree[link.a] += 1;
1475
+ degree[link.b] += 1;
1476
+ }
1477
+
1478
+ const repulse = 26000;
1479
+ const minDist = 26;
1480
+ const springLength = 120;
1481
+ const springK = 0.012;
1482
+ const gravity = 0.0035;
1483
+ for (let step = 0; step < iterations; step += 1) {
1484
+ // 斥力(O(n²),n 已被 maxNodes 限制)
1485
+ for (let i = 0; i < n; i += 1) {
1486
+ for (let j = i + 1; j < n; j += 1) {
1487
+ let dx = xs[i] - xs[j];
1488
+ let dy = ys[i] - ys[j];
1489
+ let dist2 = dx * dx + dy * dy;
1490
+ if (dist2 < 0.01) {
1491
+ dx = (rand() - 0.5) * 0.6;
1492
+ dy = (rand() - 0.5) * 0.6;
1493
+ dist2 = dx * dx + dy * dy;
1494
+ }
1495
+ if (dist2 < minDist * minDist) dist2 = minDist * minDist;
1496
+ const force = repulse / dist2;
1497
+ const dist = Math.sqrt(dist2);
1498
+ const fx = (dx / dist) * force;
1499
+ const fy = (dy / dist) * force;
1500
+ vx[i] += fx;
1501
+ vy[i] += fy;
1502
+ vx[j] -= fx;
1503
+ vy[j] -= fy;
1504
+ }
1505
+ }
1506
+ // 弹簧(边)
1507
+ for (const link of linkIndex) {
1508
+ const dx = xs[link.b] - xs[link.a];
1509
+ const dy = ys[link.b] - ys[link.a];
1510
+ const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
1511
+ const force = (dist - springLength) * springK;
1512
+ const fx = (dx / dist) * force;
1513
+ const fy = (dy / dist) * force;
1514
+ vx[link.a] += fx;
1515
+ vy[link.a] += fy;
1516
+ vx[link.b] -= fx;
1517
+ vy[link.b] -= fy;
1518
+ }
1519
+ // 向心力 + 阻尼积分
1520
+ for (let i = 0; i < n; i += 1) {
1521
+ vx[i] += (cx - xs[i]) * gravity;
1522
+ vy[i] += (cy - ys[i]) * gravity;
1523
+ vx[i] *= 0.82;
1524
+ vy[i] *= 0.82;
1525
+ const maxStep = 28;
1526
+ if (vx[i] > maxStep) vx[i] = maxStep;
1527
+ if (vx[i] < -maxStep) vx[i] = -maxStep;
1528
+ if (vy[i] > maxStep) vy[i] = maxStep;
1529
+ if (vy[i] < -maxStep) vy[i] = -maxStep;
1530
+ xs[i] += vx[i];
1531
+ ys[i] += vy[i];
1532
+ }
1533
+ }
1534
+
1535
+ // 归一化到画布(保留 padding),图形太小时水平居中撑开
1536
+ let minX = Infinity;
1537
+ let maxX = -Infinity;
1538
+ let minY = Infinity;
1539
+ let maxY = -Infinity;
1540
+ for (let i = 0; i < n; i += 1) {
1541
+ if (xs[i] < minX) minX = xs[i];
1542
+ if (xs[i] > maxX) maxX = xs[i];
1543
+ if (ys[i] < minY) minY = ys[i];
1544
+ if (ys[i] > maxY) maxY = ys[i];
1545
+ }
1546
+ const spanX = maxX - minX || 1;
1547
+ const spanY = maxY - minY || 1;
1548
+ const usableW = width - padding * 2;
1549
+ const usableH = height - padding * 2;
1550
+ const scale = Math.min(usableW / spanX, usableH / spanY, 1.8);
1551
+ const offsetX = padding + (usableW - spanX * scale) / 2;
1552
+ const offsetY = padding + (usableH - spanY * scale) / 2;
1553
+ const placed = list.map((node, i) => ({
1554
+ ...node,
1555
+ x: offsetX + (xs[i] - minX) * scale,
1556
+ y: offsetY + (ys[i] - minY) * scale,
1557
+ degree: degree[i],
1558
+ }));
1559
+ return { nodes: placed, width, height, truncated };
1560
+ }
1561
+
1562
+ /**
1563
+ * 以某节点为中心截 N 跳子图(Obsidian 的 local graph)。
1564
+ * @param graph - `{ nodes, edges }`(host 返回)。
1565
+ * @param noteId - 中心节点 id(笔记节点是 `note:<rel>`)。
1566
+ * @param depth - 跳数(默认 2)。
1567
+ * @returns `{ nodes, edges, found }`。
1568
+ */
1569
+ function localSubgraph(graph, noteId, depth = 2) {
1570
+ const nodes = graph && Array.isArray(graph.nodes) ? graph.nodes : [];
1571
+ const edges = graph && Array.isArray(graph.edges) ? graph.edges : [];
1572
+ const byId = new Map(nodes.map((node) => [node.id, node]));
1573
+ if (!byId.has(noteId)) return { nodes: [], edges: [], found: false };
1574
+ const adj = adjacency(nodes, edges);
1575
+ const seen = new Set([noteId]);
1576
+ let frontier = [noteId];
1577
+ for (let hop = 0; hop < depth; hop += 1) {
1578
+ const next = [];
1579
+ for (const id of frontier) {
1580
+ for (const other of adj.get(id) ?? []) {
1581
+ if (seen.has(other)) continue;
1582
+ seen.add(other);
1583
+ next.push(other);
1584
+ }
1585
+ }
1586
+ frontier = next;
1587
+ if (frontier.length === 0) break;
1588
+ }
1589
+ const keep = new Set([...seen].filter((id) => byId.has(id)));
1590
+ return {
1591
+ nodes: nodes.filter((node) => keep.has(node.id)),
1592
+ edges: edges.filter((edge) => keep.has(edge.source) && keep.has(edge.target)),
1593
+ found: true,
1594
+ };
1595
+ }
1596
+
1597
+
1598
+ /** POSIX 风格拼接 + 折叠 `.`/`..`(客户端兜底构建时与 host 同一套链接解析)。 */
1599
+ function joinPosix(dir, rel) {
1600
+ const parts = dir === "" ? [] : dir.split("/");
1601
+ for (const seg of rel.split("/")) {
1602
+ if (seg === "" || seg === ".") continue;
1603
+ if (seg === "..") parts.pop();
1604
+ else parts.push(seg);
1605
+ }
1606
+ return parts.join("/");
1607
+ }
1608
+
1609
+ /**
1610
+ * `[[链接目标]]` → 笔记相对路径(与 host 侧 / Obsidian 语义一致:精确路径 →
1611
+ * 相对当前笔记目录 → 文件名,多个候选取层数最浅再字母序)。
1612
+ * 客户端兜底构建图谱时用;解析不到返回 null。
1613
+ */
1614
+ function resolveGraphLink(target, fromRel, byPath, byBase) {
1615
+ let raw = String(target == null ? "" : target).trim();
1616
+ if (raw === "" || /^[a-z]+:\/\//i.test(raw)) return null;
1617
+ raw = raw.replace(/\\/g, "/");
1618
+ const stripExt = (value) => value.replace(/\.md$/i, "");
1619
+ const tryPath = (value) => byPath.get(stripExt(value).replace(/^\.\//, "").toLowerCase()) ?? null;
1620
+ let hit = tryPath(raw);
1621
+ if (hit !== null) return hit;
1622
+ if (raw.includes("/") || raw.startsWith(".")) {
1623
+ const dir = fromRel.includes("/") ? fromRel.slice(0, fromRel.lastIndexOf("/")) : "";
1624
+ hit = tryPath(joinPosix(dir, raw));
1625
+ if (hit !== null) return hit;
1626
+ }
1627
+ const base = stripExt(raw.split("/").pop()).toLowerCase();
1628
+ const list = byBase.get(base);
1629
+ if (list === undefined || list.length === 0) return null;
1630
+ if (list.length === 1) return list[0];
1631
+ return [...list].sort((a, b) => (a.split("/").length - b.split("/").length) || a.localeCompare(b))[0];
1632
+ }
1633
+
1634
+ /**
1635
+ * 客户端兜底:用「笔记列表 + 每篇的 links/tags」构建与 host 相同形状的图。
1636
+ * 用途:插件升级后 host 还是旧版(没有 /obsidian/graph 路由)时,前端仍能
1637
+ * 通过旧接口自建图谱 —— 用户无感知、不用重启 DSH。
1638
+ * @param notes - `[{ path, links, tags }]`(path 为 vault 相对路径)。
1639
+ * @returns `{ nodes, edges, stats }`。
1640
+ */
1641
+ function buildGraphFromNotes(notes, options = {}) {
1642
+ const maxNotes = options.maxNotes ?? 1200;
1643
+ const maxEdges = options.maxEdges ?? 12000;
1644
+ const maxTags = options.maxTags ?? 800;
1645
+ const list = [...notes].slice(0, maxNotes);
1646
+ const byPath = new Map();
1647
+ const byBase = new Map();
1648
+ for (const note of list) {
1649
+ const noExt = note.path.replace(/\.md$/i, "");
1650
+ byPath.set(noExt.toLowerCase(), note.path);
1651
+ const base = (noExt.split("/").pop() || noExt).toLowerCase();
1652
+ const bucket = byBase.get(base);
1653
+ if (bucket === undefined) byBase.set(base, [note.path]);
1654
+ else bucket.push(note.path);
1655
+ }
1656
+ const nodes = new Map();
1657
+ for (const note of list) {
1658
+ nodes.set(`note:${note.path}`, {
1659
+ id: `note:${note.path}`,
1660
+ kind: "note",
1661
+ label: (note.path.split("/").pop() || note.path).replace(/\.md$/i, ""),
1662
+ path: note.path,
1663
+ });
1664
+ }
1665
+ const edges = [];
1666
+ const seen = new Set();
1667
+ const addEdge = (source, target) => {
1668
+ if (edges.length >= maxEdges) return;
1669
+ const key = `${source}\u0000${target}`;
1670
+ if (seen.has(key)) return;
1671
+ seen.add(key);
1672
+ edges.push({ source, target });
1673
+ };
1674
+ const tagIds = new Map();
1675
+ let unresolved = 0;
1676
+ for (const note of list) {
1677
+ const self = `note:${note.path}`;
1678
+ for (const link of note.links ?? []) {
1679
+ const target = resolveGraphLink(link, note.path, byPath, byBase);
1680
+ if (target === null) {
1681
+ unresolved += 1;
1682
+ continue;
1683
+ }
1684
+ if (target !== note.path) addEdge(self, `note:${target}`);
1685
+ }
1686
+ for (const tag of note.tags ?? []) {
1687
+ const key = String(tag).toLowerCase();
1688
+ let tagId = tagIds.get(key);
1689
+ if (tagId === undefined) {
1690
+ if (tagIds.size >= maxTags) continue;
1691
+ tagId = `tag:${key}`;
1692
+ tagIds.set(key, tagId);
1693
+ nodes.set(tagId, { id: tagId, kind: "tag", label: `#${tag}` });
1694
+ }
1695
+ addEdge(self, tagId);
1696
+ }
1697
+ }
1698
+ return {
1699
+ nodes: [...nodes.values()],
1700
+ edges,
1701
+ stats: {
1702
+ notes: list.length,
1703
+ tags: tagIds.size,
1704
+ edges: edges.length,
1705
+ unresolved,
1706
+ truncated: notes.length > maxNotes,
1707
+ source: "client",
1708
+ },
1709
+ };
1710
+ }
1711
+
1712
+
1713
+ /**
1714
+ * 从笔记内容 + frontmatter 提取标签(客户端兜底构建图谱时用;与 host 解析同语义)。
1715
+ * @param content - 笔记正文。
1716
+ * @param frontmatter - `{ tags: "..." }` 形态的 frontmatter 映射。
1717
+ * @returns 标签名数组(不含 `#`、去重)。
1718
+ */
1719
+ function extractTags(content, frontmatter) {
1720
+ const tags = new Set();
1721
+ const fm = frontmatter && frontmatter.tags;
1722
+ if (typeof fm === "string" && fm.trim() !== "") {
1723
+ for (const token of fm.replace(/^\[|\]$/g, "").split(/[,\s]+/)) {
1724
+ const tag = token.replace(/^#/, "").replace(/^["']|["']$/g, "").trim();
1725
+ if (tag !== "" && /^[\p{L}\p{N}_/-]{1,64}$/u.test(tag)) tags.add(tag);
1726
+ }
1727
+ }
1728
+ const body = String(content ?? "")
1729
+ .replace(/```[\s\S]*?```/g, " ")
1730
+ .replace(/~~~[\s\S]*?~~~/g, " ")
1731
+ .replace(/`[^`\n]*`/g, " ");
1732
+ const re = /(^|[\s((\[【,,;;、>|])#([\p{L}\p{N}_/-]{1,64})/gu;
1733
+ for (const match of body.matchAll(re)) {
1734
+ const tag = match[2].replace(/\/+$/, "");
1735
+ if (tag !== "" && !/^\d+$/.test(tag)) tags.add(tag);
1736
+ }
1737
+ return [...tags];
1738
+ }
1739
+
1740
+ return { layoutGraph, localSubgraph, adjacency, rng, resolveGraphLink, buildGraphFromNotes, extractTags };
1741
+ })();
1742
+
1098
1743
  /**
1099
1744
  * dsh-obsidian-vault client 半边(侧边栏重构版):
1100
1745
  * - 常驻接管 sidebar.workspaces(priority:-100 阴影官方 WorkspaceBrowser):
@@ -1209,6 +1854,44 @@ window.__ModuleLoader__.load({
1209
1854
  cancel: "取消",
1210
1855
  discard: "放弃未保存的修改?",
1211
1856
  cite: "引用到对话",
1857
+ mindMap: "思维导图",
1858
+ preview: "预览",
1859
+ mapEmpty: "这篇笔记还没有标题或列表,画不出思维导图",
1860
+ mapHint: "滚轮缩放 · 拖动平移 · 单击节点选中 · 双击跳到源码行",
1861
+ mapJump: "跳到源码行",
1862
+ mapAsk: "把分支插入对话",
1863
+ branchCited: "已把分支插入对话({n} 行)",
1864
+ graph: "关系图谱",
1865
+ graphHint: "滚轮缩放 · 拖动平移 · 点击笔记打开 · 悬停看邻居",
1866
+ graphGlobal: "全库",
1867
+ graphLocal: "局部",
1868
+ graphLocalHint: "以当前打开的笔记为中心显示 2 跳邻居",
1869
+ graphRefresh: "重新读取 vault",
1870
+ graphRerun: "重新布局",
1871
+ graphClose: "关闭图谱",
1872
+ graphLoading: "正在构建关系图谱…",
1873
+ graphEmpty: "vault 里还没有可显示的笔记或双链",
1874
+ graphError: "关系图谱加载失败:{msg}",
1875
+ graphStats: "{notes} 篇笔记 · {tags} 个标签 · {edges} 条连线",
1876
+ graphTruncated: "笔记较多,图谱只画了最重要的 {n} 个节点",
1877
+ graphNoNote: "先在侧边栏打开一篇笔记,才能看局部图谱",
1878
+ graphOpen: "打开关系图谱",
1879
+ graphMove: "拖动移动面板",
1880
+ graphNeedsRestart: "关系图谱需要重启 DSH 后启用",
1881
+ graphRestartHint: "当前 DSH 进程里还是旧版 host(缺少 /obsidian/graph 路由)。完全退出 DSH(Cmd+Q)再打开,然后点「重试」。",
1882
+ graphErrorTitle: "关系图谱加载失败",
1883
+ graphBuilding: "正在从笔记构建图谱({done}/{total})…",
1884
+ graphFocus: "聚焦",
1885
+ graphFocusHint: "一键切到以当前打开笔记为中心的局部图谱(局部模式下再点 = 重新居中)",
1886
+ graphLegendNote: "笔记",
1887
+ graphLegendTag: "标签",
1888
+ graphLegendFocus: "当前笔记",
1889
+ graphLegendLink: "链接",
1890
+ graphDepth: "跳数",
1891
+ graphDepthHint: "局部图谱跳数,1–5 跳(与 Obsidian 局部图谱的 depth 范围一致)",
1892
+ graphRetry: "重试",
1893
+ graphDetails: "详细信息",
1894
+ graphDetailsHide: "收起详细信息",
1212
1895
  cited: "已引用到对话",
1213
1896
  copied: "已复制引用,可粘贴到输入框",
1214
1897
  noInput: "未找到对话输入框,引用已复制到剪贴板",
@@ -1237,11 +1920,10 @@ window.__ModuleLoader__.load({
1237
1920
  wikilinkNotFound: "未找到笔记",
1238
1921
  error: "出错了",
1239
1922
  imageInserted: "已插入图片",
1240
- attachDir: "附件目录",
1923
+ attachDir: "附件文件夹",
1241
1924
  attachDirPick: "选择附件目录(Vault 内)",
1242
- attachDirEdit: "手写附件目录",
1243
- attachDirClear: "清空(回到 Vault 根)",
1244
- attachDirPlaceholder: "Vault 相对路径,如 docs/images",
1925
+ attachDirPlaceholder: "Vault 相对路径,如 attachments",
1926
+ attachDirHint: "留空 = Vault 根目录,回车保存",
1245
1927
  imageRemoved: "已同步删除未引用的图片",
1246
1928
  attachDirConfirm: "更改截图落盘目录:{from} → {to}。会**自动把原目录下的截图移动到新目录**、删除原目录(若空),并同步更新笔记里的引用路径。确认更改?",
1247
1929
  attachSettings: "附件设置(位置 / 显示 / 清理)",
@@ -1250,7 +1932,9 @@ window.__ModuleLoader__.load({
1250
1932
  attachMode_fixed: "固定目录",
1251
1933
  attachMode_note: "与笔记同目录",
1252
1934
  attachMode_sub: "笔记目录下子目录",
1253
- attachSubfolder: "子目录名(如 attachments)",
1935
+ attachSubfolder: "子目录名称",
1936
+ attachSubfolderPlaceholder: "如 attachments",
1937
+ attachSubHint: "回车保存",
1254
1938
  attachHideDir: "隐藏图片目录(只含图片、没有笔记的目录)",
1255
1939
  attachDeleteOrphans: "保存笔记时自动删除无用附件(默认关)",
1256
1940
  migrated: "迁移 {n} 张截图,更新 {m} 篇笔记",
@@ -1346,6 +2030,44 @@ window.__ModuleLoader__.load({
1346
2030
  cancel: "Cancel",
1347
2031
  discard: "Discard unsaved changes?",
1348
2032
  cite: "Cite to chat",
2033
+ mindMap: "Mind map",
2034
+ preview: "Preview",
2035
+ mapEmpty: "No headings or list items yet — nothing to draw",
2036
+ mapHint: "Scroll to zoom · drag to pan · click a node to select · double-click to jump to its line",
2037
+ mapJump: "Jump to line",
2038
+ mapAsk: "Insert branch into chat",
2039
+ branchCited: "Branch inserted into the chat ({n} lines)",
2040
+ graph: "Graph view",
2041
+ graphHint: "Scroll to zoom · drag to pan · click a note to open it · hover to see neighbours",
2042
+ graphGlobal: "Vault",
2043
+ graphLocal: "Local",
2044
+ graphLocalHint: "Show the 2-hop neighbourhood of the note that is open",
2045
+ graphRefresh: "Re-read the vault",
2046
+ graphRerun: "Re-run the layout",
2047
+ graphClose: "Close the graph",
2048
+ graphLoading: "Building the graph…",
2049
+ graphEmpty: "No notes or links to draw yet",
2050
+ graphError: "Graph view failed: {msg}",
2051
+ graphStats: "{notes} notes · {tags} tags · {edges} links",
2052
+ graphTruncated: "Large vault: only the {n} most connected nodes are drawn",
2053
+ graphNoNote: "Open a note in the sidebar first to see the local graph",
2054
+ graphOpen: "Open the graph view",
2055
+ graphMove: "Drag to move the panel",
2056
+ graphNeedsRestart: "The graph view needs a DSH restart",
2057
+ graphRestartHint: "The running DSH host still predates the /obsidian/graph route. Quit DSH completely (Cmd+Q), reopen it, then press Retry.",
2058
+ graphErrorTitle: "The graph view failed to load",
2059
+ graphBuilding: "Building the graph from notes ({done}/{total})…",
2060
+ graphFocus: "Focus",
2061
+ graphFocusHint: "Jump to the local graph centred on the open note (click again to re-centre)",
2062
+ graphLegendNote: "note",
2063
+ graphLegendTag: "tag",
2064
+ graphLegendFocus: "current note",
2065
+ graphLegendLink: "link",
2066
+ graphDepth: "Depth",
2067
+ graphDepthHint: "Local graph depth, 1–5 hops (same range as Obsidian's local graph depth)",
2068
+ graphRetry: "Retry",
2069
+ graphDetails: "Details",
2070
+ graphDetailsHide: "Hide details",
1349
2071
  cited: "Cited to chat",
1350
2072
  copied: "Reference copied — paste it into the input",
1351
2073
  noInput: "No chat input found — reference copied to clipboard",
@@ -1374,11 +2096,10 @@ window.__ModuleLoader__.load({
1374
2096
  wikilinkNotFound: "Note not found",
1375
2097
  error: "Error",
1376
2098
  imageInserted: "Image inserted",
1377
- attachDir: "Attachment dir",
2099
+ attachDir: "Attachment folder",
1378
2100
  attachDirPick: "Choose attachment dir (inside vault)",
1379
- attachDirEdit: "Type an attachment dir",
1380
- attachDirClear: "Clear (back to vault root)",
1381
- attachDirPlaceholder: "Vault-relative path, e.g. docs/images",
2101
+ attachDirPlaceholder: "Vault-relative path, e.g. attachments",
2102
+ attachDirHint: "Empty = vault root; press Enter to save",
1382
2103
  imageRemoved: "Deleted unreferenced image(s)",
1383
2104
  attachDirConfirm: "Change screenshot dir: {from} → {to}. This will automatically move existing screenshots to the new dir, remove the old dir (if empty), and update note references. Continue?",
1384
2105
  attachSettings: "Attachment settings (location / visibility / cleanup)",
@@ -1387,7 +2108,9 @@ window.__ModuleLoader__.load({
1387
2108
  attachMode_fixed: "Fixed folder",
1388
2109
  attachMode_note: "Same folder as note",
1389
2110
  attachMode_sub: "Subfolder under note",
1390
- attachSubfolder: "Subfolder name (e.g. attachments)",
2111
+ attachSubfolder: "Subfolder name",
2112
+ attachSubfolderPlaceholder: "e.g. attachments",
2113
+ attachSubHint: "Press Enter to save",
1391
2114
  attachHideDir: "Hide image folders (folders without any notes)",
1392
2115
  attachDeleteOrphans: "Auto-delete orphaned attachments on save (off by default)",
1393
2116
  migrated: "moved {n} image(s), updated {m} note(s)",
@@ -1396,6 +2119,8 @@ window.__ModuleLoader__.load({
1396
2119
  // ── 共享外部 store(跨槽位:侧边栏 / Dock / Toast)──────────────────────────
1397
2120
  const LS_NOTES_OPEN = "dsh-obsidian:notesOpen";
1398
2121
  const LS_DOCK_WIDTH = "dsh-obsidian:dockWidth";
2122
+ const LS_GRAPH_GEOMETRY = "dsh-obsidian:graphGeometry";
2123
+ const LS_LOCAL_DEPTH = "dsh-obsidian:localDepth";
1399
2124
 
1400
2125
  function readNotesOpen() {
1401
2126
  try {
@@ -1405,7 +2130,7 @@ window.__ModuleLoader__.load({
1405
2130
  }
1406
2131
  }
1407
2132
 
1408
- let shared = { notesOpen: readNotesOpen(), openNote: null, vaultRev: 0, toast: null, noteCount: 0, prompt: null };
2133
+ let shared = { notesOpen: readNotesOpen(), openNote: null, graphOpen: false, vaultRev: 0, toast: null, noteCount: 0, prompt: null };
1409
2134
  const sharedListeners = new Set();
1410
2135
 
1411
2136
  function patchShared(patch) {
@@ -1761,6 +2486,7 @@ window.__ModuleLoader__.load({
1761
2486
  `.dsh-obs-ws-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;font-weight:500}`,
1762
2487
  `.dsh-obs-mini{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;line-height:1;flex:none}`,
1763
2488
  `.dsh-obs-mini:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}`,
2489
+ `.dsh-obs-mini.dsh-obs-on{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-brand-primary)}`,
1764
2490
  // 行尾管理动作(工作区/会话 的 重命名 / 删除,常显)
1765
2491
  `.dsh-obs-actions{display:flex;align-items:center;gap:2px;flex:none}`,
1766
2492
  `.dsh-obs-actions button{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:12px;line-height:1}`,
@@ -1809,9 +2535,21 @@ window.__ModuleLoader__.load({
1809
2535
  `.dsh-obs-tree-head button{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;padding:0 6px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:14px;line-height:1}`,
1810
2536
  `.dsh-obs-tree-head button:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
1811
2537
  `.dsh-obs-tree-head button.dsh-obs-on{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-brand-primary)}`,
1812
- `.dsh-obs-attachcfg{display:flex;flex-wrap:wrap;gap:4px;align-items:center;padding:2px 6px 6px}`,
1813
- `.dsh-obs-attachcfg input:not([type=checkbox]){flex:1;min-width:110px;height:24px;padding:0 6px;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font-size:12px}`,
1814
- `.dsh-obs-cfgline{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}`,
2538
+ // 附件设置面板:窄侧边栏里的表单卡片。位置模式用下拉框,四个中文标签都能整行放下;
2539
+ // 旧版把四个标签塞进 24×24 的图标按钮,汉字被挤成一列,盖住下面的输入框和开关。
2540
+ `.dsh-obs-attachcfg{display:flex;flex-direction:column;gap:8px;margin:2px 4px 8px;padding:8px 8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-interactive-bg-hover)}`,
2541
+ `.dsh-obs-field{display:flex;flex-direction:column;gap:4px;min-width:0}`,
2542
+ `.dsh-obs-field-label{font-size:11px;line-height:15px;color:var(--dsw-alias-label-tertiary)}`,
2543
+ `.dsh-obs-select{width:100%;height:28px;padding:0 28px 0 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background-color:transparent;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;background-size:12px 12px;color:var(--dsw-alias-label-primary);font-size:12px;line-height:26px;appearance:none;outline:none;cursor:pointer}`,
2544
+ `.dsh-obs-select:focus{border-color:var(--dsw-alias-brand-primary)}`,
2545
+ `.dsh-obs-inputrow{display:flex;align-items:center;gap:4px;min-width:0}`,
2546
+ `.dsh-obs-inputrow input{flex:1;min-width:0;height:28px;padding:0 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font-size:12px;outline:none}`,
2547
+ `.dsh-obs-inputrow input:focus{border-color:var(--dsw-alias-brand-primary)}`,
2548
+ `.dsh-obs-inputrow .dsh-obs-mini{width:26px;height:26px;border:1px solid var(--dsw-alias-border-l2)}`,
2549
+ `.dsh-obs-hint{font-size:11px;line-height:15px;color:var(--dsw-alias-label-tertiary)}`,
2550
+ `.dsh-obs-checks{display:flex;flex-direction:column;gap:6px;padding-top:2px}`,
2551
+ `.dsh-obs-cfgline{display:flex;align-items:flex-start;gap:6px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer;user-select:none}`,
2552
+ `.dsh-obs-cfgline input[type=checkbox]{flex:none;margin:2px 0 0;cursor:pointer}`,
1815
2553
  `.dsh-obs-root-row{display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:11px;color:var(--dsw-alias-label-tertiary)}`,
1816
2554
  `.dsh-obs-root-row .dsh-obs-root-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
1817
2555
  `.dsh-obs-root-row .dsh-obs-mini,.dsh-obs-row .dsh-obs-mini{flex:none;width:22px;height:22px}`,
@@ -1847,7 +2585,69 @@ window.__ModuleLoader__.load({
1847
2585
  `.dsh-obs-md-lc{flex:1;min-width:0}`,
1848
2586
  `.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc p,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc h1,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc h2,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc h3,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc h4,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc h5,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc h6,.dsh-obs-dock-body .dsh-obs-md-row .dsh-obs-md-lc blockquote{margin:0;padding:0}`,
1849
2587
  `.dsh-obs-md-row.dsh-obs-md-heading .dsh-obs-md-lc{font-weight:600}`,
2588
+ // 行内 Obsidian 语法:==高亮== / ~~删除线~~ / 裸链接与自动链接
2589
+ `.dsh-obs-md mark,.dsh-obs-mark{background:rgba(255,208,0,.32);color:inherit;border-radius:2px;padding:0 1px}`,
2590
+ `.dsh-obs-md del{opacity:.62;text-decoration:line-through}`,
2591
+ `.dsh-obs-md .dsh-obs-autolink{color:var(--dsw-alias-brand-primary);text-decoration:underline;text-underline-offset:2px;word-break:break-all}`,
1850
2592
  `.dsh-obs-md-row.dsh-obs-md-quote .dsh-obs-md-lc{border-left:3px solid var(--dsw-alias-brand-primary);padding-left:10px;color:var(--dsw-alias-label-secondary)}`,
2593
+ // ── 思维导图视图(B1)──
2594
+ `.dsh-obs-dock-map{overflow:hidden;padding:0}`,
2595
+ `.dsh-obs-map{position:relative;display:block;width:100%;height:100%;min-height:300px;cursor:grab;overflow:hidden;user-select:none;-webkit-user-select:none}`,
2596
+ `.dsh-obs-map-svg{display:block;width:100%;height:100%}`,
2597
+ `.dsh-obs-map-edge{fill:none;stroke:var(--dsw-alias-border-l2);stroke-width:1.5}`,
2598
+ `.dsh-obs-map-node{cursor:pointer}`,
2599
+ `.dsh-obs-map-node rect{fill:var(--dsw-alias-bg-overlay);stroke:var(--dsw-alias-border-l2);stroke-width:1}`,
2600
+ `.dsh-obs-map-heading rect{stroke:var(--dsw-alias-brand-primary)}`,
2601
+ `.dsh-obs-map-root rect{fill:var(--dsw-alias-brand-primary);stroke:var(--dsw-alias-brand-primary)}`,
2602
+ `.dsh-obs-map-root text{fill:#fff;font-weight:600}`,
2603
+ `.dsh-obs-map-node text{font-size:12.5px;fill:var(--dsw-alias-label-primary);pointer-events:none}`,
2604
+ `.dsh-obs-map-sel rect{stroke:var(--dsw-alias-brand-primary);stroke-width:2}`,
2605
+ `.dsh-obs-map-hint{position:absolute;left:10px;bottom:8px;font-size:11px;color:var(--dsw-alias-label-tertiary);pointer-events:none}`,
2606
+ `.dsh-obs-map-actions{position:absolute;right:10px;top:8px;display:flex;align-items:center;gap:6px;padding:4px 6px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-overlay);font-size:12px}`,
2607
+ `.dsh-obs-map-actions .dsh-obs-map-line{color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}`,
2608
+ `.dsh-obs-map-actions button{padding:3px 8px;font-size:12px}`,
2609
+ `.dsh-obs-map-empty{display:flex;align-items:center;justify-content:center;height:100%;min-height:220px;padding:24px;color:var(--dsw-alias-label-tertiary);text-align:center}`,
2610
+ `.dsh-obs-md-row.dsh-obs-md-flash{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 16%,transparent);border-radius:4px}`,
2611
+ // ── 关系图谱(shell.overlay,vault 级独立视图)──
2612
+ `.dsh-obs-graph{position:fixed;z-index:40;display:flex;flex-direction:column;box-sizing:border-box;min-width:560px;min-height:360px;border:1px solid var(--dsw-alias-border-l2);border-radius:14px;background:var(--dsw-alias-bg-overlay);box-shadow:0 24px 64px rgba(0,0,0,.45);overflow:hidden;color:var(--dsw-alias-label-primary)}`,
2613
+ `.dsh-obs-graph-head{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--dsw-alias-border-l2);flex-wrap:wrap;cursor:move;user-select:none;-webkit-user-select:none}`,
2614
+ `.dsh-obs-graph-resize{position:absolute;right:2px;bottom:2px;width:16px;height:16px;cursor:nwse-resize;border-right:2px solid var(--dsw-alias-border-l2);border-bottom:2px solid var(--dsw-alias-border-l2);opacity:.75}`,
2615
+ `.dsh-obs-graph-error{display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;gap:8px;padding:32px;text-align:center}`,
2616
+ `.dsh-obs-graph-error-icon{font-size:26px}`,
2617
+ `.dsh-obs-graph-error-title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}`,
2618
+ `.dsh-obs-graph-error-text{font-size:12px;line-height:1.7;color:var(--dsw-alias-label-secondary);max-width:560px}`,
2619
+ `.dsh-obs-graph-error-actions{display:flex;gap:8px;margin-top:8px}`,
2620
+ `.dsh-obs-graph-error-actions button{padding:5px 12px;font-size:12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer}`,
2621
+ `.dsh-obs-graph-error-actions button:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
2622
+ `.dsh-obs-graph-error-actions button.dsh-obs-primary{background:var(--dsw-alias-button-primary-fill);border-color:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}`,
2623
+ `.dsh-obs-graph-error-details{max-width:560px;max-height:120px;overflow:auto;margin-top:6px;padding:8px;border-radius:6px;background:var(--dsw-alias-interactive-bg-hover);font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:pre-wrap;word-break:break-all;text-align:left}`,
2624
+ `.dsh-obs-graph-title{font-weight:600;font-size:14px}`,
2625
+ `.dsh-obs-graph-stats{font-size:11px;color:var(--dsw-alias-label-tertiary)}`,
2626
+ `.dsh-obs-graph-modes{display:flex;gap:4px;margin-left:auto}`,
2627
+ `.dsh-obs-graph-modes button[disabled]{opacity:.45;cursor:not-allowed}`,
2628
+ `.dsh-obs-graph-depth{display:inline-flex;align-items:center;gap:2px;margin-left:6px;padding-left:8px;border-left:1px solid var(--dsw-alias-border-l2)}`,
2629
+ `.dsh-obs-graph-depth-label{font-size:11px;color:var(--dsw-alias-label-tertiary);margin-right:2px}`,
2630
+ `.dsh-obs-graph-depth button{min-width:20px;padding:0 4px}`,
2631
+ `.dsh-obs-graph-head button{white-space:nowrap;flex:none}`,
2632
+ `.dsh-obs-legend-dot{width:9px;height:9px;border-radius:50%;border:1.5px solid var(--dsw-alias-label-secondary);display:inline-block;box-sizing:border-box}`,
2633
+ `.dsh-obs-legend-tag{width:8px;height:8px;border-radius:2px;background:var(--dsw-alias-brand-primary);display:inline-block}`,
2634
+ `.dsh-obs-legend-focus{width:9px;height:9px;border-radius:50%;border:2px solid var(--dsw-alias-state-error-primary);display:inline-block;box-sizing:border-box}`,
2635
+ `.dsh-obs-legend-link{width:14px;height:1.5px;background:var(--dsw-alias-border-l2);display:inline-block}`,
2636
+ `.dsh-obs-graph-canvas{position:relative;flex:1;min-height:0;overflow:hidden;cursor:grab;user-select:none;-webkit-user-select:none}`,
2637
+ `.dsh-obs-graph-svg{display:block;width:100%;height:100%}`,
2638
+ `.dsh-obs-graph-edge{stroke:var(--dsw-alias-border-l2);stroke-width:1;opacity:.55}`,
2639
+ `.dsh-obs-graph-edge-on{stroke:var(--dsw-alias-brand-primary);opacity:.9;stroke-width:1.6}`,
2640
+ `.dsh-obs-graph-node{cursor:pointer}`,
2641
+ `.dsh-obs-graph-note circle{fill:var(--dsw-alias-bg-overlay);stroke:var(--dsw-alias-label-secondary);stroke-width:1.4}`,
2642
+ `.dsh-obs-graph-tag rect{fill:var(--dsw-alias-brand-primary);opacity:.75}`,
2643
+ `.dsh-obs-graph-sel circle,.dsh-obs-graph-sel rect{stroke:var(--dsw-alias-brand-primary);stroke-width:2.2}`,
2644
+ `.dsh-obs-graph-focus circle{stroke:var(--dsw-alias-state-error-primary);stroke-width:2.4}`,
2645
+ `.dsh-obs-graph-dim{opacity:.25}`,
2646
+ `.dsh-obs-graph-node text{font-size:11px;fill:var(--dsw-alias-label-primary);pointer-events:none}`,
2647
+ `.dsh-obs-graph-tag text{fill:var(--dsw-alias-label-secondary)}`,
2648
+ `.dsh-obs-graph-hint{padding:6px 14px;border-top:1px solid var(--dsw-alias-border-l2);font-size:11px;color:var(--dsw-alias-label-tertiary)}`,
2649
+ `.dsh-obs-graph-empty{display:flex;align-items:center;justify-content:center;flex:1;padding:32px;color:var(--dsw-alias-label-tertiary);text-align:center}`,
2650
+ `.dsh-obs-graph-error, .dsh-obs-graph-empty{flex:1;min-height:0}`,
1851
2651
  // ── Obsidian callout(`> [!type] 标题`)配色:类型在根节点上带 data-callout ──
1852
2652
  `[data-callout="note"]{--dsh-obs-callout-color:#448aff}`,
1853
2653
  `[data-callout="abstract"]{--dsh-obs-callout-color:#00b8d4}`,
@@ -2013,7 +2813,14 @@ window.__ModuleLoader__.load({
2013
2813
 
2014
2814
  function injectCss() {
2015
2815
  if (typeof document === "undefined") return;
2016
- if (document.querySelector('style[data-plugin-css="dsh-obsidian-vault/core.css"]')) return;
2816
+ // 已注入过就只更新内容:插件被 HMR 重新装载时 apply() 会再跑一遍,旧实现直接
2817
+ // return,页面会继续用上一次的 CSS —— 新加的类名没有样式,界面半新半旧,只能
2818
+ // 整页刷新才正常。
2819
+ const existing = document.querySelector('style[data-plugin-css="dsh-obsidian-vault/core.css"]');
2820
+ if (existing !== null) {
2821
+ if (existing.textContent !== CSS) existing.textContent = CSS;
2822
+ return;
2823
+ }
2017
2824
  const tag = document.createElement("style");
2018
2825
  tag.dataset.plugin = "dsh-obsidian-vault";
2019
2826
  tag.dataset.pluginCss = "dsh-obsidian-vault/core.css";
@@ -2167,6 +2974,7 @@ window.__ModuleLoader__.load({
2167
2974
  // `![[附件/a.png]]` 会被当成 Vault 根下的 `附件/a.png` → 403 → 只剩断图。
2168
2975
  assetUrl: (rel, from) => `/obsidian/file?path=${encodeURIComponent(rel)}`
2169
2976
  + (from ? `&from=${encodeURIComponent(from)}` : ""),
2977
+ graph: () => api("/graph"),
2170
2978
  note: (rel) => api(`/note?path=${encodeURIComponent(rel)}`),
2171
2979
  save: (rel, content) => api("/note", {
2172
2980
  method: "POST",
@@ -2266,6 +3074,7 @@ window.__ModuleLoader__.load({
2266
3074
  const [attachDir, setAttachDirState] = useState(null);
2267
3075
  const [attachOpen, setAttachOpen] = useState(false);
2268
3076
  const [attachInput, setAttachInput] = useState("");
3077
+ const [attachSubInput, setAttachSubInput] = useState("");
2269
3078
  // 附件位置模式 / 子目录 / 两个开关(对齐 Obsidian 的附件位置设置)
2270
3079
  const [attachCfg, setAttachCfg] = useState(null);
2271
3080
 
@@ -2315,6 +3124,14 @@ window.__ModuleLoader__.load({
2315
3124
  void loadRoot();
2316
3125
  }, [loadRoot, vaultRev]);
2317
3126
 
3127
+ // 面板每次打开都把输入框填成当前设置:旧版只有铅笔按钮会带值,点齿轮打开时
3128
+ // 输入框是空的,看起来像「设置丢了」。
3129
+ useEffect(() => {
3130
+ if (!attachOpen) return;
3131
+ setAttachInput(typeof attachDir === "string" ? attachDir : "");
3132
+ setAttachSubInput(attachCfg !== null ? attachCfg.subfolder : "attachments");
3133
+ }, [attachOpen]);
3134
+
2318
3135
  const toggleDir = useCallback(async (rel) => {
2319
3136
  const willOpen = !expanded.has(rel);
2320
3137
  setExpanded((prev) => {
@@ -2608,63 +3425,93 @@ window.__ModuleLoader__.load({
2608
3425
  onClick: (e) => { e.stopPropagation(); void newNote(""); },
2609
3426
  }, "+"),
2610
3427
  ),
2611
- rootInfo && rootInfo.exists && attachDir !== null && createElement("div", { className: "dsh-obs-root-row", title: t("attachSettings") },
3428
+ rootInfo && rootInfo.exists && createElement("div", { className: "dsh-obs-root-row", title: t("graphOpen") },
3429
+ createElement("span", { className: "dsh-obs-root-path" }, `🕸 ${t("graph")}`),
3430
+ createElement("button", {
3431
+ className: "dsh-obs-mini",
3432
+ title: t("graphOpen"),
3433
+ onClick: (e) => { e.stopPropagation(); patchShared({ graphOpen: true }); },
3434
+ }, "↗"),
3435
+ ),
3436
+ rootInfo && rootInfo.exists && attachDir !== null && createElement("div", {
3437
+ className: "dsh-obs-root-row",
3438
+ title: `${t("attachLocation")}: ${attachLocationLabel(attachCfg, attachDir, t)}`,
3439
+ },
2612
3440
  createElement("span", { className: "dsh-obs-root-path" },
2613
3441
  `📎 ${t("attachLocation")}: ${attachLocationLabel(attachCfg, attachDir, t)}`),
2614
- attachCfg !== null && attachCfg.mode === "fixed" && createElement("button", {
2615
- className: "dsh-obs-mini",
2616
- title: t("attachDirPick"),
2617
- onClick: (e) => { e.stopPropagation(); void pickAttachDir(); },
2618
- }, "📂"),
2619
- attachCfg !== null && attachCfg.mode === "fixed" && createElement("button", {
2620
- className: "dsh-obs-mini",
2621
- title: t("attachDirEdit"),
2622
- onClick: (e) => { e.stopPropagation(); setAttachInput(attachDir); setAttachOpen((v) => !v); },
2623
- }, "✎"),
2624
3442
  createElement("button", {
2625
- className: "dsh-obs-mini",
3443
+ className: `dsh-obs-mini${attachOpen ? " dsh-obs-on" : ""}`,
2626
3444
  title: t("attachSettings"),
3445
+ "aria-expanded": attachOpen === true,
2627
3446
  onClick: (e) => { e.stopPropagation(); setAttachOpen((v) => !v); },
2628
3447
  }, "⚙"),
2629
3448
  ),
2630
- // 附件设置面板:位置模式(Obsidian 的四种)+ 子目录名 + 两个开关
3449
+ // 附件设置面板:窄侧边栏里当表单排(下拉框 + 输入行 + 开关),长标签整行放得下
2631
3450
  attachOpen && rootInfo && rootInfo.exists && attachCfg !== null && createElement("div", { className: "dsh-obs-attachcfg" },
2632
- ...["root", "fixed", "note", "sub"].map((m) => createElement("button", {
2633
- key: m,
2634
- className: `dsh-obs-mini${attachCfg.mode === m ? " dsh-obs-on" : ""}`,
2635
- title: t(`attachMode_${m}`),
2636
- onClick: (e) => { e.stopPropagation(); void applyAttachSettings({ mode: m }); },
2637
- }, t(`attachMode_${m}`))),
2638
- attachCfg.mode === "fixed" && createElement("input", {
2639
- autoFocus: true,
2640
- placeholder: t("attachDirPlaceholder"),
2641
- value: attachInput,
2642
- onChange: (e) => setAttachInput(e.target.value),
2643
- onKeyDown: (e) => {
2644
- if (e.key === "Enter") void applyAttachDir(attachInput);
2645
- if (e.key === "Escape") setAttachOpen(false);
3451
+ createElement("label", { className: "dsh-obs-field" },
3452
+ createElement("span", { className: "dsh-obs-field-label" }, t("attachLocation")),
3453
+ createElement("select", {
3454
+ className: "dsh-obs-select",
3455
+ value: attachCfg.mode,
3456
+ onChange: (e) => { void applyAttachSettings({ mode: e.target.value }); },
2646
3457
  },
2647
- }),
2648
- attachCfg.mode === "sub" && createElement("input", {
2649
- placeholder: t("attachSubfolder"),
2650
- defaultValue: attachCfg.subfolder,
2651
- onKeyDown: (e) => { if (e.key === "Enter") void applyAttachSettings({ subfolder: e.target.value }); },
2652
- }),
2653
- createElement("label", { className: "dsh-obs-cfgline" },
2654
- createElement("input", {
2655
- type: "checkbox",
2656
- checked: attachCfg.hideDir,
2657
- onChange: (e) => { e.stopPropagation(); void applyAttachSettings({ hideAttachmentDir: e.target.checked === true }); },
2658
- }),
2659
- t("attachHideDir"),
3458
+ ["root", "fixed", "note", "sub"].map((m) => createElement("option", { key: m, value: m }, t(`attachMode_${m}`))),
3459
+ ),
2660
3460
  ),
2661
- createElement("label", { className: "dsh-obs-cfgline" },
2662
- createElement("input", {
2663
- type: "checkbox",
2664
- checked: attachCfg.deleteOrphans,
2665
- onChange: (e) => { e.stopPropagation(); void applyAttachSettings({ deleteOrphanAttachments: e.target.checked === true }); },
2666
- }),
2667
- t("attachDeleteOrphans"),
3461
+ attachCfg.mode === "fixed" && createElement("div", { className: "dsh-obs-field" },
3462
+ createElement("span", { className: "dsh-obs-field-label" }, t("attachDir")),
3463
+ createElement("div", { className: "dsh-obs-inputrow" },
3464
+ createElement("input", {
3465
+ "aria-label": t("attachDir"),
3466
+ placeholder: t("attachDirPlaceholder"),
3467
+ value: attachInput,
3468
+ onChange: (e) => setAttachInput(e.target.value),
3469
+ onKeyDown: (e) => {
3470
+ if (e.key === "Enter") void applyAttachDir(attachInput);
3471
+ if (e.key === "Escape") setAttachOpen(false);
3472
+ },
3473
+ }),
3474
+ createElement("button", {
3475
+ className: "dsh-obs-mini",
3476
+ title: t("attachDirPick"),
3477
+ onClick: (e) => { e.stopPropagation(); void pickAttachDir(); },
3478
+ }, "📂"),
3479
+ ),
3480
+ createElement("span", { className: "dsh-obs-hint" }, t("attachDirHint")),
3481
+ ),
3482
+ attachCfg.mode === "sub" && createElement("div", { className: "dsh-obs-field" },
3483
+ createElement("span", { className: "dsh-obs-field-label" }, t("attachSubfolder")),
3484
+ createElement("div", { className: "dsh-obs-inputrow" },
3485
+ createElement("input", {
3486
+ "aria-label": t("attachSubfolder"),
3487
+ placeholder: t("attachSubfolderPlaceholder"),
3488
+ value: attachSubInput,
3489
+ onChange: (e) => setAttachSubInput(e.target.value),
3490
+ onKeyDown: (e) => {
3491
+ if (e.key === "Enter") void applyAttachSettings({ subfolder: attachSubInput });
3492
+ if (e.key === "Escape") setAttachOpen(false);
3493
+ },
3494
+ }),
3495
+ ),
3496
+ createElement("span", { className: "dsh-obs-hint" }, t("attachSubHint")),
3497
+ ),
3498
+ createElement("div", { className: "dsh-obs-checks" },
3499
+ createElement("label", { className: "dsh-obs-cfgline" },
3500
+ createElement("input", {
3501
+ type: "checkbox",
3502
+ checked: attachCfg.hideDir,
3503
+ onChange: (e) => { e.stopPropagation(); void applyAttachSettings({ hideAttachmentDir: e.target.checked === true }); },
3504
+ }),
3505
+ createElement("span", null, t("attachHideDir")),
3506
+ ),
3507
+ createElement("label", { className: "dsh-obs-cfgline" },
3508
+ createElement("input", {
3509
+ type: "checkbox",
3510
+ checked: attachCfg.deleteOrphans,
3511
+ onChange: (e) => { e.stopPropagation(); void applyAttachSettings({ deleteOrphanAttachments: e.target.checked === true }); },
3512
+ }),
3513
+ createElement("span", null, t("attachDeleteOrphans")),
3514
+ ),
2668
3515
  ),
2669
3516
  ),
2670
3517
  body,
@@ -3680,12 +4527,663 @@ window.__ModuleLoader__.load({
3680
4527
  );
3681
4528
  }
3682
4529
 
4530
+ /**
4531
+ * 关系图谱视图:全库 .md + [[双链]] + 标签(host `/obsidian/graph` 计算,
4532
+ * 力导向布局在 src/graph.js)。可切到「局部」= 当前打开笔记的 2 跳邻居。
4533
+ */
4534
+ function GraphView({ t, currentNote, onOpenNote, onClose }) {
4535
+ const [data, setData] = useState(null);
4536
+ const [error, setError] = useState("");
4537
+ const [loading, setLoading] = useState(true);
4538
+ const [mode, setMode] = useState("global");
4539
+ // 局部图谱跳数:与 Obsidian 局部图谱的 depth 滑杆对齐(范围 1–5,默认 1)
4540
+ const [fitKey, setFitKey] = useState(0);
4541
+ const [localDepth, setLocalDepth] = useState(() => {
4542
+ try {
4543
+ const v = parseInt(localStorage.getItem(LS_LOCAL_DEPTH) ?? "", 10);
4544
+ if (Number.isFinite(v) && v >= 1 && v <= 5) return v;
4545
+ } catch { /* ignore */ }
4546
+ return 1;
4547
+ });
4548
+ const [seed, setSeed] = useState(1);
4549
+ const [hovered, setHovered] = useState(null);
4550
+ const [reloadKey, setReloadKey] = useState(0);
4551
+ const [transform, setTransform] = useState({ x: 0, y: 0, s: 1 });
4552
+ const [detailsOpen, setDetailsOpen] = useState(false);
4553
+ const [buildProgress, setBuildProgress] = useState(null);
4554
+ const dragRef = useRef(null);
4555
+ const wrapRef = useRef(null);
4556
+ const panelDragRef = useRef(null);
4557
+ const resizeRef = useRef(null);
4558
+ // 面板几何:可拖动、可缩放、可关闭(和笔记 Dock 同一套交互),位置/尺寸持久化
4559
+ const [geometry, setGeometry] = useState(() => {
4560
+ const fallback = { x: null, y: 64, width: 860, height: 600 };
4561
+ try {
4562
+ const raw = localStorage.getItem(LS_GRAPH_GEOMETRY);
4563
+ if (raw !== null) {
4564
+ const parsed = JSON.parse(raw);
4565
+ if (typeof parsed === "object" && parsed !== null) {
4566
+ return {
4567
+ x: typeof parsed.x === "number" ? parsed.x : null,
4568
+ y: typeof parsed.y === "number" ? parsed.y : 64,
4569
+ width: Number.isFinite(parsed.width) ? Math.min(Math.max(560, parsed.width), 1600) : 860,
4570
+ height: Number.isFinite(parsed.height) ? Math.min(Math.max(360, parsed.height), 1200) : 600,
4571
+ };
4572
+ }
4573
+ }
4574
+ } catch { /* 忽略坏掉的本地存储 */ }
4575
+ return fallback;
4576
+ });
4577
+
4578
+ useEffect(() => {
4579
+ let cancelled = false;
4580
+ setLoading(true);
4581
+ setError("");
4582
+ setBuildProgress(null);
4583
+ // 旧 host 没有 /obsidian/graph 路由时的兜底:用已有的 tree/note 接口在浏览器里
4584
+ // 自建同一张图(插件升级后页面刷新即可用,不要求用户重启 DSH)。
4585
+ const buildFromClient = async () => {
4586
+ const dirs = [""];
4587
+ const notes = [];
4588
+ while (dirs.length > 0 && notes.length < 1200) {
4589
+ const rel = dirs.shift();
4590
+ let res;
4591
+ try {
4592
+ res = await treeApi.tree(rel);
4593
+ } catch {
4594
+ continue;
4595
+ }
4596
+ for (const entry of res.entries ?? []) {
4597
+ const path = rel === "" ? entry.name : `${rel}/${entry.name}`;
4598
+ if (entry.type === "dir") dirs.push(path);
4599
+ else if (entry.type === "note") notes.push({ path });
4600
+ }
4601
+ }
4602
+ const collected = new Array(notes.length).fill(null);
4603
+ let done = 0;
4604
+ let cursor = 0;
4605
+ setBuildProgress({ done, total: notes.length });
4606
+ const worker = async () => {
4607
+ while (cursor < notes.length && !cancelled) {
4608
+ const index = cursor;
4609
+ cursor += 1;
4610
+ try {
4611
+ const note = await treeApi.note(notes[index].path);
4612
+ collected[index] = {
4613
+ path: notes[index].path,
4614
+ links: Array.isArray(note.links) ? note.links : [],
4615
+ tags: graphModule.extractTags(note.content ?? "", note.frontmatter),
4616
+ };
4617
+ } catch { /* 单篇读失败不影响整张图 */ }
4618
+ done += 1;
4619
+ if ((done % 8 === 0 || done === notes.length) && !cancelled) setBuildProgress({ done, total: notes.length });
4620
+ }
4621
+ };
4622
+ await Promise.all(Array.from({ length: Math.min(6, Math.max(1, notes.length)) }, worker));
4623
+ if (cancelled) return null;
4624
+ return graphModule.buildGraphFromNotes(collected.filter(Boolean));
4625
+ };
4626
+ treeApi.root().then((root) => {
4627
+ if (cancelled) return null;
4628
+ const features = root && Array.isArray(root.features) ? root.features : [];
4629
+ if (!features.includes("graph")) return buildFromClient();
4630
+ return treeApi.graph();
4631
+ }).then((res) => {
4632
+ if (cancelled || res === null || res === undefined) return;
4633
+ setData(res);
4634
+ setLoading(false);
4635
+ setBuildProgress(null);
4636
+ }).catch((err) => {
4637
+ if (cancelled) return;
4638
+ setError(String(err && err.message ? err.message : err));
4639
+ setLoading(false);
4640
+ });
4641
+ return () => { cancelled = true; };
4642
+ }, [reloadKey]);
4643
+
4644
+ // 位置/尺寸持久化
4645
+ useEffect(() => {
4646
+ try { localStorage.setItem(LS_GRAPH_GEOMETRY, JSON.stringify(geometry)); } catch { /* ignore */ }
4647
+ }, [geometry]);
4648
+
4649
+ useEffect(() => {
4650
+ try { localStorage.setItem(LS_LOCAL_DEPTH, String(localDepth)); } catch { /* ignore */ }
4651
+ }, [localDepth]);
4652
+
4653
+ // 拖动标题栏移动面板(按钮上不触发)
4654
+ const beginPanelDrag = useCallback((e) => {
4655
+ if (e.target && e.target.closest && e.target.closest("button")) return;
4656
+ e.preventDefault();
4657
+ const el = e.currentTarget && e.currentTarget.parentElement;
4658
+ const base = { x: geometry.x !== null ? geometry.x : (el && typeof el.getBoundingClientRect === "function" ? el.getBoundingClientRect().left : 80), y: geometry.y };
4659
+ panelDragRef.current = { startX: e.clientX, startY: e.clientY, base };
4660
+ const onMove = (ev) => {
4661
+ const st = panelDragRef.current;
4662
+ if (!st) return;
4663
+ setGeometry((prev) => ({
4664
+ ...prev,
4665
+ x: Math.min(Math.max(0, st.base.x + ev.clientX - st.startX), Math.max(0, window.innerWidth - 240)),
4666
+ y: Math.min(Math.max(8, st.base.y + ev.clientY - st.startY), Math.max(8, window.innerHeight - 120)),
4667
+ }));
4668
+ };
4669
+ const onUp = () => {
4670
+ panelDragRef.current = null;
4671
+ window.removeEventListener("mousemove", onMove);
4672
+ window.removeEventListener("mouseup", onUp);
4673
+ };
4674
+ window.addEventListener("mousemove", onMove);
4675
+ window.addEventListener("mouseup", onUp);
4676
+ }, [geometry.x, geometry.y]);
4677
+
4678
+ // 右下角缩放面板
4679
+ const beginPanelResize = useCallback((e) => {
4680
+ e.preventDefault();
4681
+ e.stopPropagation();
4682
+ resizeRef.current = { startX: e.clientX, startY: e.clientY, baseW: geometry.width, baseH: geometry.height };
4683
+ const onMove = (ev) => {
4684
+ const st = resizeRef.current;
4685
+ if (!st) return;
4686
+ setGeometry((prev) => ({
4687
+ ...prev,
4688
+ width: Math.min(Math.max(560, st.baseW + ev.clientX - st.startX), Math.round(window.innerWidth * 0.96)),
4689
+ height: Math.min(Math.max(360, st.baseH + ev.clientY - st.startY), Math.round(window.innerHeight * 0.94)),
4690
+ }));
4691
+ };
4692
+ const onUp = () => {
4693
+ resizeRef.current = null;
4694
+ window.removeEventListener("mousemove", onMove);
4695
+ window.removeEventListener("mouseup", onUp);
4696
+ };
4697
+ window.addEventListener("mousemove", onMove);
4698
+ window.addEventListener("mouseup", onUp);
4699
+ }, [geometry.width, geometry.height]);
4700
+
4701
+ const currentRel = currentNote && currentNote.rel ? currentNote.rel : null;
4702
+ const focusId = currentRel !== null ? `note:${currentRel}` : null;
4703
+ const localAvailable = data !== null && focusId !== null && data.nodes.some((node) => node.id === focusId);
4704
+ const shown = useMemo(() => {
4705
+ if (data === null) return { nodes: [], edges: [] };
4706
+ if (mode === "local" && localAvailable) {
4707
+ const sub = graphModule.localSubgraph(data, focusId, localDepth);
4708
+ return { nodes: sub.nodes, edges: sub.edges };
4709
+ }
4710
+ return { nodes: data.nodes, edges: data.edges };
4711
+ }, [data, mode, localAvailable, focusId, localDepth]);
4712
+ const plan = useMemo(
4713
+ () => graphModule.layoutGraph(shown.nodes, shown.edges, { seed, maxNodes: 420 }),
4714
+ [shown, seed],
4715
+ );
4716
+
4717
+ // 打开 / 切换范围 / 重新布局后把整张图适配进画布
4718
+ useEffect(() => {
4719
+ const el = wrapRef.current;
4720
+ if (!el || plan.nodes.length === 0) return;
4721
+ const w = el.clientWidth || 900;
4722
+ const h = el.clientHeight || 560;
4723
+ const fit = Math.min((w - 30) / plan.width, (h - 30) / plan.height, 1);
4724
+ setTransform({ x: (w - plan.width * fit) / 2, y: (h - plan.height * fit) / 2, s: fit });
4725
+ }, [plan, fitKey]);
4726
+
4727
+ const onWheel = useCallback((e) => {
4728
+ if (plan.nodes.length === 0) return;
4729
+ if (e.cancelable !== false && e.preventDefault) e.preventDefault();
4730
+ const rect = e.currentTarget && e.currentTarget.getBoundingClientRect
4731
+ ? e.currentTarget.getBoundingClientRect()
4732
+ : { left: 0, top: 0 };
4733
+ const px = (e.clientX || 0) - rect.left;
4734
+ const py = (e.clientY || 0) - rect.top;
4735
+ setTransform((prev) => {
4736
+ const next = Math.min(4, Math.max(0.18, prev.s * (e.deltaY > 0 ? 0.9 : 1.11)));
4737
+ const k = next / prev.s;
4738
+ return { s: next, x: px - (px - prev.x) * k, y: py - (py - prev.y) * k };
4739
+ });
4740
+ }, [plan.nodes.length]);
4741
+ const onMouseDown = useCallback((e) => {
4742
+ if (e.button !== undefined && e.button !== 0) return;
4743
+ dragRef.current = { x: e.clientX, y: e.clientY, ox: transform.x, oy: transform.y };
4744
+ }, [transform.x, transform.y]);
4745
+ const onMouseMove = useCallback((e) => {
4746
+ const drag = dragRef.current;
4747
+ if (!drag) return;
4748
+ setTransform((prev) => ({ ...prev, x: drag.ox + (e.clientX - drag.x), y: drag.oy + (e.clientY - drag.y) }));
4749
+ }, []);
4750
+ const endDrag = useCallback(() => { dragRef.current = null; }, []);
4751
+
4752
+ const neighbourSet = useMemo(() => {
4753
+ if (hovered === null) return null;
4754
+ const set = new Set([hovered]);
4755
+ for (const edge of shown.edges) {
4756
+ if (edge.source === hovered) set.add(edge.target);
4757
+ if (edge.target === hovered) set.add(edge.source);
4758
+ }
4759
+ return set;
4760
+ }, [hovered, shown.edges]);
4761
+
4762
+ const fitLabel = (text, maxUnits) => {
4763
+ let out = "";
4764
+ let units = 0;
4765
+ for (const ch of text) {
4766
+ const w = /[\u2E80-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/.test(ch) ? 2 : 1;
4767
+ if (units + w > maxUnits) return `${out}…`;
4768
+ out += ch;
4769
+ units += w;
4770
+ }
4771
+ return out;
4772
+ };
4773
+ const showAllLabels = plan.nodes.length <= 150;
4774
+
4775
+ // 小按钮工厂:分段控件(全库/局部/聚焦)+ 右侧图标按钮,统一尺寸,永不换行
4776
+ const modeButton = (label, active, onClick, options = {}) => createElement("button", {
4777
+ className: "dsh-obs-mini",
4778
+ style: {
4779
+ flex: "none",
4780
+ width: "auto",
4781
+ height: 24,
4782
+ padding: "0 10px",
4783
+ border: "none",
4784
+ borderRight: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
4785
+ borderRadius: 0,
4786
+ background: active ? "var(--dsw-alias-interactive-bg-hover-accent, rgba(99,102,241,.18))" : "transparent",
4787
+ color: active ? "var(--dsw-alias-brand-primary, #8b7cf6)" : "var(--dsw-alias-label-secondary, #b3b8c2)",
4788
+ whiteSpace: "nowrap",
4789
+ cursor: options.disabled === true ? "not-allowed" : "pointer",
4790
+ opacity: options.disabled === true ? 0.45 : 1,
4791
+ },
4792
+ disabled: options.disabled === true,
4793
+ title: options.title,
4794
+ onClick,
4795
+ }, label);
4796
+ const iconButton = (glyph, title, onClick) => createElement("button", {
4797
+ className: "dsh-obs-mini",
4798
+ style: { flex: "none", width: "auto", height: 24, minWidth: 24, padding: "0 4px" },
4799
+ title,
4800
+ onClick,
4801
+ }, glyph);
4802
+
4803
+ const head = createElement("div", {
4804
+ className: "dsh-obs-graph-head",
4805
+ title: t("graphMove"),
4806
+ onMouseDown: beginPanelDrag,
4807
+ style: {
4808
+ display: "flex",
4809
+ alignItems: "center",
4810
+ gap: 10,
4811
+ padding: "8px 12px",
4812
+ borderBottom: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
4813
+ cursor: "move",
4814
+ userSelect: "none",
4815
+ flex: "none",
4816
+ },
4817
+ },
4818
+ createElement("div", {
4819
+ className: "dsh-obs-graph-title",
4820
+ style: { whiteSpace: "nowrap", flex: "none" },
4821
+ }, `🕸 ${t("graph")}`),
4822
+ data !== null && createElement("div", {
4823
+ className: "dsh-obs-graph-stats",
4824
+ style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "0 1 auto" },
4825
+ }, t("graphStats", { notes: data.stats.notes, tags: data.stats.tags, edges: data.stats.edges })),
4826
+ // 右侧控制区整体作为一块:分段按钮组(全库/局部/聚焦)+ 局部跳数 + 图标按钮
4827
+ createElement("div", {
4828
+ style: { display: "flex", alignItems: "center", gap: 10, marginLeft: "auto", flex: "none" },
4829
+ },
4830
+ createElement("div", {
4831
+ className: "dsh-obs-graph-modes",
4832
+ style: {
4833
+ display: "flex",
4834
+ alignItems: "center",
4835
+ flex: "none",
4836
+ border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
4837
+ borderRadius: 8,
4838
+ overflow: "hidden",
4839
+ width: "auto",
4840
+ },
4841
+ },
4842
+ modeButton(t("graphGlobal"), mode === "global", () => setMode("global")),
4843
+ modeButton(t("graphLocal"), mode === "local", () => setMode("local"), {
4844
+ disabled: !localAvailable,
4845
+ title: localAvailable ? t("graphLocalHint") : t("graphNoNote"),
4846
+ }),
4847
+ modeButton(t("graphFocus"), false, () => {
4848
+ if (!localAvailable) return;
4849
+ setMode("local");
4850
+ setFitKey((v) => v + 1);
4851
+ }, {
4852
+ disabled: !localAvailable,
4853
+ title: localAvailable ? t("graphFocusHint") : t("graphNoNote"),
4854
+ }),
4855
+ ),
4856
+ // 局部模式:跳数(与 Obsidian 局部图谱 depth 滑杆同范围 1–5)
4857
+ mode === "local" && createElement("span", {
4858
+ className: "dsh-obs-graph-depth",
4859
+ style: { display: "flex", alignItems: "center", gap: 2, flex: "none" },
4860
+ title: t("graphDepthHint"),
4861
+ },
4862
+ createElement("span", {
4863
+ className: "dsh-obs-graph-depth-label",
4864
+ style: { fontSize: 11, marginRight: 2, whiteSpace: "nowrap" },
4865
+ }, t("graphDepth")),
4866
+ [1, 2, 3, 4, 5].map((depth) => createElement("button", {
4867
+ key: depth,
4868
+ className: `dsh-obs-mini${localDepth === depth ? " dsh-obs-on" : ""}`,
4869
+ style: { flex: "none", width: "auto", height: 24, minWidth: 22, padding: "0 5px", whiteSpace: "nowrap" },
4870
+ onClick: () => setLocalDepth(depth),
4871
+ }, String(depth))),
4872
+ ),
4873
+ createElement("div", { style: { display: "flex", alignItems: "center", gap: 2, flex: "none" } },
4874
+ iconButton("↻", t("graphRerun"), () => setSeed((v) => v + 1)),
4875
+ iconButton("⟳", t("graphRefresh"), () => setReloadKey((v) => v + 1)),
4876
+ iconButton("✕", t("graphClose"), onClose),
4877
+ ),
4878
+ ),
4879
+ );
4880
+
4881
+ let body;
4882
+ if (loading) {
4883
+ body = createElement("div", { className: "dsh-obs-graph-empty" },
4884
+ buildProgress !== null
4885
+ ? t("graphBuilding", { done: buildProgress.done, total: buildProgress.total })
4886
+ : t("graphLoading"));
4887
+ } else if (error !== "") {
4888
+ body = createElement("div", { className: "dsh-obs-graph-error" },
4889
+ createElement("div", { className: "dsh-obs-graph-error-icon" }, "⚠️"),
4890
+ createElement("div", { className: "dsh-obs-graph-error-title" }, t("graphErrorTitle")),
4891
+ createElement("div", { className: "dsh-obs-graph-error-text" }, t("graphError", { msg: error })),
4892
+ createElement("div", { className: "dsh-obs-graph-error-actions" },
4893
+ createElement("button", { className: "dsh-obs-primary", onClick: () => setReloadKey((v) => v + 1) }, `↻ ${t("graphRetry")}`),
4894
+ createElement("button", {
4895
+ onClick: () => setDetailsOpen((v) => !v),
4896
+ }, detailsOpen ? t("graphDetailsHide") : t("graphDetails")),
4897
+ ),
4898
+ detailsOpen && createElement("pre", { className: "dsh-obs-graph-error-details" }, error),
4899
+ );
4900
+ } else if (plan.nodes.length === 0) {
4901
+ body = createElement("div", { className: "dsh-obs-graph-empty" },
4902
+ mode === "local" ? t("graphNoNote") : t("graphEmpty"));
4903
+ } else {
4904
+ const planById = new Map(plan.nodes.map((node) => [node.id, node]));
4905
+ body = createElement("div", {
4906
+ ref: wrapRef,
4907
+ className: "dsh-obs-graph-canvas",
4908
+ style: { position: "relative", flex: 1, minHeight: 0, overflow: "hidden", cursor: "grab" },
4909
+ onWheel,
4910
+ onMouseDown,
4911
+ onMouseMove,
4912
+ onMouseUp: endDrag,
4913
+ onMouseLeave: endDrag,
4914
+ },
4915
+ createElement("svg", { className: "dsh-obs-graph-svg" },
4916
+ createElement("g", { transform: `translate(${transform.x},${transform.y}) scale(${transform.s})` },
4917
+ shown.edges.map((edge) => {
4918
+ const a = planById.get(edge.source);
4919
+ const b = planById.get(edge.target);
4920
+ if (!a || !b) return null;
4921
+ const active = neighbourSet !== null && (neighbourSet.has(edge.source) || neighbourSet.has(edge.target));
4922
+ return createElement("line", {
4923
+ key: `${edge.source}-${edge.target}`,
4924
+ className: `dsh-obs-graph-edge${active ? " dsh-obs-graph-edge-on" : ""}`,
4925
+ x1: a.x,
4926
+ y1: a.y,
4927
+ x2: b.x,
4928
+ y2: b.y,
4929
+ });
4930
+ }),
4931
+ plan.nodes.map((node) => {
4932
+ const isTag = node.kind === "tag";
4933
+ const radius = isTag ? 7 : 4 + Math.min(9, node.degree * 1.5);
4934
+ const dim = neighbourSet !== null && !neighbourSet.has(node.id);
4935
+ const cls = `dsh-obs-graph-node ${isTag ? "dsh-obs-graph-tag" : "dsh-obs-graph-note"}`
4936
+ + `${node.id === hovered ? " dsh-obs-graph-sel" : ""}${dim ? " dsh-obs-graph-dim" : ""}`
4937
+ + `${focusId !== null && node.id === focusId ? " dsh-obs-graph-focus" : ""}`;
4938
+ const label = showAllLabels || node.degree >= 5 || node.id === hovered
4939
+ ? fitLabel(node.label, 26)
4940
+ : "";
4941
+ return createElement("g", {
4942
+ key: node.id,
4943
+ className: cls,
4944
+ transform: `translate(${node.x},${node.y})`,
4945
+ onMouseEnter: () => setHovered(node.id),
4946
+ onMouseLeave: () => setHovered(null),
4947
+ onClick: (e) => {
4948
+ if (e && e.stopPropagation) e.stopPropagation();
4949
+ if (!isTag && onOpenNote) onOpenNote(node.path, node.label);
4950
+ else setHovered(node.id);
4951
+ },
4952
+ },
4953
+ createElement(isTag ? "rect" : "circle", isTag
4954
+ ? { x: -radius, y: -radius, width: radius * 2, height: radius * 2, rx: 3 }
4955
+ : { r: radius }),
4956
+ createElement("title", null, node.label),
4957
+ label !== "" && createElement("text", { x: radius + 6, y: 4 }, label),
4958
+ );
4959
+ }),
4960
+ ),
4961
+ ),
4962
+ );
4963
+ }
4964
+
4965
+ const truncatedNote = plan.truncated ? t("graphTruncated", { n: plan.nodes.length }) : t("graphHint");
4966
+ // 关键布局全部走内联样式:即使插件的 <style> 注入失败(旧 bundle / 特殊宿主),
4967
+ // 面板也不会变成左上角裸文本;z-index 也内联,避免被宿主容器压住。
4968
+ const defaultX = typeof window !== "undefined"
4969
+ ? Math.max(16, Math.round((window.innerWidth - geometry.width) / 2))
4970
+ : 80;
4971
+ const style = {
4972
+ position: "fixed",
4973
+ left: geometry.x === null ? defaultX : geometry.x,
4974
+ top: geometry.y,
4975
+ width: geometry.width,
4976
+ height: geometry.height,
4977
+ zIndex: 2147483000,
4978
+ display: "flex",
4979
+ flexDirection: "column",
4980
+ boxSizing: "border-box",
4981
+ overflow: "hidden",
4982
+ background: "var(--dsw-alias-bg-overlay, #1f2328)",
4983
+ color: "var(--dsw-alias-label-primary, #e6e6e6)",
4984
+ border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.15))",
4985
+ borderRadius: 14,
4986
+ boxShadow: "0 24px 64px rgba(0,0,0,.45)",
4987
+ };
4988
+ return createElement("div", {
4989
+ className: "dsh-obs-graph",
4990
+ role: "dialog",
4991
+ "aria-label": t("graph"),
4992
+ style,
4993
+ },
4994
+ head,
4995
+ body,
4996
+ createElement("div", {
4997
+ className: "dsh-obs-graph-hint",
4998
+ style: {
4999
+ flex: "none",
5000
+ display: "flex",
5001
+ alignItems: "center",
5002
+ gap: 12,
5003
+ padding: "6px 14px",
5004
+ borderTop: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
5005
+ fontSize: 11,
5006
+ color: "var(--dsw-alias-label-tertiary, #9aa0aa)",
5007
+ },
5008
+ },
5009
+ createElement("span", {
5010
+ style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "0 1 auto" },
5011
+ }, truncatedNote),
5012
+ createElement("span", {
5013
+ className: "dsh-obs-graph-legend",
5014
+ style: { display: "flex", alignItems: "center", gap: 10, flex: "none", marginLeft: "auto", whiteSpace: "nowrap" },
5015
+ },
5016
+ createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4 } },
5017
+ createElement("span", { className: "dsh-obs-legend-dot" }), t("graphLegendNote")),
5018
+ createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4 } },
5019
+ createElement("span", { className: "dsh-obs-legend-tag" }), t("graphLegendTag")),
5020
+ createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4 } },
5021
+ createElement("span", { className: "dsh-obs-legend-focus" }), t("graphLegendFocus")),
5022
+ createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4 } },
5023
+ createElement("span", { className: "dsh-obs-legend-link" }), t("graphLegendLink")),
5024
+ ),
5025
+ ),
5026
+ createElement("div", { className: "dsh-obs-graph-resize", title: t("resize"), onMouseDown: beginPanelResize }),
5027
+ );
5028
+ }
5029
+
5030
+ /** shell.overlay 包装:只有 shared.graphOpen 时才挂载 GraphView(关闭即卸载)。 */
5031
+ function GraphOverlay({ t }) {
5032
+ const { graphOpen, openNote } = useShared();
5033
+ if (!graphOpen) return null;
5034
+ return createElement(GraphView, {
5035
+ t,
5036
+ currentNote: openNote,
5037
+ onOpenNote: (rel, name) => patchShared({ openNote: { rel, name } }),
5038
+ onClose: () => patchShared({ graphOpen: false }),
5039
+ });
5040
+ }
5041
+
3683
5042
  // ── 右侧笔记 Dock(shell.overlay)──────────────────────────────────────────
5043
+
5044
+ /**
5045
+ * 思维导图视图(B1):把 note.content 的标题/列表画成横向树。
5046
+ * - 滚轮缩放(以指针为中心)/ 拖动平移;
5047
+ * - 单击节点:选中并显示操作条(跳到源码行 / 把分支插入对话);
5048
+ * - 双击节点:直接跳到该节点的源码行。
5049
+ */
5050
+ function MindMapView({ note, t, onJumpLine, onAskBranch }) {
5051
+ const tree = useMemo(
5052
+ () => mindMapModule.buildTree(note.content, { title: note.name.replace(/\.md$/i, "") }),
5053
+ [note.content, note.name],
5054
+ );
5055
+ const plan = useMemo(() => mindMapModule.layout(tree), [tree]);
5056
+ const [viewState, setViewState] = useState({ x: 0, y: 14, s: 1 });
5057
+ const [selected, setSelected] = useState(null);
5058
+ const dragRef = useRef(null);
5059
+ const wrapRef = useRef(null);
5060
+ const hasNodes = plan.nodes.length > 1;
5061
+
5062
+ // 初始把整张导图缩放进 Dock 可视区(导图比面板宽,不 fit 只能看到最左一列)
5063
+ useEffect(() => {
5064
+ const el = wrapRef.current;
5065
+ if (!el || !hasNodes) return;
5066
+ const w = el.clientWidth || 480;
5067
+ const h = el.clientHeight || 320;
5068
+ // 横向导图比面板宽:只按高度适配(保持字号可读),横向靠拖动平移
5069
+ const fit = Math.min(1, (h - 28) / plan.height);
5070
+ setViewState({
5071
+ x: 10,
5072
+ y: Math.max(10, (h - plan.height * fit) / 2),
5073
+ s: fit,
5074
+ });
5075
+ }, [plan, hasNodes]);
5076
+
5077
+ // SVG 无自动省略:按估算字宽截断,完整文本放 <title> 悬停显示
5078
+ const fitText = (text, width) => {
5079
+ const maxUnits = Math.max(6, Math.floor((width - 26) / 7.2));
5080
+ let out = "";
5081
+ let units = 0;
5082
+ for (const ch of text) {
5083
+ const u = /[\u2E80-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/.test(ch) ? 2 : 1;
5084
+ if (units + u > maxUnits) return `${out}…`;
5085
+ out += ch;
5086
+ units += u;
5087
+ }
5088
+ return out;
5089
+ };
5090
+
5091
+ const onWheel = useCallback((e) => {
5092
+ if (!hasNodes) return;
5093
+ if (e.cancelable !== false && e.preventDefault) e.preventDefault();
5094
+ const rect = e.currentTarget && e.currentTarget.getBoundingClientRect
5095
+ ? e.currentTarget.getBoundingClientRect()
5096
+ : { left: 0, top: 0 };
5097
+ const px = (e.clientX || 0) - rect.left;
5098
+ const py = (e.clientY || 0) - rect.top;
5099
+ setViewState((prev) => {
5100
+ const next = Math.min(2.6, Math.max(0.35, prev.s * (e.deltaY > 0 ? 0.88 : 1.14)));
5101
+ const k = next / prev.s;
5102
+ return { s: next, x: px - (px - prev.x) * k, y: py - (py - prev.y) * k };
5103
+ });
5104
+ }, [hasNodes]);
5105
+
5106
+ const onMouseDown = useCallback((e) => {
5107
+ if (e.button !== undefined && e.button !== 0) return;
5108
+ dragRef.current = { x: e.clientX, y: e.clientY, ox: viewState.x, oy: viewState.y };
5109
+ }, [viewState.x, viewState.y]);
5110
+ const onMouseMove = useCallback((e) => {
5111
+ const drag = dragRef.current;
5112
+ if (!drag) return;
5113
+ setViewState((prev) => ({ ...prev, x: drag.ox + (e.clientX - drag.x), y: drag.oy + (e.clientY - drag.y) }));
5114
+ }, []);
5115
+ const endDrag = useCallback(() => { dragRef.current = null; }, []);
5116
+
5117
+ if (!hasNodes) {
5118
+ return createElement("div", { className: "dsh-obs-map-empty" }, t("mapEmpty"));
5119
+ }
5120
+
5121
+ const selectedNode = selected !== null ? plan.byId[selected] : null;
5122
+ return createElement("div", {
5123
+ ref: wrapRef,
5124
+ className: "dsh-obs-map",
5125
+ onWheel,
5126
+ onMouseDown,
5127
+ onMouseMove,
5128
+ onMouseUp: endDrag,
5129
+ onMouseLeave: endDrag,
5130
+ },
5131
+ createElement("svg", { className: "dsh-obs-map-svg" },
5132
+ createElement("g", { transform: `translate(${viewState.x},${viewState.y}) scale(${viewState.s})` },
5133
+ plan.edges.map((edge) => {
5134
+ const a = plan.byId[edge.from];
5135
+ const b = plan.byId[edge.to];
5136
+ if (!a || !b) return null;
5137
+ const x1 = a.x + a.w;
5138
+ const x2 = b.x;
5139
+ const mx = (x1 + x2) / 2;
5140
+ return createElement("path", {
5141
+ key: `${edge.from}-${edge.to}`,
5142
+ className: "dsh-obs-map-edge",
5143
+ d: `M ${x1} ${a.y} C ${mx} ${a.y}, ${mx} ${b.y}, ${x2} ${b.y}`,
5144
+ });
5145
+ }),
5146
+ plan.nodes.map((node) => createElement("g", {
5147
+ key: node.id,
5148
+ className: `dsh-obs-map-node dsh-obs-map-${node.kind}${selected === node.id ? " dsh-obs-map-sel" : ""}`,
5149
+ transform: `translate(${node.x},${node.y - node.h / 2})`,
5150
+ onClick: (e) => {
5151
+ if (e && e.stopPropagation) e.stopPropagation();
5152
+ setSelected(node.id);
5153
+ },
5154
+ onDoubleClick: (e) => {
5155
+ if (e && e.stopPropagation) e.stopPropagation();
5156
+ if (node.line > 0) onJumpLine(node.line);
5157
+ },
5158
+ },
5159
+ createElement("rect", { width: node.w, height: node.h, rx: 9 }),
5160
+ createElement("text", { x: 13, y: node.h / 2 + 5 }, fitText(node.text, node.w)),
5161
+ createElement("title", null, node.text),
5162
+ )),
5163
+ ),
5164
+ ),
5165
+ selectedNode !== null && createElement("div", { className: "dsh-obs-map-actions" },
5166
+ createElement("span", { className: "dsh-obs-map-line" }, `L${selectedNode.line}`),
5167
+ createElement("button", {
5168
+ onClick: () => onJumpLine(selectedNode.line),
5169
+ }, `↩ ${t("mapJump")}`),
5170
+ createElement("button", {
5171
+ className: "dsh-obs-primary",
5172
+ onClick: () => onAskBranch(selectedNode),
5173
+ }, `⤴ ${t("mapAsk")}`),
5174
+ ),
5175
+ createElement("div", { className: "dsh-obs-map-hint" }, t("mapHint")),
5176
+ );
5177
+ }
5178
+
3684
5179
  function Dock({ t, workspaces }) {
3685
5180
  const { openNote, vaultRev } = useShared();
3686
5181
  const [note, setNote] = useState(null);
3687
5182
  const [root, setRoot] = useState("");
3688
5183
  const [editing, setEditing] = useState(false);
5184
+ // 视图:预览(行号)/ 思维导图;编辑态始终是编辑器
5185
+ const [view, setView] = useState("preview");
5186
+ const [jumpLine, setJumpLine] = useState(null);
3689
5187
  const [draft, setDraft] = useState("");
3690
5188
  const [saving, setSaving] = useState(false);
3691
5189
  const [error, setError] = useState("");
@@ -3982,6 +5480,60 @@ window.__ModuleLoader__.load({
3982
5480
  patchShared({ openNote: { rel: found.path, name: found.name } });
3983
5481
  }, [editing, draft, note, t]);
3984
5482
 
5483
+ /** 思维导图 → 预览:跳到源码行并短暂高亮(行元素带 data-line)。 */
5484
+ const jumpToLine = useCallback((line) => {
5485
+ if (!line || line < 1) return;
5486
+ setView("preview");
5487
+ setJumpLine(line);
5488
+ }, []);
5489
+
5490
+ /** 思维导图 → 对话:把「该节点 + 全部后代」的源码区间按选区提问同款 prompt 插入输入框。 */
5491
+ const askBranch = useCallback((node) => {
5492
+ if (!note) return;
5493
+ const span = mindMapModule.branchSpan(note.content, node);
5494
+ const prompt = buildPrompt(
5495
+ t,
5496
+ treeApi.absPath(note.path, root),
5497
+ note.content,
5498
+ { text: span.text, start: span.start, end: span.end, mode: "preview" },
5499
+ "",
5500
+ );
5501
+ if (injectPrompt(prompt)) {
5502
+ showToast(t("branchCited", { n: span.endLine - span.startLine + 1 }));
5503
+ return;
5504
+ }
5505
+ try {
5506
+ if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
5507
+ navigator.clipboard.writeText(prompt).then(() => showToast(t("noInput"))).catch(() => showToast(t("citeFail")));
5508
+ } else {
5509
+ showToast(t("citeFail"));
5510
+ }
5511
+ } catch {
5512
+ showToast(t("citeFail"));
5513
+ }
5514
+ }, [note, t, root]);
5515
+
5516
+ // 切笔记时回到预览态(避免上一份笔记的导图/跳转状态残留)
5517
+ useEffect(() => {
5518
+ setView("preview");
5519
+ setJumpLine(null);
5520
+ }, [openNote]);
5521
+
5522
+ // 预览行高亮:跳到行号后滚动到该行并闪一下(1.8s 后恢复)
5523
+ useEffect(() => {
5524
+ if (jumpLine === null || view !== "preview" || !previewRef.current) return undefined;
5525
+ const el = previewRef.current.querySelector(`[data-line="${jumpLine}"]`);
5526
+ if (el) {
5527
+ if (typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center" });
5528
+ el.classList.add("dsh-obs-md-flash");
5529
+ }
5530
+ const timer = window.setTimeout(() => {
5531
+ if (el) el.classList.remove("dsh-obs-md-flash");
5532
+ setJumpLine(null);
5533
+ }, 1800);
5534
+ return () => window.clearTimeout(timer);
5535
+ }, [jumpLine, view, note, vaultRev]);
5536
+
3985
5537
  const dirty = editing && note !== null && draft !== note.content;
3986
5538
 
3987
5539
  // 编辑态行号(每行一个,对应源码行数;textarea 为 white-space:pre 无折行,行号与文本行一一对齐)
@@ -4005,6 +5557,11 @@ window.__ModuleLoader__.load({
4005
5557
  createElement("span", null, "📝"),
4006
5558
  createElement("span", null, note ? note.name : openNote.name),
4007
5559
  ),
5560
+ !editing && note && createElement("button", {
5561
+ className: view === "map" ? "dsh-obs-on" : undefined,
5562
+ title: view === "map" ? t("preview") : t("mindMap"),
5563
+ onClick: () => setView((prev) => (prev === "map" ? "preview" : "map")),
5564
+ }, view === "map" ? `📄 ${t("preview")}` : `🧠 ${t("mindMap")}`),
4008
5565
  !editing && note && createElement("button", { onClick: () => void cite() }, `⤴ ${t("cite")}`),
4009
5566
  !editing && note && createElement("button", { onClick: () => { setDraft(note.content); setEditing(true); } }, t("edit")),
4010
5567
  editing && createElement("button", {
@@ -4056,8 +5613,17 @@ window.__ModuleLoader__.load({
4056
5613
  },
4057
5614
  }),
4058
5615
  )
4059
- : createElement("div", { className: "dsh-obs-dock-body" },
4060
- createElement("div", { ref: previewRef, className: "dsh-obs-md" },
5616
+ : view === "map"
5617
+ ? createElement("div", { className: "dsh-obs-dock-body dsh-obs-dock-map" },
5618
+ createElement(MindMapView, {
5619
+ note,
5620
+ t,
5621
+ onJumpLine: jumpToLine,
5622
+ onAskBranch: askBranch,
5623
+ }),
5624
+ )
5625
+ : createElement("div", { className: "dsh-obs-dock-body" },
5626
+ createElement("div", { ref: previewRef, className: "dsh-obs-md" },
4061
5627
  renderNumbered(note.content, {
4062
5628
  onWikilink: (target) => void openWikilink(target),
4063
5629
  onText: () => {},
@@ -4346,10 +5912,15 @@ window.__ModuleLoader__.load({
4346
5912
  { name: "shell.overlay", id: "obsidian-prompt", order: 6, label: "Obsidian text prompt", locale: NS },
4347
5913
  Prompt,
4348
5914
  );
5915
+ const d4 = ctx.slots.register(
5916
+ { name: "shell.overlay", id: "obsidian-graph", order: 32, label: "Obsidian relation graph", locale: NS },
5917
+ GraphOverlay,
5918
+ );
4349
5919
  return () => {
4350
5920
  d1();
4351
5921
  d2();
4352
5922
  d3();
5923
+ d4();
4353
5924
  };
4354
5925
  });
4355
5926
  }