dsh-obsidian-notes 0.5.2 → 0.6.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/README.md +41 -10
- package/README.zh.md +19 -5
- package/lib/client.js +1535 -17
- package/package.json +1 -1
- package/src/client.js +876 -3
- package/src/graph.js +348 -0
- package/src/index.js +234 -2
- package/src/markdown.js +102 -14
- package/src/mindmap.js +207 -0
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 >
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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: "未找到对话输入框,引用已复制到剪贴板",
|
|
@@ -1346,6 +2029,44 @@ window.__ModuleLoader__.load({
|
|
|
1346
2029
|
cancel: "Cancel",
|
|
1347
2030
|
discard: "Discard unsaved changes?",
|
|
1348
2031
|
cite: "Cite to chat",
|
|
2032
|
+
mindMap: "Mind map",
|
|
2033
|
+
preview: "Preview",
|
|
2034
|
+
mapEmpty: "No headings or list items yet — nothing to draw",
|
|
2035
|
+
mapHint: "Scroll to zoom · drag to pan · click a node to select · double-click to jump to its line",
|
|
2036
|
+
mapJump: "Jump to line",
|
|
2037
|
+
mapAsk: "Insert branch into chat",
|
|
2038
|
+
branchCited: "Branch inserted into the chat ({n} lines)",
|
|
2039
|
+
graph: "Graph view",
|
|
2040
|
+
graphHint: "Scroll to zoom · drag to pan · click a note to open it · hover to see neighbours",
|
|
2041
|
+
graphGlobal: "Vault",
|
|
2042
|
+
graphLocal: "Local",
|
|
2043
|
+
graphLocalHint: "Show the 2-hop neighbourhood of the note that is open",
|
|
2044
|
+
graphRefresh: "Re-read the vault",
|
|
2045
|
+
graphRerun: "Re-run the layout",
|
|
2046
|
+
graphClose: "Close the graph",
|
|
2047
|
+
graphLoading: "Building the graph…",
|
|
2048
|
+
graphEmpty: "No notes or links to draw yet",
|
|
2049
|
+
graphError: "Graph view failed: {msg}",
|
|
2050
|
+
graphStats: "{notes} notes · {tags} tags · {edges} links",
|
|
2051
|
+
graphTruncated: "Large vault: only the {n} most connected nodes are drawn",
|
|
2052
|
+
graphNoNote: "Open a note in the sidebar first to see the local graph",
|
|
2053
|
+
graphOpen: "Open the graph view",
|
|
2054
|
+
graphMove: "Drag to move the panel",
|
|
2055
|
+
graphNeedsRestart: "The graph view needs a DSH restart",
|
|
2056
|
+
graphRestartHint: "The running DSH host still predates the /obsidian/graph route. Quit DSH completely (Cmd+Q), reopen it, then press Retry.",
|
|
2057
|
+
graphErrorTitle: "The graph view failed to load",
|
|
2058
|
+
graphBuilding: "Building the graph from notes ({done}/{total})…",
|
|
2059
|
+
graphFocus: "Focus",
|
|
2060
|
+
graphFocusHint: "Jump to the local graph centred on the open note (click again to re-centre)",
|
|
2061
|
+
graphLegendNote: "note",
|
|
2062
|
+
graphLegendTag: "tag",
|
|
2063
|
+
graphLegendFocus: "current note",
|
|
2064
|
+
graphLegendLink: "link",
|
|
2065
|
+
graphDepth: "Depth",
|
|
2066
|
+
graphDepthHint: "Local graph depth, 1–5 hops (same range as Obsidian's local graph depth)",
|
|
2067
|
+
graphRetry: "Retry",
|
|
2068
|
+
graphDetails: "Details",
|
|
2069
|
+
graphDetailsHide: "Hide details",
|
|
1349
2070
|
cited: "Cited to chat",
|
|
1350
2071
|
copied: "Reference copied — paste it into the input",
|
|
1351
2072
|
noInput: "No chat input found — reference copied to clipboard",
|
|
@@ -1396,6 +2117,8 @@ window.__ModuleLoader__.load({
|
|
|
1396
2117
|
// ── 共享外部 store(跨槽位:侧边栏 / Dock / Toast)──────────────────────────
|
|
1397
2118
|
const LS_NOTES_OPEN = "dsh-obsidian:notesOpen";
|
|
1398
2119
|
const LS_DOCK_WIDTH = "dsh-obsidian:dockWidth";
|
|
2120
|
+
const LS_GRAPH_GEOMETRY = "dsh-obsidian:graphGeometry";
|
|
2121
|
+
const LS_LOCAL_DEPTH = "dsh-obsidian:localDepth";
|
|
1399
2122
|
|
|
1400
2123
|
function readNotesOpen() {
|
|
1401
2124
|
try {
|
|
@@ -1405,7 +2128,7 @@ window.__ModuleLoader__.load({
|
|
|
1405
2128
|
}
|
|
1406
2129
|
}
|
|
1407
2130
|
|
|
1408
|
-
let shared = { notesOpen: readNotesOpen(), openNote: null, vaultRev: 0, toast: null, noteCount: 0, prompt: null };
|
|
2131
|
+
let shared = { notesOpen: readNotesOpen(), openNote: null, graphOpen: false, vaultRev: 0, toast: null, noteCount: 0, prompt: null };
|
|
1409
2132
|
const sharedListeners = new Set();
|
|
1410
2133
|
|
|
1411
2134
|
function patchShared(patch) {
|
|
@@ -1847,7 +2570,69 @@ window.__ModuleLoader__.load({
|
|
|
1847
2570
|
`.dsh-obs-md-lc{flex:1;min-width:0}`,
|
|
1848
2571
|
`.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
2572
|
`.dsh-obs-md-row.dsh-obs-md-heading .dsh-obs-md-lc{font-weight:600}`,
|
|
2573
|
+
// 行内 Obsidian 语法:==高亮== / ~~删除线~~ / 裸链接与自动链接
|
|
2574
|
+
`.dsh-obs-md mark,.dsh-obs-mark{background:rgba(255,208,0,.32);color:inherit;border-radius:2px;padding:0 1px}`,
|
|
2575
|
+
`.dsh-obs-md del{opacity:.62;text-decoration:line-through}`,
|
|
2576
|
+
`.dsh-obs-md .dsh-obs-autolink{color:var(--dsw-alias-brand-primary);text-decoration:underline;text-underline-offset:2px;word-break:break-all}`,
|
|
1850
2577
|
`.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)}`,
|
|
2578
|
+
// ── 思维导图视图(B1)──
|
|
2579
|
+
`.dsh-obs-dock-map{overflow:hidden;padding:0}`,
|
|
2580
|
+
`.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}`,
|
|
2581
|
+
`.dsh-obs-map-svg{display:block;width:100%;height:100%}`,
|
|
2582
|
+
`.dsh-obs-map-edge{fill:none;stroke:var(--dsw-alias-border-l2);stroke-width:1.5}`,
|
|
2583
|
+
`.dsh-obs-map-node{cursor:pointer}`,
|
|
2584
|
+
`.dsh-obs-map-node rect{fill:var(--dsw-alias-bg-overlay);stroke:var(--dsw-alias-border-l2);stroke-width:1}`,
|
|
2585
|
+
`.dsh-obs-map-heading rect{stroke:var(--dsw-alias-brand-primary)}`,
|
|
2586
|
+
`.dsh-obs-map-root rect{fill:var(--dsw-alias-brand-primary);stroke:var(--dsw-alias-brand-primary)}`,
|
|
2587
|
+
`.dsh-obs-map-root text{fill:#fff;font-weight:600}`,
|
|
2588
|
+
`.dsh-obs-map-node text{font-size:12.5px;fill:var(--dsw-alias-label-primary);pointer-events:none}`,
|
|
2589
|
+
`.dsh-obs-map-sel rect{stroke:var(--dsw-alias-brand-primary);stroke-width:2}`,
|
|
2590
|
+
`.dsh-obs-map-hint{position:absolute;left:10px;bottom:8px;font-size:11px;color:var(--dsw-alias-label-tertiary);pointer-events:none}`,
|
|
2591
|
+
`.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}`,
|
|
2592
|
+
`.dsh-obs-map-actions .dsh-obs-map-line{color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}`,
|
|
2593
|
+
`.dsh-obs-map-actions button{padding:3px 8px;font-size:12px}`,
|
|
2594
|
+
`.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}`,
|
|
2595
|
+
`.dsh-obs-md-row.dsh-obs-md-flash{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 16%,transparent);border-radius:4px}`,
|
|
2596
|
+
// ── 关系图谱(shell.overlay,vault 级独立视图)──
|
|
2597
|
+
`.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)}`,
|
|
2598
|
+
`.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}`,
|
|
2599
|
+
`.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}`,
|
|
2600
|
+
`.dsh-obs-graph-error{display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;gap:8px;padding:32px;text-align:center}`,
|
|
2601
|
+
`.dsh-obs-graph-error-icon{font-size:26px}`,
|
|
2602
|
+
`.dsh-obs-graph-error-title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}`,
|
|
2603
|
+
`.dsh-obs-graph-error-text{font-size:12px;line-height:1.7;color:var(--dsw-alias-label-secondary);max-width:560px}`,
|
|
2604
|
+
`.dsh-obs-graph-error-actions{display:flex;gap:8px;margin-top:8px}`,
|
|
2605
|
+
`.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}`,
|
|
2606
|
+
`.dsh-obs-graph-error-actions button:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
2607
|
+
`.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)}`,
|
|
2608
|
+
`.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}`,
|
|
2609
|
+
`.dsh-obs-graph-title{font-weight:600;font-size:14px}`,
|
|
2610
|
+
`.dsh-obs-graph-stats{font-size:11px;color:var(--dsw-alias-label-tertiary)}`,
|
|
2611
|
+
`.dsh-obs-graph-modes{display:flex;gap:4px;margin-left:auto}`,
|
|
2612
|
+
`.dsh-obs-graph-modes button[disabled]{opacity:.45;cursor:not-allowed}`,
|
|
2613
|
+
`.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)}`,
|
|
2614
|
+
`.dsh-obs-graph-depth-label{font-size:11px;color:var(--dsw-alias-label-tertiary);margin-right:2px}`,
|
|
2615
|
+
`.dsh-obs-graph-depth button{min-width:20px;padding:0 4px}`,
|
|
2616
|
+
`.dsh-obs-graph-head button{white-space:nowrap;flex:none}`,
|
|
2617
|
+
`.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}`,
|
|
2618
|
+
`.dsh-obs-legend-tag{width:8px;height:8px;border-radius:2px;background:var(--dsw-alias-brand-primary);display:inline-block}`,
|
|
2619
|
+
`.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}`,
|
|
2620
|
+
`.dsh-obs-legend-link{width:14px;height:1.5px;background:var(--dsw-alias-border-l2);display:inline-block}`,
|
|
2621
|
+
`.dsh-obs-graph-canvas{position:relative;flex:1;min-height:0;overflow:hidden;cursor:grab;user-select:none;-webkit-user-select:none}`,
|
|
2622
|
+
`.dsh-obs-graph-svg{display:block;width:100%;height:100%}`,
|
|
2623
|
+
`.dsh-obs-graph-edge{stroke:var(--dsw-alias-border-l2);stroke-width:1;opacity:.55}`,
|
|
2624
|
+
`.dsh-obs-graph-edge-on{stroke:var(--dsw-alias-brand-primary);opacity:.9;stroke-width:1.6}`,
|
|
2625
|
+
`.dsh-obs-graph-node{cursor:pointer}`,
|
|
2626
|
+
`.dsh-obs-graph-note circle{fill:var(--dsw-alias-bg-overlay);stroke:var(--dsw-alias-label-secondary);stroke-width:1.4}`,
|
|
2627
|
+
`.dsh-obs-graph-tag rect{fill:var(--dsw-alias-brand-primary);opacity:.75}`,
|
|
2628
|
+
`.dsh-obs-graph-sel circle,.dsh-obs-graph-sel rect{stroke:var(--dsw-alias-brand-primary);stroke-width:2.2}`,
|
|
2629
|
+
`.dsh-obs-graph-focus circle{stroke:var(--dsw-alias-state-error-primary);stroke-width:2.4}`,
|
|
2630
|
+
`.dsh-obs-graph-dim{opacity:.25}`,
|
|
2631
|
+
`.dsh-obs-graph-node text{font-size:11px;fill:var(--dsw-alias-label-primary);pointer-events:none}`,
|
|
2632
|
+
`.dsh-obs-graph-tag text{fill:var(--dsw-alias-label-secondary)}`,
|
|
2633
|
+
`.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)}`,
|
|
2634
|
+
`.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}`,
|
|
2635
|
+
`.dsh-obs-graph-error, .dsh-obs-graph-empty{flex:1;min-height:0}`,
|
|
1851
2636
|
// ── Obsidian callout(`> [!type] 标题`)配色:类型在根节点上带 data-callout ──
|
|
1852
2637
|
`[data-callout="note"]{--dsh-obs-callout-color:#448aff}`,
|
|
1853
2638
|
`[data-callout="abstract"]{--dsh-obs-callout-color:#00b8d4}`,
|
|
@@ -2167,6 +2952,7 @@ window.__ModuleLoader__.load({
|
|
|
2167
2952
|
// `![[附件/a.png]]` 会被当成 Vault 根下的 `附件/a.png` → 403 → 只剩断图。
|
|
2168
2953
|
assetUrl: (rel, from) => `/obsidian/file?path=${encodeURIComponent(rel)}`
|
|
2169
2954
|
+ (from ? `&from=${encodeURIComponent(from)}` : ""),
|
|
2955
|
+
graph: () => api("/graph"),
|
|
2170
2956
|
note: (rel) => api(`/note?path=${encodeURIComponent(rel)}`),
|
|
2171
2957
|
save: (rel, content) => api("/note", {
|
|
2172
2958
|
method: "POST",
|
|
@@ -2608,6 +3394,14 @@ window.__ModuleLoader__.load({
|
|
|
2608
3394
|
onClick: (e) => { e.stopPropagation(); void newNote(""); },
|
|
2609
3395
|
}, "+"),
|
|
2610
3396
|
),
|
|
3397
|
+
rootInfo && rootInfo.exists && createElement("div", { className: "dsh-obs-root-row", title: t("graphOpen") },
|
|
3398
|
+
createElement("span", { className: "dsh-obs-root-path" }, `🕸 ${t("graph")}`),
|
|
3399
|
+
createElement("button", {
|
|
3400
|
+
className: "dsh-obs-mini",
|
|
3401
|
+
title: t("graphOpen"),
|
|
3402
|
+
onClick: (e) => { e.stopPropagation(); patchShared({ graphOpen: true }); },
|
|
3403
|
+
}, "↗"),
|
|
3404
|
+
),
|
|
2611
3405
|
rootInfo && rootInfo.exists && attachDir !== null && createElement("div", { className: "dsh-obs-root-row", title: t("attachSettings") },
|
|
2612
3406
|
createElement("span", { className: "dsh-obs-root-path" },
|
|
2613
3407
|
`📎 ${t("attachLocation")}: ${attachLocationLabel(attachCfg, attachDir, t)}`),
|
|
@@ -3680,12 +4474,663 @@ window.__ModuleLoader__.load({
|
|
|
3680
4474
|
);
|
|
3681
4475
|
}
|
|
3682
4476
|
|
|
4477
|
+
/**
|
|
4478
|
+
* 关系图谱视图:全库 .md + [[双链]] + 标签(host `/obsidian/graph` 计算,
|
|
4479
|
+
* 力导向布局在 src/graph.js)。可切到「局部」= 当前打开笔记的 2 跳邻居。
|
|
4480
|
+
*/
|
|
4481
|
+
function GraphView({ t, currentNote, onOpenNote, onClose }) {
|
|
4482
|
+
const [data, setData] = useState(null);
|
|
4483
|
+
const [error, setError] = useState("");
|
|
4484
|
+
const [loading, setLoading] = useState(true);
|
|
4485
|
+
const [mode, setMode] = useState("global");
|
|
4486
|
+
// 局部图谱跳数:与 Obsidian 局部图谱的 depth 滑杆对齐(范围 1–5,默认 1)
|
|
4487
|
+
const [fitKey, setFitKey] = useState(0);
|
|
4488
|
+
const [localDepth, setLocalDepth] = useState(() => {
|
|
4489
|
+
try {
|
|
4490
|
+
const v = parseInt(localStorage.getItem(LS_LOCAL_DEPTH) ?? "", 10);
|
|
4491
|
+
if (Number.isFinite(v) && v >= 1 && v <= 5) return v;
|
|
4492
|
+
} catch { /* ignore */ }
|
|
4493
|
+
return 1;
|
|
4494
|
+
});
|
|
4495
|
+
const [seed, setSeed] = useState(1);
|
|
4496
|
+
const [hovered, setHovered] = useState(null);
|
|
4497
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
4498
|
+
const [transform, setTransform] = useState({ x: 0, y: 0, s: 1 });
|
|
4499
|
+
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
4500
|
+
const [buildProgress, setBuildProgress] = useState(null);
|
|
4501
|
+
const dragRef = useRef(null);
|
|
4502
|
+
const wrapRef = useRef(null);
|
|
4503
|
+
const panelDragRef = useRef(null);
|
|
4504
|
+
const resizeRef = useRef(null);
|
|
4505
|
+
// 面板几何:可拖动、可缩放、可关闭(和笔记 Dock 同一套交互),位置/尺寸持久化
|
|
4506
|
+
const [geometry, setGeometry] = useState(() => {
|
|
4507
|
+
const fallback = { x: null, y: 64, width: 860, height: 600 };
|
|
4508
|
+
try {
|
|
4509
|
+
const raw = localStorage.getItem(LS_GRAPH_GEOMETRY);
|
|
4510
|
+
if (raw !== null) {
|
|
4511
|
+
const parsed = JSON.parse(raw);
|
|
4512
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
4513
|
+
return {
|
|
4514
|
+
x: typeof parsed.x === "number" ? parsed.x : null,
|
|
4515
|
+
y: typeof parsed.y === "number" ? parsed.y : 64,
|
|
4516
|
+
width: Number.isFinite(parsed.width) ? Math.min(Math.max(560, parsed.width), 1600) : 860,
|
|
4517
|
+
height: Number.isFinite(parsed.height) ? Math.min(Math.max(360, parsed.height), 1200) : 600,
|
|
4518
|
+
};
|
|
4519
|
+
}
|
|
4520
|
+
}
|
|
4521
|
+
} catch { /* 忽略坏掉的本地存储 */ }
|
|
4522
|
+
return fallback;
|
|
4523
|
+
});
|
|
4524
|
+
|
|
4525
|
+
useEffect(() => {
|
|
4526
|
+
let cancelled = false;
|
|
4527
|
+
setLoading(true);
|
|
4528
|
+
setError("");
|
|
4529
|
+
setBuildProgress(null);
|
|
4530
|
+
// 旧 host 没有 /obsidian/graph 路由时的兜底:用已有的 tree/note 接口在浏览器里
|
|
4531
|
+
// 自建同一张图(插件升级后页面刷新即可用,不要求用户重启 DSH)。
|
|
4532
|
+
const buildFromClient = async () => {
|
|
4533
|
+
const dirs = [""];
|
|
4534
|
+
const notes = [];
|
|
4535
|
+
while (dirs.length > 0 && notes.length < 1200) {
|
|
4536
|
+
const rel = dirs.shift();
|
|
4537
|
+
let res;
|
|
4538
|
+
try {
|
|
4539
|
+
res = await treeApi.tree(rel);
|
|
4540
|
+
} catch {
|
|
4541
|
+
continue;
|
|
4542
|
+
}
|
|
4543
|
+
for (const entry of res.entries ?? []) {
|
|
4544
|
+
const path = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
4545
|
+
if (entry.type === "dir") dirs.push(path);
|
|
4546
|
+
else if (entry.type === "note") notes.push({ path });
|
|
4547
|
+
}
|
|
4548
|
+
}
|
|
4549
|
+
const collected = new Array(notes.length).fill(null);
|
|
4550
|
+
let done = 0;
|
|
4551
|
+
let cursor = 0;
|
|
4552
|
+
setBuildProgress({ done, total: notes.length });
|
|
4553
|
+
const worker = async () => {
|
|
4554
|
+
while (cursor < notes.length && !cancelled) {
|
|
4555
|
+
const index = cursor;
|
|
4556
|
+
cursor += 1;
|
|
4557
|
+
try {
|
|
4558
|
+
const note = await treeApi.note(notes[index].path);
|
|
4559
|
+
collected[index] = {
|
|
4560
|
+
path: notes[index].path,
|
|
4561
|
+
links: Array.isArray(note.links) ? note.links : [],
|
|
4562
|
+
tags: graphModule.extractTags(note.content ?? "", note.frontmatter),
|
|
4563
|
+
};
|
|
4564
|
+
} catch { /* 单篇读失败不影响整张图 */ }
|
|
4565
|
+
done += 1;
|
|
4566
|
+
if ((done % 8 === 0 || done === notes.length) && !cancelled) setBuildProgress({ done, total: notes.length });
|
|
4567
|
+
}
|
|
4568
|
+
};
|
|
4569
|
+
await Promise.all(Array.from({ length: Math.min(6, Math.max(1, notes.length)) }, worker));
|
|
4570
|
+
if (cancelled) return null;
|
|
4571
|
+
return graphModule.buildGraphFromNotes(collected.filter(Boolean));
|
|
4572
|
+
};
|
|
4573
|
+
treeApi.root().then((root) => {
|
|
4574
|
+
if (cancelled) return null;
|
|
4575
|
+
const features = root && Array.isArray(root.features) ? root.features : [];
|
|
4576
|
+
if (!features.includes("graph")) return buildFromClient();
|
|
4577
|
+
return treeApi.graph();
|
|
4578
|
+
}).then((res) => {
|
|
4579
|
+
if (cancelled || res === null || res === undefined) return;
|
|
4580
|
+
setData(res);
|
|
4581
|
+
setLoading(false);
|
|
4582
|
+
setBuildProgress(null);
|
|
4583
|
+
}).catch((err) => {
|
|
4584
|
+
if (cancelled) return;
|
|
4585
|
+
setError(String(err && err.message ? err.message : err));
|
|
4586
|
+
setLoading(false);
|
|
4587
|
+
});
|
|
4588
|
+
return () => { cancelled = true; };
|
|
4589
|
+
}, [reloadKey]);
|
|
4590
|
+
|
|
4591
|
+
// 位置/尺寸持久化
|
|
4592
|
+
useEffect(() => {
|
|
4593
|
+
try { localStorage.setItem(LS_GRAPH_GEOMETRY, JSON.stringify(geometry)); } catch { /* ignore */ }
|
|
4594
|
+
}, [geometry]);
|
|
4595
|
+
|
|
4596
|
+
useEffect(() => {
|
|
4597
|
+
try { localStorage.setItem(LS_LOCAL_DEPTH, String(localDepth)); } catch { /* ignore */ }
|
|
4598
|
+
}, [localDepth]);
|
|
4599
|
+
|
|
4600
|
+
// 拖动标题栏移动面板(按钮上不触发)
|
|
4601
|
+
const beginPanelDrag = useCallback((e) => {
|
|
4602
|
+
if (e.target && e.target.closest && e.target.closest("button")) return;
|
|
4603
|
+
e.preventDefault();
|
|
4604
|
+
const el = e.currentTarget && e.currentTarget.parentElement;
|
|
4605
|
+
const base = { x: geometry.x !== null ? geometry.x : (el && typeof el.getBoundingClientRect === "function" ? el.getBoundingClientRect().left : 80), y: geometry.y };
|
|
4606
|
+
panelDragRef.current = { startX: e.clientX, startY: e.clientY, base };
|
|
4607
|
+
const onMove = (ev) => {
|
|
4608
|
+
const st = panelDragRef.current;
|
|
4609
|
+
if (!st) return;
|
|
4610
|
+
setGeometry((prev) => ({
|
|
4611
|
+
...prev,
|
|
4612
|
+
x: Math.min(Math.max(0, st.base.x + ev.clientX - st.startX), Math.max(0, window.innerWidth - 240)),
|
|
4613
|
+
y: Math.min(Math.max(8, st.base.y + ev.clientY - st.startY), Math.max(8, window.innerHeight - 120)),
|
|
4614
|
+
}));
|
|
4615
|
+
};
|
|
4616
|
+
const onUp = () => {
|
|
4617
|
+
panelDragRef.current = null;
|
|
4618
|
+
window.removeEventListener("mousemove", onMove);
|
|
4619
|
+
window.removeEventListener("mouseup", onUp);
|
|
4620
|
+
};
|
|
4621
|
+
window.addEventListener("mousemove", onMove);
|
|
4622
|
+
window.addEventListener("mouseup", onUp);
|
|
4623
|
+
}, [geometry.x, geometry.y]);
|
|
4624
|
+
|
|
4625
|
+
// 右下角缩放面板
|
|
4626
|
+
const beginPanelResize = useCallback((e) => {
|
|
4627
|
+
e.preventDefault();
|
|
4628
|
+
e.stopPropagation();
|
|
4629
|
+
resizeRef.current = { startX: e.clientX, startY: e.clientY, baseW: geometry.width, baseH: geometry.height };
|
|
4630
|
+
const onMove = (ev) => {
|
|
4631
|
+
const st = resizeRef.current;
|
|
4632
|
+
if (!st) return;
|
|
4633
|
+
setGeometry((prev) => ({
|
|
4634
|
+
...prev,
|
|
4635
|
+
width: Math.min(Math.max(560, st.baseW + ev.clientX - st.startX), Math.round(window.innerWidth * 0.96)),
|
|
4636
|
+
height: Math.min(Math.max(360, st.baseH + ev.clientY - st.startY), Math.round(window.innerHeight * 0.94)),
|
|
4637
|
+
}));
|
|
4638
|
+
};
|
|
4639
|
+
const onUp = () => {
|
|
4640
|
+
resizeRef.current = null;
|
|
4641
|
+
window.removeEventListener("mousemove", onMove);
|
|
4642
|
+
window.removeEventListener("mouseup", onUp);
|
|
4643
|
+
};
|
|
4644
|
+
window.addEventListener("mousemove", onMove);
|
|
4645
|
+
window.addEventListener("mouseup", onUp);
|
|
4646
|
+
}, [geometry.width, geometry.height]);
|
|
4647
|
+
|
|
4648
|
+
const currentRel = currentNote && currentNote.rel ? currentNote.rel : null;
|
|
4649
|
+
const focusId = currentRel !== null ? `note:${currentRel}` : null;
|
|
4650
|
+
const localAvailable = data !== null && focusId !== null && data.nodes.some((node) => node.id === focusId);
|
|
4651
|
+
const shown = useMemo(() => {
|
|
4652
|
+
if (data === null) return { nodes: [], edges: [] };
|
|
4653
|
+
if (mode === "local" && localAvailable) {
|
|
4654
|
+
const sub = graphModule.localSubgraph(data, focusId, localDepth);
|
|
4655
|
+
return { nodes: sub.nodes, edges: sub.edges };
|
|
4656
|
+
}
|
|
4657
|
+
return { nodes: data.nodes, edges: data.edges };
|
|
4658
|
+
}, [data, mode, localAvailable, focusId, localDepth]);
|
|
4659
|
+
const plan = useMemo(
|
|
4660
|
+
() => graphModule.layoutGraph(shown.nodes, shown.edges, { seed, maxNodes: 420 }),
|
|
4661
|
+
[shown, seed],
|
|
4662
|
+
);
|
|
4663
|
+
|
|
4664
|
+
// 打开 / 切换范围 / 重新布局后把整张图适配进画布
|
|
4665
|
+
useEffect(() => {
|
|
4666
|
+
const el = wrapRef.current;
|
|
4667
|
+
if (!el || plan.nodes.length === 0) return;
|
|
4668
|
+
const w = el.clientWidth || 900;
|
|
4669
|
+
const h = el.clientHeight || 560;
|
|
4670
|
+
const fit = Math.min((w - 30) / plan.width, (h - 30) / plan.height, 1);
|
|
4671
|
+
setTransform({ x: (w - plan.width * fit) / 2, y: (h - plan.height * fit) / 2, s: fit });
|
|
4672
|
+
}, [plan, fitKey]);
|
|
4673
|
+
|
|
4674
|
+
const onWheel = useCallback((e) => {
|
|
4675
|
+
if (plan.nodes.length === 0) return;
|
|
4676
|
+
if (e.cancelable !== false && e.preventDefault) e.preventDefault();
|
|
4677
|
+
const rect = e.currentTarget && e.currentTarget.getBoundingClientRect
|
|
4678
|
+
? e.currentTarget.getBoundingClientRect()
|
|
4679
|
+
: { left: 0, top: 0 };
|
|
4680
|
+
const px = (e.clientX || 0) - rect.left;
|
|
4681
|
+
const py = (e.clientY || 0) - rect.top;
|
|
4682
|
+
setTransform((prev) => {
|
|
4683
|
+
const next = Math.min(4, Math.max(0.18, prev.s * (e.deltaY > 0 ? 0.9 : 1.11)));
|
|
4684
|
+
const k = next / prev.s;
|
|
4685
|
+
return { s: next, x: px - (px - prev.x) * k, y: py - (py - prev.y) * k };
|
|
4686
|
+
});
|
|
4687
|
+
}, [plan.nodes.length]);
|
|
4688
|
+
const onMouseDown = useCallback((e) => {
|
|
4689
|
+
if (e.button !== undefined && e.button !== 0) return;
|
|
4690
|
+
dragRef.current = { x: e.clientX, y: e.clientY, ox: transform.x, oy: transform.y };
|
|
4691
|
+
}, [transform.x, transform.y]);
|
|
4692
|
+
const onMouseMove = useCallback((e) => {
|
|
4693
|
+
const drag = dragRef.current;
|
|
4694
|
+
if (!drag) return;
|
|
4695
|
+
setTransform((prev) => ({ ...prev, x: drag.ox + (e.clientX - drag.x), y: drag.oy + (e.clientY - drag.y) }));
|
|
4696
|
+
}, []);
|
|
4697
|
+
const endDrag = useCallback(() => { dragRef.current = null; }, []);
|
|
4698
|
+
|
|
4699
|
+
const neighbourSet = useMemo(() => {
|
|
4700
|
+
if (hovered === null) return null;
|
|
4701
|
+
const set = new Set([hovered]);
|
|
4702
|
+
for (const edge of shown.edges) {
|
|
4703
|
+
if (edge.source === hovered) set.add(edge.target);
|
|
4704
|
+
if (edge.target === hovered) set.add(edge.source);
|
|
4705
|
+
}
|
|
4706
|
+
return set;
|
|
4707
|
+
}, [hovered, shown.edges]);
|
|
4708
|
+
|
|
4709
|
+
const fitLabel = (text, maxUnits) => {
|
|
4710
|
+
let out = "";
|
|
4711
|
+
let units = 0;
|
|
4712
|
+
for (const ch of text) {
|
|
4713
|
+
const w = /[\u2E80-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/.test(ch) ? 2 : 1;
|
|
4714
|
+
if (units + w > maxUnits) return `${out}…`;
|
|
4715
|
+
out += ch;
|
|
4716
|
+
units += w;
|
|
4717
|
+
}
|
|
4718
|
+
return out;
|
|
4719
|
+
};
|
|
4720
|
+
const showAllLabels = plan.nodes.length <= 150;
|
|
4721
|
+
|
|
4722
|
+
// 小按钮工厂:分段控件(全库/局部/聚焦)+ 右侧图标按钮,统一尺寸,永不换行
|
|
4723
|
+
const modeButton = (label, active, onClick, options = {}) => createElement("button", {
|
|
4724
|
+
className: "dsh-obs-mini",
|
|
4725
|
+
style: {
|
|
4726
|
+
flex: "none",
|
|
4727
|
+
width: "auto",
|
|
4728
|
+
height: 24,
|
|
4729
|
+
padding: "0 10px",
|
|
4730
|
+
border: "none",
|
|
4731
|
+
borderRight: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
|
|
4732
|
+
borderRadius: 0,
|
|
4733
|
+
background: active ? "var(--dsw-alias-interactive-bg-hover-accent, rgba(99,102,241,.18))" : "transparent",
|
|
4734
|
+
color: active ? "var(--dsw-alias-brand-primary, #8b7cf6)" : "var(--dsw-alias-label-secondary, #b3b8c2)",
|
|
4735
|
+
whiteSpace: "nowrap",
|
|
4736
|
+
cursor: options.disabled === true ? "not-allowed" : "pointer",
|
|
4737
|
+
opacity: options.disabled === true ? 0.45 : 1,
|
|
4738
|
+
},
|
|
4739
|
+
disabled: options.disabled === true,
|
|
4740
|
+
title: options.title,
|
|
4741
|
+
onClick,
|
|
4742
|
+
}, label);
|
|
4743
|
+
const iconButton = (glyph, title, onClick) => createElement("button", {
|
|
4744
|
+
className: "dsh-obs-mini",
|
|
4745
|
+
style: { flex: "none", width: "auto", height: 24, minWidth: 24, padding: "0 4px" },
|
|
4746
|
+
title,
|
|
4747
|
+
onClick,
|
|
4748
|
+
}, glyph);
|
|
4749
|
+
|
|
4750
|
+
const head = createElement("div", {
|
|
4751
|
+
className: "dsh-obs-graph-head",
|
|
4752
|
+
title: t("graphMove"),
|
|
4753
|
+
onMouseDown: beginPanelDrag,
|
|
4754
|
+
style: {
|
|
4755
|
+
display: "flex",
|
|
4756
|
+
alignItems: "center",
|
|
4757
|
+
gap: 10,
|
|
4758
|
+
padding: "8px 12px",
|
|
4759
|
+
borderBottom: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
|
|
4760
|
+
cursor: "move",
|
|
4761
|
+
userSelect: "none",
|
|
4762
|
+
flex: "none",
|
|
4763
|
+
},
|
|
4764
|
+
},
|
|
4765
|
+
createElement("div", {
|
|
4766
|
+
className: "dsh-obs-graph-title",
|
|
4767
|
+
style: { whiteSpace: "nowrap", flex: "none" },
|
|
4768
|
+
}, `🕸 ${t("graph")}`),
|
|
4769
|
+
data !== null && createElement("div", {
|
|
4770
|
+
className: "dsh-obs-graph-stats",
|
|
4771
|
+
style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "0 1 auto" },
|
|
4772
|
+
}, t("graphStats", { notes: data.stats.notes, tags: data.stats.tags, edges: data.stats.edges })),
|
|
4773
|
+
// 右侧控制区整体作为一块:分段按钮组(全库/局部/聚焦)+ 局部跳数 + 图标按钮
|
|
4774
|
+
createElement("div", {
|
|
4775
|
+
style: { display: "flex", alignItems: "center", gap: 10, marginLeft: "auto", flex: "none" },
|
|
4776
|
+
},
|
|
4777
|
+
createElement("div", {
|
|
4778
|
+
className: "dsh-obs-graph-modes",
|
|
4779
|
+
style: {
|
|
4780
|
+
display: "flex",
|
|
4781
|
+
alignItems: "center",
|
|
4782
|
+
flex: "none",
|
|
4783
|
+
border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
|
|
4784
|
+
borderRadius: 8,
|
|
4785
|
+
overflow: "hidden",
|
|
4786
|
+
width: "auto",
|
|
4787
|
+
},
|
|
4788
|
+
},
|
|
4789
|
+
modeButton(t("graphGlobal"), mode === "global", () => setMode("global")),
|
|
4790
|
+
modeButton(t("graphLocal"), mode === "local", () => setMode("local"), {
|
|
4791
|
+
disabled: !localAvailable,
|
|
4792
|
+
title: localAvailable ? t("graphLocalHint") : t("graphNoNote"),
|
|
4793
|
+
}),
|
|
4794
|
+
modeButton(t("graphFocus"), false, () => {
|
|
4795
|
+
if (!localAvailable) return;
|
|
4796
|
+
setMode("local");
|
|
4797
|
+
setFitKey((v) => v + 1);
|
|
4798
|
+
}, {
|
|
4799
|
+
disabled: !localAvailable,
|
|
4800
|
+
title: localAvailable ? t("graphFocusHint") : t("graphNoNote"),
|
|
4801
|
+
}),
|
|
4802
|
+
),
|
|
4803
|
+
// 局部模式:跳数(与 Obsidian 局部图谱 depth 滑杆同范围 1–5)
|
|
4804
|
+
mode === "local" && createElement("span", {
|
|
4805
|
+
className: "dsh-obs-graph-depth",
|
|
4806
|
+
style: { display: "flex", alignItems: "center", gap: 2, flex: "none" },
|
|
4807
|
+
title: t("graphDepthHint"),
|
|
4808
|
+
},
|
|
4809
|
+
createElement("span", {
|
|
4810
|
+
className: "dsh-obs-graph-depth-label",
|
|
4811
|
+
style: { fontSize: 11, marginRight: 2, whiteSpace: "nowrap" },
|
|
4812
|
+
}, t("graphDepth")),
|
|
4813
|
+
[1, 2, 3, 4, 5].map((depth) => createElement("button", {
|
|
4814
|
+
key: depth,
|
|
4815
|
+
className: `dsh-obs-mini${localDepth === depth ? " dsh-obs-on" : ""}`,
|
|
4816
|
+
style: { flex: "none", width: "auto", height: 24, minWidth: 22, padding: "0 5px", whiteSpace: "nowrap" },
|
|
4817
|
+
onClick: () => setLocalDepth(depth),
|
|
4818
|
+
}, String(depth))),
|
|
4819
|
+
),
|
|
4820
|
+
createElement("div", { style: { display: "flex", alignItems: "center", gap: 2, flex: "none" } },
|
|
4821
|
+
iconButton("↻", t("graphRerun"), () => setSeed((v) => v + 1)),
|
|
4822
|
+
iconButton("⟳", t("graphRefresh"), () => setReloadKey((v) => v + 1)),
|
|
4823
|
+
iconButton("✕", t("graphClose"), onClose),
|
|
4824
|
+
),
|
|
4825
|
+
),
|
|
4826
|
+
);
|
|
4827
|
+
|
|
4828
|
+
let body;
|
|
4829
|
+
if (loading) {
|
|
4830
|
+
body = createElement("div", { className: "dsh-obs-graph-empty" },
|
|
4831
|
+
buildProgress !== null
|
|
4832
|
+
? t("graphBuilding", { done: buildProgress.done, total: buildProgress.total })
|
|
4833
|
+
: t("graphLoading"));
|
|
4834
|
+
} else if (error !== "") {
|
|
4835
|
+
body = createElement("div", { className: "dsh-obs-graph-error" },
|
|
4836
|
+
createElement("div", { className: "dsh-obs-graph-error-icon" }, "⚠️"),
|
|
4837
|
+
createElement("div", { className: "dsh-obs-graph-error-title" }, t("graphErrorTitle")),
|
|
4838
|
+
createElement("div", { className: "dsh-obs-graph-error-text" }, t("graphError", { msg: error })),
|
|
4839
|
+
createElement("div", { className: "dsh-obs-graph-error-actions" },
|
|
4840
|
+
createElement("button", { className: "dsh-obs-primary", onClick: () => setReloadKey((v) => v + 1) }, `↻ ${t("graphRetry")}`),
|
|
4841
|
+
createElement("button", {
|
|
4842
|
+
onClick: () => setDetailsOpen((v) => !v),
|
|
4843
|
+
}, detailsOpen ? t("graphDetailsHide") : t("graphDetails")),
|
|
4844
|
+
),
|
|
4845
|
+
detailsOpen && createElement("pre", { className: "dsh-obs-graph-error-details" }, error),
|
|
4846
|
+
);
|
|
4847
|
+
} else if (plan.nodes.length === 0) {
|
|
4848
|
+
body = createElement("div", { className: "dsh-obs-graph-empty" },
|
|
4849
|
+
mode === "local" ? t("graphNoNote") : t("graphEmpty"));
|
|
4850
|
+
} else {
|
|
4851
|
+
const planById = new Map(plan.nodes.map((node) => [node.id, node]));
|
|
4852
|
+
body = createElement("div", {
|
|
4853
|
+
ref: wrapRef,
|
|
4854
|
+
className: "dsh-obs-graph-canvas",
|
|
4855
|
+
style: { position: "relative", flex: 1, minHeight: 0, overflow: "hidden", cursor: "grab" },
|
|
4856
|
+
onWheel,
|
|
4857
|
+
onMouseDown,
|
|
4858
|
+
onMouseMove,
|
|
4859
|
+
onMouseUp: endDrag,
|
|
4860
|
+
onMouseLeave: endDrag,
|
|
4861
|
+
},
|
|
4862
|
+
createElement("svg", { className: "dsh-obs-graph-svg" },
|
|
4863
|
+
createElement("g", { transform: `translate(${transform.x},${transform.y}) scale(${transform.s})` },
|
|
4864
|
+
shown.edges.map((edge) => {
|
|
4865
|
+
const a = planById.get(edge.source);
|
|
4866
|
+
const b = planById.get(edge.target);
|
|
4867
|
+
if (!a || !b) return null;
|
|
4868
|
+
const active = neighbourSet !== null && (neighbourSet.has(edge.source) || neighbourSet.has(edge.target));
|
|
4869
|
+
return createElement("line", {
|
|
4870
|
+
key: `${edge.source}-${edge.target}`,
|
|
4871
|
+
className: `dsh-obs-graph-edge${active ? " dsh-obs-graph-edge-on" : ""}`,
|
|
4872
|
+
x1: a.x,
|
|
4873
|
+
y1: a.y,
|
|
4874
|
+
x2: b.x,
|
|
4875
|
+
y2: b.y,
|
|
4876
|
+
});
|
|
4877
|
+
}),
|
|
4878
|
+
plan.nodes.map((node) => {
|
|
4879
|
+
const isTag = node.kind === "tag";
|
|
4880
|
+
const radius = isTag ? 7 : 4 + Math.min(9, node.degree * 1.5);
|
|
4881
|
+
const dim = neighbourSet !== null && !neighbourSet.has(node.id);
|
|
4882
|
+
const cls = `dsh-obs-graph-node ${isTag ? "dsh-obs-graph-tag" : "dsh-obs-graph-note"}`
|
|
4883
|
+
+ `${node.id === hovered ? " dsh-obs-graph-sel" : ""}${dim ? " dsh-obs-graph-dim" : ""}`
|
|
4884
|
+
+ `${focusId !== null && node.id === focusId ? " dsh-obs-graph-focus" : ""}`;
|
|
4885
|
+
const label = showAllLabels || node.degree >= 5 || node.id === hovered
|
|
4886
|
+
? fitLabel(node.label, 26)
|
|
4887
|
+
: "";
|
|
4888
|
+
return createElement("g", {
|
|
4889
|
+
key: node.id,
|
|
4890
|
+
className: cls,
|
|
4891
|
+
transform: `translate(${node.x},${node.y})`,
|
|
4892
|
+
onMouseEnter: () => setHovered(node.id),
|
|
4893
|
+
onMouseLeave: () => setHovered(null),
|
|
4894
|
+
onClick: (e) => {
|
|
4895
|
+
if (e && e.stopPropagation) e.stopPropagation();
|
|
4896
|
+
if (!isTag && onOpenNote) onOpenNote(node.path, node.label);
|
|
4897
|
+
else setHovered(node.id);
|
|
4898
|
+
},
|
|
4899
|
+
},
|
|
4900
|
+
createElement(isTag ? "rect" : "circle", isTag
|
|
4901
|
+
? { x: -radius, y: -radius, width: radius * 2, height: radius * 2, rx: 3 }
|
|
4902
|
+
: { r: radius }),
|
|
4903
|
+
createElement("title", null, node.label),
|
|
4904
|
+
label !== "" && createElement("text", { x: radius + 6, y: 4 }, label),
|
|
4905
|
+
);
|
|
4906
|
+
}),
|
|
4907
|
+
),
|
|
4908
|
+
),
|
|
4909
|
+
);
|
|
4910
|
+
}
|
|
4911
|
+
|
|
4912
|
+
const truncatedNote = plan.truncated ? t("graphTruncated", { n: plan.nodes.length }) : t("graphHint");
|
|
4913
|
+
// 关键布局全部走内联样式:即使插件的 <style> 注入失败(旧 bundle / 特殊宿主),
|
|
4914
|
+
// 面板也不会变成左上角裸文本;z-index 也内联,避免被宿主容器压住。
|
|
4915
|
+
const defaultX = typeof window !== "undefined"
|
|
4916
|
+
? Math.max(16, Math.round((window.innerWidth - geometry.width) / 2))
|
|
4917
|
+
: 80;
|
|
4918
|
+
const style = {
|
|
4919
|
+
position: "fixed",
|
|
4920
|
+
left: geometry.x === null ? defaultX : geometry.x,
|
|
4921
|
+
top: geometry.y,
|
|
4922
|
+
width: geometry.width,
|
|
4923
|
+
height: geometry.height,
|
|
4924
|
+
zIndex: 2147483000,
|
|
4925
|
+
display: "flex",
|
|
4926
|
+
flexDirection: "column",
|
|
4927
|
+
boxSizing: "border-box",
|
|
4928
|
+
overflow: "hidden",
|
|
4929
|
+
background: "var(--dsw-alias-bg-overlay, #1f2328)",
|
|
4930
|
+
color: "var(--dsw-alias-label-primary, #e6e6e6)",
|
|
4931
|
+
border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.15))",
|
|
4932
|
+
borderRadius: 14,
|
|
4933
|
+
boxShadow: "0 24px 64px rgba(0,0,0,.45)",
|
|
4934
|
+
};
|
|
4935
|
+
return createElement("div", {
|
|
4936
|
+
className: "dsh-obs-graph",
|
|
4937
|
+
role: "dialog",
|
|
4938
|
+
"aria-label": t("graph"),
|
|
4939
|
+
style,
|
|
4940
|
+
},
|
|
4941
|
+
head,
|
|
4942
|
+
body,
|
|
4943
|
+
createElement("div", {
|
|
4944
|
+
className: "dsh-obs-graph-hint",
|
|
4945
|
+
style: {
|
|
4946
|
+
flex: "none",
|
|
4947
|
+
display: "flex",
|
|
4948
|
+
alignItems: "center",
|
|
4949
|
+
gap: 12,
|
|
4950
|
+
padding: "6px 14px",
|
|
4951
|
+
borderTop: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
|
|
4952
|
+
fontSize: 11,
|
|
4953
|
+
color: "var(--dsw-alias-label-tertiary, #9aa0aa)",
|
|
4954
|
+
},
|
|
4955
|
+
},
|
|
4956
|
+
createElement("span", {
|
|
4957
|
+
style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "0 1 auto" },
|
|
4958
|
+
}, truncatedNote),
|
|
4959
|
+
createElement("span", {
|
|
4960
|
+
className: "dsh-obs-graph-legend",
|
|
4961
|
+
style: { display: "flex", alignItems: "center", gap: 10, flex: "none", marginLeft: "auto", whiteSpace: "nowrap" },
|
|
4962
|
+
},
|
|
4963
|
+
createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4 } },
|
|
4964
|
+
createElement("span", { className: "dsh-obs-legend-dot" }), t("graphLegendNote")),
|
|
4965
|
+
createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4 } },
|
|
4966
|
+
createElement("span", { className: "dsh-obs-legend-tag" }), t("graphLegendTag")),
|
|
4967
|
+
createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4 } },
|
|
4968
|
+
createElement("span", { className: "dsh-obs-legend-focus" }), t("graphLegendFocus")),
|
|
4969
|
+
createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4 } },
|
|
4970
|
+
createElement("span", { className: "dsh-obs-legend-link" }), t("graphLegendLink")),
|
|
4971
|
+
),
|
|
4972
|
+
),
|
|
4973
|
+
createElement("div", { className: "dsh-obs-graph-resize", title: t("resize"), onMouseDown: beginPanelResize }),
|
|
4974
|
+
);
|
|
4975
|
+
}
|
|
4976
|
+
|
|
4977
|
+
/** shell.overlay 包装:只有 shared.graphOpen 时才挂载 GraphView(关闭即卸载)。 */
|
|
4978
|
+
function GraphOverlay({ t }) {
|
|
4979
|
+
const { graphOpen, openNote } = useShared();
|
|
4980
|
+
if (!graphOpen) return null;
|
|
4981
|
+
return createElement(GraphView, {
|
|
4982
|
+
t,
|
|
4983
|
+
currentNote: openNote,
|
|
4984
|
+
onOpenNote: (rel, name) => patchShared({ openNote: { rel, name } }),
|
|
4985
|
+
onClose: () => patchShared({ graphOpen: false }),
|
|
4986
|
+
});
|
|
4987
|
+
}
|
|
4988
|
+
|
|
3683
4989
|
// ── 右侧笔记 Dock(shell.overlay)──────────────────────────────────────────
|
|
4990
|
+
|
|
4991
|
+
/**
|
|
4992
|
+
* 思维导图视图(B1):把 note.content 的标题/列表画成横向树。
|
|
4993
|
+
* - 滚轮缩放(以指针为中心)/ 拖动平移;
|
|
4994
|
+
* - 单击节点:选中并显示操作条(跳到源码行 / 把分支插入对话);
|
|
4995
|
+
* - 双击节点:直接跳到该节点的源码行。
|
|
4996
|
+
*/
|
|
4997
|
+
function MindMapView({ note, t, onJumpLine, onAskBranch }) {
|
|
4998
|
+
const tree = useMemo(
|
|
4999
|
+
() => mindMapModule.buildTree(note.content, { title: note.name.replace(/\.md$/i, "") }),
|
|
5000
|
+
[note.content, note.name],
|
|
5001
|
+
);
|
|
5002
|
+
const plan = useMemo(() => mindMapModule.layout(tree), [tree]);
|
|
5003
|
+
const [viewState, setViewState] = useState({ x: 0, y: 14, s: 1 });
|
|
5004
|
+
const [selected, setSelected] = useState(null);
|
|
5005
|
+
const dragRef = useRef(null);
|
|
5006
|
+
const wrapRef = useRef(null);
|
|
5007
|
+
const hasNodes = plan.nodes.length > 1;
|
|
5008
|
+
|
|
5009
|
+
// 初始把整张导图缩放进 Dock 可视区(导图比面板宽,不 fit 只能看到最左一列)
|
|
5010
|
+
useEffect(() => {
|
|
5011
|
+
const el = wrapRef.current;
|
|
5012
|
+
if (!el || !hasNodes) return;
|
|
5013
|
+
const w = el.clientWidth || 480;
|
|
5014
|
+
const h = el.clientHeight || 320;
|
|
5015
|
+
// 横向导图比面板宽:只按高度适配(保持字号可读),横向靠拖动平移
|
|
5016
|
+
const fit = Math.min(1, (h - 28) / plan.height);
|
|
5017
|
+
setViewState({
|
|
5018
|
+
x: 10,
|
|
5019
|
+
y: Math.max(10, (h - plan.height * fit) / 2),
|
|
5020
|
+
s: fit,
|
|
5021
|
+
});
|
|
5022
|
+
}, [plan, hasNodes]);
|
|
5023
|
+
|
|
5024
|
+
// SVG 无自动省略:按估算字宽截断,完整文本放 <title> 悬停显示
|
|
5025
|
+
const fitText = (text, width) => {
|
|
5026
|
+
const maxUnits = Math.max(6, Math.floor((width - 26) / 7.2));
|
|
5027
|
+
let out = "";
|
|
5028
|
+
let units = 0;
|
|
5029
|
+
for (const ch of text) {
|
|
5030
|
+
const u = /[\u2E80-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/.test(ch) ? 2 : 1;
|
|
5031
|
+
if (units + u > maxUnits) return `${out}…`;
|
|
5032
|
+
out += ch;
|
|
5033
|
+
units += u;
|
|
5034
|
+
}
|
|
5035
|
+
return out;
|
|
5036
|
+
};
|
|
5037
|
+
|
|
5038
|
+
const onWheel = useCallback((e) => {
|
|
5039
|
+
if (!hasNodes) return;
|
|
5040
|
+
if (e.cancelable !== false && e.preventDefault) e.preventDefault();
|
|
5041
|
+
const rect = e.currentTarget && e.currentTarget.getBoundingClientRect
|
|
5042
|
+
? e.currentTarget.getBoundingClientRect()
|
|
5043
|
+
: { left: 0, top: 0 };
|
|
5044
|
+
const px = (e.clientX || 0) - rect.left;
|
|
5045
|
+
const py = (e.clientY || 0) - rect.top;
|
|
5046
|
+
setViewState((prev) => {
|
|
5047
|
+
const next = Math.min(2.6, Math.max(0.35, prev.s * (e.deltaY > 0 ? 0.88 : 1.14)));
|
|
5048
|
+
const k = next / prev.s;
|
|
5049
|
+
return { s: next, x: px - (px - prev.x) * k, y: py - (py - prev.y) * k };
|
|
5050
|
+
});
|
|
5051
|
+
}, [hasNodes]);
|
|
5052
|
+
|
|
5053
|
+
const onMouseDown = useCallback((e) => {
|
|
5054
|
+
if (e.button !== undefined && e.button !== 0) return;
|
|
5055
|
+
dragRef.current = { x: e.clientX, y: e.clientY, ox: viewState.x, oy: viewState.y };
|
|
5056
|
+
}, [viewState.x, viewState.y]);
|
|
5057
|
+
const onMouseMove = useCallback((e) => {
|
|
5058
|
+
const drag = dragRef.current;
|
|
5059
|
+
if (!drag) return;
|
|
5060
|
+
setViewState((prev) => ({ ...prev, x: drag.ox + (e.clientX - drag.x), y: drag.oy + (e.clientY - drag.y) }));
|
|
5061
|
+
}, []);
|
|
5062
|
+
const endDrag = useCallback(() => { dragRef.current = null; }, []);
|
|
5063
|
+
|
|
5064
|
+
if (!hasNodes) {
|
|
5065
|
+
return createElement("div", { className: "dsh-obs-map-empty" }, t("mapEmpty"));
|
|
5066
|
+
}
|
|
5067
|
+
|
|
5068
|
+
const selectedNode = selected !== null ? plan.byId[selected] : null;
|
|
5069
|
+
return createElement("div", {
|
|
5070
|
+
ref: wrapRef,
|
|
5071
|
+
className: "dsh-obs-map",
|
|
5072
|
+
onWheel,
|
|
5073
|
+
onMouseDown,
|
|
5074
|
+
onMouseMove,
|
|
5075
|
+
onMouseUp: endDrag,
|
|
5076
|
+
onMouseLeave: endDrag,
|
|
5077
|
+
},
|
|
5078
|
+
createElement("svg", { className: "dsh-obs-map-svg" },
|
|
5079
|
+
createElement("g", { transform: `translate(${viewState.x},${viewState.y}) scale(${viewState.s})` },
|
|
5080
|
+
plan.edges.map((edge) => {
|
|
5081
|
+
const a = plan.byId[edge.from];
|
|
5082
|
+
const b = plan.byId[edge.to];
|
|
5083
|
+
if (!a || !b) return null;
|
|
5084
|
+
const x1 = a.x + a.w;
|
|
5085
|
+
const x2 = b.x;
|
|
5086
|
+
const mx = (x1 + x2) / 2;
|
|
5087
|
+
return createElement("path", {
|
|
5088
|
+
key: `${edge.from}-${edge.to}`,
|
|
5089
|
+
className: "dsh-obs-map-edge",
|
|
5090
|
+
d: `M ${x1} ${a.y} C ${mx} ${a.y}, ${mx} ${b.y}, ${x2} ${b.y}`,
|
|
5091
|
+
});
|
|
5092
|
+
}),
|
|
5093
|
+
plan.nodes.map((node) => createElement("g", {
|
|
5094
|
+
key: node.id,
|
|
5095
|
+
className: `dsh-obs-map-node dsh-obs-map-${node.kind}${selected === node.id ? " dsh-obs-map-sel" : ""}`,
|
|
5096
|
+
transform: `translate(${node.x},${node.y - node.h / 2})`,
|
|
5097
|
+
onClick: (e) => {
|
|
5098
|
+
if (e && e.stopPropagation) e.stopPropagation();
|
|
5099
|
+
setSelected(node.id);
|
|
5100
|
+
},
|
|
5101
|
+
onDoubleClick: (e) => {
|
|
5102
|
+
if (e && e.stopPropagation) e.stopPropagation();
|
|
5103
|
+
if (node.line > 0) onJumpLine(node.line);
|
|
5104
|
+
},
|
|
5105
|
+
},
|
|
5106
|
+
createElement("rect", { width: node.w, height: node.h, rx: 9 }),
|
|
5107
|
+
createElement("text", { x: 13, y: node.h / 2 + 5 }, fitText(node.text, node.w)),
|
|
5108
|
+
createElement("title", null, node.text),
|
|
5109
|
+
)),
|
|
5110
|
+
),
|
|
5111
|
+
),
|
|
5112
|
+
selectedNode !== null && createElement("div", { className: "dsh-obs-map-actions" },
|
|
5113
|
+
createElement("span", { className: "dsh-obs-map-line" }, `L${selectedNode.line}`),
|
|
5114
|
+
createElement("button", {
|
|
5115
|
+
onClick: () => onJumpLine(selectedNode.line),
|
|
5116
|
+
}, `↩ ${t("mapJump")}`),
|
|
5117
|
+
createElement("button", {
|
|
5118
|
+
className: "dsh-obs-primary",
|
|
5119
|
+
onClick: () => onAskBranch(selectedNode),
|
|
5120
|
+
}, `⤴ ${t("mapAsk")}`),
|
|
5121
|
+
),
|
|
5122
|
+
createElement("div", { className: "dsh-obs-map-hint" }, t("mapHint")),
|
|
5123
|
+
);
|
|
5124
|
+
}
|
|
5125
|
+
|
|
3684
5126
|
function Dock({ t, workspaces }) {
|
|
3685
5127
|
const { openNote, vaultRev } = useShared();
|
|
3686
5128
|
const [note, setNote] = useState(null);
|
|
3687
5129
|
const [root, setRoot] = useState("");
|
|
3688
5130
|
const [editing, setEditing] = useState(false);
|
|
5131
|
+
// 视图:预览(行号)/ 思维导图;编辑态始终是编辑器
|
|
5132
|
+
const [view, setView] = useState("preview");
|
|
5133
|
+
const [jumpLine, setJumpLine] = useState(null);
|
|
3689
5134
|
const [draft, setDraft] = useState("");
|
|
3690
5135
|
const [saving, setSaving] = useState(false);
|
|
3691
5136
|
const [error, setError] = useState("");
|
|
@@ -3982,6 +5427,60 @@ window.__ModuleLoader__.load({
|
|
|
3982
5427
|
patchShared({ openNote: { rel: found.path, name: found.name } });
|
|
3983
5428
|
}, [editing, draft, note, t]);
|
|
3984
5429
|
|
|
5430
|
+
/** 思维导图 → 预览:跳到源码行并短暂高亮(行元素带 data-line)。 */
|
|
5431
|
+
const jumpToLine = useCallback((line) => {
|
|
5432
|
+
if (!line || line < 1) return;
|
|
5433
|
+
setView("preview");
|
|
5434
|
+
setJumpLine(line);
|
|
5435
|
+
}, []);
|
|
5436
|
+
|
|
5437
|
+
/** 思维导图 → 对话:把「该节点 + 全部后代」的源码区间按选区提问同款 prompt 插入输入框。 */
|
|
5438
|
+
const askBranch = useCallback((node) => {
|
|
5439
|
+
if (!note) return;
|
|
5440
|
+
const span = mindMapModule.branchSpan(note.content, node);
|
|
5441
|
+
const prompt = buildPrompt(
|
|
5442
|
+
t,
|
|
5443
|
+
treeApi.absPath(note.path, root),
|
|
5444
|
+
note.content,
|
|
5445
|
+
{ text: span.text, start: span.start, end: span.end, mode: "preview" },
|
|
5446
|
+
"",
|
|
5447
|
+
);
|
|
5448
|
+
if (injectPrompt(prompt)) {
|
|
5449
|
+
showToast(t("branchCited", { n: span.endLine - span.startLine + 1 }));
|
|
5450
|
+
return;
|
|
5451
|
+
}
|
|
5452
|
+
try {
|
|
5453
|
+
if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
|
5454
|
+
navigator.clipboard.writeText(prompt).then(() => showToast(t("noInput"))).catch(() => showToast(t("citeFail")));
|
|
5455
|
+
} else {
|
|
5456
|
+
showToast(t("citeFail"));
|
|
5457
|
+
}
|
|
5458
|
+
} catch {
|
|
5459
|
+
showToast(t("citeFail"));
|
|
5460
|
+
}
|
|
5461
|
+
}, [note, t, root]);
|
|
5462
|
+
|
|
5463
|
+
// 切笔记时回到预览态(避免上一份笔记的导图/跳转状态残留)
|
|
5464
|
+
useEffect(() => {
|
|
5465
|
+
setView("preview");
|
|
5466
|
+
setJumpLine(null);
|
|
5467
|
+
}, [openNote]);
|
|
5468
|
+
|
|
5469
|
+
// 预览行高亮:跳到行号后滚动到该行并闪一下(1.8s 后恢复)
|
|
5470
|
+
useEffect(() => {
|
|
5471
|
+
if (jumpLine === null || view !== "preview" || !previewRef.current) return undefined;
|
|
5472
|
+
const el = previewRef.current.querySelector(`[data-line="${jumpLine}"]`);
|
|
5473
|
+
if (el) {
|
|
5474
|
+
if (typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center" });
|
|
5475
|
+
el.classList.add("dsh-obs-md-flash");
|
|
5476
|
+
}
|
|
5477
|
+
const timer = window.setTimeout(() => {
|
|
5478
|
+
if (el) el.classList.remove("dsh-obs-md-flash");
|
|
5479
|
+
setJumpLine(null);
|
|
5480
|
+
}, 1800);
|
|
5481
|
+
return () => window.clearTimeout(timer);
|
|
5482
|
+
}, [jumpLine, view, note, vaultRev]);
|
|
5483
|
+
|
|
3985
5484
|
const dirty = editing && note !== null && draft !== note.content;
|
|
3986
5485
|
|
|
3987
5486
|
// 编辑态行号(每行一个,对应源码行数;textarea 为 white-space:pre 无折行,行号与文本行一一对齐)
|
|
@@ -4005,6 +5504,11 @@ window.__ModuleLoader__.load({
|
|
|
4005
5504
|
createElement("span", null, "📝"),
|
|
4006
5505
|
createElement("span", null, note ? note.name : openNote.name),
|
|
4007
5506
|
),
|
|
5507
|
+
!editing && note && createElement("button", {
|
|
5508
|
+
className: view === "map" ? "dsh-obs-on" : undefined,
|
|
5509
|
+
title: view === "map" ? t("preview") : t("mindMap"),
|
|
5510
|
+
onClick: () => setView((prev) => (prev === "map" ? "preview" : "map")),
|
|
5511
|
+
}, view === "map" ? `📄 ${t("preview")}` : `🧠 ${t("mindMap")}`),
|
|
4008
5512
|
!editing && note && createElement("button", { onClick: () => void cite() }, `⤴ ${t("cite")}`),
|
|
4009
5513
|
!editing && note && createElement("button", { onClick: () => { setDraft(note.content); setEditing(true); } }, t("edit")),
|
|
4010
5514
|
editing && createElement("button", {
|
|
@@ -4056,8 +5560,17 @@ window.__ModuleLoader__.load({
|
|
|
4056
5560
|
},
|
|
4057
5561
|
}),
|
|
4058
5562
|
)
|
|
4059
|
-
:
|
|
4060
|
-
createElement("div", {
|
|
5563
|
+
: view === "map"
|
|
5564
|
+
? createElement("div", { className: "dsh-obs-dock-body dsh-obs-dock-map" },
|
|
5565
|
+
createElement(MindMapView, {
|
|
5566
|
+
note,
|
|
5567
|
+
t,
|
|
5568
|
+
onJumpLine: jumpToLine,
|
|
5569
|
+
onAskBranch: askBranch,
|
|
5570
|
+
}),
|
|
5571
|
+
)
|
|
5572
|
+
: createElement("div", { className: "dsh-obs-dock-body" },
|
|
5573
|
+
createElement("div", { ref: previewRef, className: "dsh-obs-md" },
|
|
4061
5574
|
renderNumbered(note.content, {
|
|
4062
5575
|
onWikilink: (target) => void openWikilink(target),
|
|
4063
5576
|
onText: () => {},
|
|
@@ -4346,10 +5859,15 @@ window.__ModuleLoader__.load({
|
|
|
4346
5859
|
{ name: "shell.overlay", id: "obsidian-prompt", order: 6, label: "Obsidian text prompt", locale: NS },
|
|
4347
5860
|
Prompt,
|
|
4348
5861
|
);
|
|
5862
|
+
const d4 = ctx.slots.register(
|
|
5863
|
+
{ name: "shell.overlay", id: "obsidian-graph", order: 32, label: "Obsidian relation graph", locale: NS },
|
|
5864
|
+
GraphOverlay,
|
|
5865
|
+
);
|
|
4349
5866
|
return () => {
|
|
4350
5867
|
d1();
|
|
4351
5868
|
d2();
|
|
4352
5869
|
d3();
|
|
5870
|
+
d4();
|
|
4353
5871
|
};
|
|
4354
5872
|
});
|
|
4355
5873
|
}
|