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/README.md +70 -49
- package/README.zh.md +30 -28
- package/lib/client.js +1649 -78
- package/package.json +1 -1
- package/src/client.js +990 -64
- 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/src/client.js
CHANGED
|
@@ -112,6 +112,44 @@
|
|
|
112
112
|
cancel: "取消",
|
|
113
113
|
discard: "放弃未保存的修改?",
|
|
114
114
|
cite: "引用到对话",
|
|
115
|
+
mindMap: "思维导图",
|
|
116
|
+
preview: "预览",
|
|
117
|
+
mapEmpty: "这篇笔记还没有标题或列表,画不出思维导图",
|
|
118
|
+
mapHint: "滚轮缩放 · 拖动平移 · 单击节点选中 · 双击跳到源码行",
|
|
119
|
+
mapJump: "跳到源码行",
|
|
120
|
+
mapAsk: "把分支插入对话",
|
|
121
|
+
branchCited: "已把分支插入对话({n} 行)",
|
|
122
|
+
graph: "关系图谱",
|
|
123
|
+
graphHint: "滚轮缩放 · 拖动平移 · 点击笔记打开 · 悬停看邻居",
|
|
124
|
+
graphGlobal: "全库",
|
|
125
|
+
graphLocal: "局部",
|
|
126
|
+
graphLocalHint: "以当前打开的笔记为中心显示 2 跳邻居",
|
|
127
|
+
graphRefresh: "重新读取 vault",
|
|
128
|
+
graphRerun: "重新布局",
|
|
129
|
+
graphClose: "关闭图谱",
|
|
130
|
+
graphLoading: "正在构建关系图谱…",
|
|
131
|
+
graphEmpty: "vault 里还没有可显示的笔记或双链",
|
|
132
|
+
graphError: "关系图谱加载失败:{msg}",
|
|
133
|
+
graphStats: "{notes} 篇笔记 · {tags} 个标签 · {edges} 条连线",
|
|
134
|
+
graphTruncated: "笔记较多,图谱只画了最重要的 {n} 个节点",
|
|
135
|
+
graphNoNote: "先在侧边栏打开一篇笔记,才能看局部图谱",
|
|
136
|
+
graphOpen: "打开关系图谱",
|
|
137
|
+
graphMove: "拖动移动面板",
|
|
138
|
+
graphNeedsRestart: "关系图谱需要重启 DSH 后启用",
|
|
139
|
+
graphRestartHint: "当前 DSH 进程里还是旧版 host(缺少 /obsidian/graph 路由)。完全退出 DSH(Cmd+Q)再打开,然后点「重试」。",
|
|
140
|
+
graphErrorTitle: "关系图谱加载失败",
|
|
141
|
+
graphBuilding: "正在从笔记构建图谱({done}/{total})…",
|
|
142
|
+
graphFocus: "聚焦",
|
|
143
|
+
graphFocusHint: "一键切到以当前打开笔记为中心的局部图谱(局部模式下再点 = 重新居中)",
|
|
144
|
+
graphLegendNote: "笔记",
|
|
145
|
+
graphLegendTag: "标签",
|
|
146
|
+
graphLegendFocus: "当前笔记",
|
|
147
|
+
graphLegendLink: "链接",
|
|
148
|
+
graphDepth: "跳数",
|
|
149
|
+
graphDepthHint: "局部图谱跳数,1–5 跳(与 Obsidian 局部图谱的 depth 范围一致)",
|
|
150
|
+
graphRetry: "重试",
|
|
151
|
+
graphDetails: "详细信息",
|
|
152
|
+
graphDetailsHide: "收起详细信息",
|
|
115
153
|
cited: "已引用到对话",
|
|
116
154
|
copied: "已复制引用,可粘贴到输入框",
|
|
117
155
|
noInput: "未找到对话输入框,引用已复制到剪贴板",
|
|
@@ -140,11 +178,10 @@
|
|
|
140
178
|
wikilinkNotFound: "未找到笔记",
|
|
141
179
|
error: "出错了",
|
|
142
180
|
imageInserted: "已插入图片",
|
|
143
|
-
attachDir: "
|
|
181
|
+
attachDir: "附件文件夹",
|
|
144
182
|
attachDirPick: "选择附件目录(Vault 内)",
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
attachDirPlaceholder: "Vault 相对路径,如 docs/images",
|
|
183
|
+
attachDirPlaceholder: "Vault 相对路径,如 attachments",
|
|
184
|
+
attachDirHint: "留空 = Vault 根目录,回车保存",
|
|
148
185
|
imageRemoved: "已同步删除未引用的图片",
|
|
149
186
|
attachDirConfirm: "更改截图落盘目录:{from} → {to}。会**自动把原目录下的截图移动到新目录**、删除原目录(若空),并同步更新笔记里的引用路径。确认更改?",
|
|
150
187
|
attachSettings: "附件设置(位置 / 显示 / 清理)",
|
|
@@ -153,7 +190,9 @@
|
|
|
153
190
|
attachMode_fixed: "固定目录",
|
|
154
191
|
attachMode_note: "与笔记同目录",
|
|
155
192
|
attachMode_sub: "笔记目录下子目录",
|
|
156
|
-
attachSubfolder: "
|
|
193
|
+
attachSubfolder: "子目录名称",
|
|
194
|
+
attachSubfolderPlaceholder: "如 attachments",
|
|
195
|
+
attachSubHint: "回车保存",
|
|
157
196
|
attachHideDir: "隐藏图片目录(只含图片、没有笔记的目录)",
|
|
158
197
|
attachDeleteOrphans: "保存笔记时自动删除无用附件(默认关)",
|
|
159
198
|
migrated: "迁移 {n} 张截图,更新 {m} 篇笔记",
|
|
@@ -249,6 +288,44 @@
|
|
|
249
288
|
cancel: "Cancel",
|
|
250
289
|
discard: "Discard unsaved changes?",
|
|
251
290
|
cite: "Cite to chat",
|
|
291
|
+
mindMap: "Mind map",
|
|
292
|
+
preview: "Preview",
|
|
293
|
+
mapEmpty: "No headings or list items yet — nothing to draw",
|
|
294
|
+
mapHint: "Scroll to zoom · drag to pan · click a node to select · double-click to jump to its line",
|
|
295
|
+
mapJump: "Jump to line",
|
|
296
|
+
mapAsk: "Insert branch into chat",
|
|
297
|
+
branchCited: "Branch inserted into the chat ({n} lines)",
|
|
298
|
+
graph: "Graph view",
|
|
299
|
+
graphHint: "Scroll to zoom · drag to pan · click a note to open it · hover to see neighbours",
|
|
300
|
+
graphGlobal: "Vault",
|
|
301
|
+
graphLocal: "Local",
|
|
302
|
+
graphLocalHint: "Show the 2-hop neighbourhood of the note that is open",
|
|
303
|
+
graphRefresh: "Re-read the vault",
|
|
304
|
+
graphRerun: "Re-run the layout",
|
|
305
|
+
graphClose: "Close the graph",
|
|
306
|
+
graphLoading: "Building the graph…",
|
|
307
|
+
graphEmpty: "No notes or links to draw yet",
|
|
308
|
+
graphError: "Graph view failed: {msg}",
|
|
309
|
+
graphStats: "{notes} notes · {tags} tags · {edges} links",
|
|
310
|
+
graphTruncated: "Large vault: only the {n} most connected nodes are drawn",
|
|
311
|
+
graphNoNote: "Open a note in the sidebar first to see the local graph",
|
|
312
|
+
graphOpen: "Open the graph view",
|
|
313
|
+
graphMove: "Drag to move the panel",
|
|
314
|
+
graphNeedsRestart: "The graph view needs a DSH restart",
|
|
315
|
+
graphRestartHint: "The running DSH host still predates the /obsidian/graph route. Quit DSH completely (Cmd+Q), reopen it, then press Retry.",
|
|
316
|
+
graphErrorTitle: "The graph view failed to load",
|
|
317
|
+
graphBuilding: "Building the graph from notes ({done}/{total})…",
|
|
318
|
+
graphFocus: "Focus",
|
|
319
|
+
graphFocusHint: "Jump to the local graph centred on the open note (click again to re-centre)",
|
|
320
|
+
graphLegendNote: "note",
|
|
321
|
+
graphLegendTag: "tag",
|
|
322
|
+
graphLegendFocus: "current note",
|
|
323
|
+
graphLegendLink: "link",
|
|
324
|
+
graphDepth: "Depth",
|
|
325
|
+
graphDepthHint: "Local graph depth, 1–5 hops (same range as Obsidian's local graph depth)",
|
|
326
|
+
graphRetry: "Retry",
|
|
327
|
+
graphDetails: "Details",
|
|
328
|
+
graphDetailsHide: "Hide details",
|
|
252
329
|
cited: "Cited to chat",
|
|
253
330
|
copied: "Reference copied — paste it into the input",
|
|
254
331
|
noInput: "No chat input found — reference copied to clipboard",
|
|
@@ -277,11 +354,10 @@
|
|
|
277
354
|
wikilinkNotFound: "Note not found",
|
|
278
355
|
error: "Error",
|
|
279
356
|
imageInserted: "Image inserted",
|
|
280
|
-
attachDir: "Attachment
|
|
357
|
+
attachDir: "Attachment folder",
|
|
281
358
|
attachDirPick: "Choose attachment dir (inside vault)",
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
attachDirPlaceholder: "Vault-relative path, e.g. docs/images",
|
|
359
|
+
attachDirPlaceholder: "Vault-relative path, e.g. attachments",
|
|
360
|
+
attachDirHint: "Empty = vault root; press Enter to save",
|
|
285
361
|
imageRemoved: "Deleted unreferenced image(s)",
|
|
286
362
|
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?",
|
|
287
363
|
attachSettings: "Attachment settings (location / visibility / cleanup)",
|
|
@@ -290,7 +366,9 @@
|
|
|
290
366
|
attachMode_fixed: "Fixed folder",
|
|
291
367
|
attachMode_note: "Same folder as note",
|
|
292
368
|
attachMode_sub: "Subfolder under note",
|
|
293
|
-
attachSubfolder: "Subfolder name
|
|
369
|
+
attachSubfolder: "Subfolder name",
|
|
370
|
+
attachSubfolderPlaceholder: "e.g. attachments",
|
|
371
|
+
attachSubHint: "Press Enter to save",
|
|
294
372
|
attachHideDir: "Hide image folders (folders without any notes)",
|
|
295
373
|
attachDeleteOrphans: "Auto-delete orphaned attachments on save (off by default)",
|
|
296
374
|
migrated: "moved {n} image(s), updated {m} note(s)",
|
|
@@ -299,6 +377,8 @@
|
|
|
299
377
|
// ── 共享外部 store(跨槽位:侧边栏 / Dock / Toast)──────────────────────────
|
|
300
378
|
const LS_NOTES_OPEN = "dsh-obsidian:notesOpen";
|
|
301
379
|
const LS_DOCK_WIDTH = "dsh-obsidian:dockWidth";
|
|
380
|
+
const LS_GRAPH_GEOMETRY = "dsh-obsidian:graphGeometry";
|
|
381
|
+
const LS_LOCAL_DEPTH = "dsh-obsidian:localDepth";
|
|
302
382
|
|
|
303
383
|
function readNotesOpen() {
|
|
304
384
|
try {
|
|
@@ -308,7 +388,7 @@
|
|
|
308
388
|
}
|
|
309
389
|
}
|
|
310
390
|
|
|
311
|
-
let shared = { notesOpen: readNotesOpen(), openNote: null, vaultRev: 0, toast: null, noteCount: 0, prompt: null };
|
|
391
|
+
let shared = { notesOpen: readNotesOpen(), openNote: null, graphOpen: false, vaultRev: 0, toast: null, noteCount: 0, prompt: null };
|
|
312
392
|
const sharedListeners = new Set();
|
|
313
393
|
|
|
314
394
|
function patchShared(patch) {
|
|
@@ -664,6 +744,7 @@
|
|
|
664
744
|
`.dsh-obs-ws-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;font-weight:500}`,
|
|
665
745
|
`.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}`,
|
|
666
746
|
`.dsh-obs-mini:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}`,
|
|
747
|
+
`.dsh-obs-mini.dsh-obs-on{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-brand-primary)}`,
|
|
667
748
|
// 行尾管理动作(工作区/会话 的 重命名 / 删除,常显)
|
|
668
749
|
`.dsh-obs-actions{display:flex;align-items:center;gap:2px;flex:none}`,
|
|
669
750
|
`.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}`,
|
|
@@ -712,9 +793,21 @@
|
|
|
712
793
|
`.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}`,
|
|
713
794
|
`.dsh-obs-tree-head button:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
714
795
|
`.dsh-obs-tree-head button.dsh-obs-on{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-brand-primary)}`,
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
`.dsh-obs-
|
|
796
|
+
// 附件设置面板:窄侧边栏里的表单卡片。位置模式用下拉框,四个中文标签都能整行放下;
|
|
797
|
+
// 旧版把四个标签塞进 24×24 的图标按钮,汉字被挤成一列,盖住下面的输入框和开关。
|
|
798
|
+
`.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)}`,
|
|
799
|
+
`.dsh-obs-field{display:flex;flex-direction:column;gap:4px;min-width:0}`,
|
|
800
|
+
`.dsh-obs-field-label{font-size:11px;line-height:15px;color:var(--dsw-alias-label-tertiary)}`,
|
|
801
|
+
`.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}`,
|
|
802
|
+
`.dsh-obs-select:focus{border-color:var(--dsw-alias-brand-primary)}`,
|
|
803
|
+
`.dsh-obs-inputrow{display:flex;align-items:center;gap:4px;min-width:0}`,
|
|
804
|
+
`.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}`,
|
|
805
|
+
`.dsh-obs-inputrow input:focus{border-color:var(--dsw-alias-brand-primary)}`,
|
|
806
|
+
`.dsh-obs-inputrow .dsh-obs-mini{width:26px;height:26px;border:1px solid var(--dsw-alias-border-l2)}`,
|
|
807
|
+
`.dsh-obs-hint{font-size:11px;line-height:15px;color:var(--dsw-alias-label-tertiary)}`,
|
|
808
|
+
`.dsh-obs-checks{display:flex;flex-direction:column;gap:6px;padding-top:2px}`,
|
|
809
|
+
`.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}`,
|
|
810
|
+
`.dsh-obs-cfgline input[type=checkbox]{flex:none;margin:2px 0 0;cursor:pointer}`,
|
|
718
811
|
`.dsh-obs-root-row{display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:11px;color:var(--dsw-alias-label-tertiary)}`,
|
|
719
812
|
`.dsh-obs-root-row .dsh-obs-root-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
|
|
720
813
|
`.dsh-obs-root-row .dsh-obs-mini,.dsh-obs-row .dsh-obs-mini{flex:none;width:22px;height:22px}`,
|
|
@@ -750,7 +843,69 @@
|
|
|
750
843
|
`.dsh-obs-md-lc{flex:1;min-width:0}`,
|
|
751
844
|
`.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}`,
|
|
752
845
|
`.dsh-obs-md-row.dsh-obs-md-heading .dsh-obs-md-lc{font-weight:600}`,
|
|
846
|
+
// 行内 Obsidian 语法:==高亮== / ~~删除线~~ / 裸链接与自动链接
|
|
847
|
+
`.dsh-obs-md mark,.dsh-obs-mark{background:rgba(255,208,0,.32);color:inherit;border-radius:2px;padding:0 1px}`,
|
|
848
|
+
`.dsh-obs-md del{opacity:.62;text-decoration:line-through}`,
|
|
849
|
+
`.dsh-obs-md .dsh-obs-autolink{color:var(--dsw-alias-brand-primary);text-decoration:underline;text-underline-offset:2px;word-break:break-all}`,
|
|
753
850
|
`.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)}`,
|
|
851
|
+
// ── 思维导图视图(B1)──
|
|
852
|
+
`.dsh-obs-dock-map{overflow:hidden;padding:0}`,
|
|
853
|
+
`.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}`,
|
|
854
|
+
`.dsh-obs-map-svg{display:block;width:100%;height:100%}`,
|
|
855
|
+
`.dsh-obs-map-edge{fill:none;stroke:var(--dsw-alias-border-l2);stroke-width:1.5}`,
|
|
856
|
+
`.dsh-obs-map-node{cursor:pointer}`,
|
|
857
|
+
`.dsh-obs-map-node rect{fill:var(--dsw-alias-bg-overlay);stroke:var(--dsw-alias-border-l2);stroke-width:1}`,
|
|
858
|
+
`.dsh-obs-map-heading rect{stroke:var(--dsw-alias-brand-primary)}`,
|
|
859
|
+
`.dsh-obs-map-root rect{fill:var(--dsw-alias-brand-primary);stroke:var(--dsw-alias-brand-primary)}`,
|
|
860
|
+
`.dsh-obs-map-root text{fill:#fff;font-weight:600}`,
|
|
861
|
+
`.dsh-obs-map-node text{font-size:12.5px;fill:var(--dsw-alias-label-primary);pointer-events:none}`,
|
|
862
|
+
`.dsh-obs-map-sel rect{stroke:var(--dsw-alias-brand-primary);stroke-width:2}`,
|
|
863
|
+
`.dsh-obs-map-hint{position:absolute;left:10px;bottom:8px;font-size:11px;color:var(--dsw-alias-label-tertiary);pointer-events:none}`,
|
|
864
|
+
`.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}`,
|
|
865
|
+
`.dsh-obs-map-actions .dsh-obs-map-line{color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}`,
|
|
866
|
+
`.dsh-obs-map-actions button{padding:3px 8px;font-size:12px}`,
|
|
867
|
+
`.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}`,
|
|
868
|
+
`.dsh-obs-md-row.dsh-obs-md-flash{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 16%,transparent);border-radius:4px}`,
|
|
869
|
+
// ── 关系图谱(shell.overlay,vault 级独立视图)──
|
|
870
|
+
`.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)}`,
|
|
871
|
+
`.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}`,
|
|
872
|
+
`.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}`,
|
|
873
|
+
`.dsh-obs-graph-error{display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;gap:8px;padding:32px;text-align:center}`,
|
|
874
|
+
`.dsh-obs-graph-error-icon{font-size:26px}`,
|
|
875
|
+
`.dsh-obs-graph-error-title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}`,
|
|
876
|
+
`.dsh-obs-graph-error-text{font-size:12px;line-height:1.7;color:var(--dsw-alias-label-secondary);max-width:560px}`,
|
|
877
|
+
`.dsh-obs-graph-error-actions{display:flex;gap:8px;margin-top:8px}`,
|
|
878
|
+
`.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}`,
|
|
879
|
+
`.dsh-obs-graph-error-actions button:hover{background:var(--dsw-alias-interactive-bg-hover)}`,
|
|
880
|
+
`.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)}`,
|
|
881
|
+
`.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}`,
|
|
882
|
+
`.dsh-obs-graph-title{font-weight:600;font-size:14px}`,
|
|
883
|
+
`.dsh-obs-graph-stats{font-size:11px;color:var(--dsw-alias-label-tertiary)}`,
|
|
884
|
+
`.dsh-obs-graph-modes{display:flex;gap:4px;margin-left:auto}`,
|
|
885
|
+
`.dsh-obs-graph-modes button[disabled]{opacity:.45;cursor:not-allowed}`,
|
|
886
|
+
`.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)}`,
|
|
887
|
+
`.dsh-obs-graph-depth-label{font-size:11px;color:var(--dsw-alias-label-tertiary);margin-right:2px}`,
|
|
888
|
+
`.dsh-obs-graph-depth button{min-width:20px;padding:0 4px}`,
|
|
889
|
+
`.dsh-obs-graph-head button{white-space:nowrap;flex:none}`,
|
|
890
|
+
`.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}`,
|
|
891
|
+
`.dsh-obs-legend-tag{width:8px;height:8px;border-radius:2px;background:var(--dsw-alias-brand-primary);display:inline-block}`,
|
|
892
|
+
`.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}`,
|
|
893
|
+
`.dsh-obs-legend-link{width:14px;height:1.5px;background:var(--dsw-alias-border-l2);display:inline-block}`,
|
|
894
|
+
`.dsh-obs-graph-canvas{position:relative;flex:1;min-height:0;overflow:hidden;cursor:grab;user-select:none;-webkit-user-select:none}`,
|
|
895
|
+
`.dsh-obs-graph-svg{display:block;width:100%;height:100%}`,
|
|
896
|
+
`.dsh-obs-graph-edge{stroke:var(--dsw-alias-border-l2);stroke-width:1;opacity:.55}`,
|
|
897
|
+
`.dsh-obs-graph-edge-on{stroke:var(--dsw-alias-brand-primary);opacity:.9;stroke-width:1.6}`,
|
|
898
|
+
`.dsh-obs-graph-node{cursor:pointer}`,
|
|
899
|
+
`.dsh-obs-graph-note circle{fill:var(--dsw-alias-bg-overlay);stroke:var(--dsw-alias-label-secondary);stroke-width:1.4}`,
|
|
900
|
+
`.dsh-obs-graph-tag rect{fill:var(--dsw-alias-brand-primary);opacity:.75}`,
|
|
901
|
+
`.dsh-obs-graph-sel circle,.dsh-obs-graph-sel rect{stroke:var(--dsw-alias-brand-primary);stroke-width:2.2}`,
|
|
902
|
+
`.dsh-obs-graph-focus circle{stroke:var(--dsw-alias-state-error-primary);stroke-width:2.4}`,
|
|
903
|
+
`.dsh-obs-graph-dim{opacity:.25}`,
|
|
904
|
+
`.dsh-obs-graph-node text{font-size:11px;fill:var(--dsw-alias-label-primary);pointer-events:none}`,
|
|
905
|
+
`.dsh-obs-graph-tag text{fill:var(--dsw-alias-label-secondary)}`,
|
|
906
|
+
`.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)}`,
|
|
907
|
+
`.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}`,
|
|
908
|
+
`.dsh-obs-graph-error, .dsh-obs-graph-empty{flex:1;min-height:0}`,
|
|
754
909
|
// ── Obsidian callout(`> [!type] 标题`)配色:类型在根节点上带 data-callout ──
|
|
755
910
|
`[data-callout="note"]{--dsh-obs-callout-color:#448aff}`,
|
|
756
911
|
`[data-callout="abstract"]{--dsh-obs-callout-color:#00b8d4}`,
|
|
@@ -916,7 +1071,14 @@
|
|
|
916
1071
|
|
|
917
1072
|
function injectCss() {
|
|
918
1073
|
if (typeof document === "undefined") return;
|
|
919
|
-
|
|
1074
|
+
// 已注入过就只更新内容:插件被 HMR 重新装载时 apply() 会再跑一遍,旧实现直接
|
|
1075
|
+
// return,页面会继续用上一次的 CSS —— 新加的类名没有样式,界面半新半旧,只能
|
|
1076
|
+
// 整页刷新才正常。
|
|
1077
|
+
const existing = document.querySelector('style[data-plugin-css="dsh-obsidian-vault/core.css"]');
|
|
1078
|
+
if (existing !== null) {
|
|
1079
|
+
if (existing.textContent !== CSS) existing.textContent = CSS;
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
920
1082
|
const tag = document.createElement("style");
|
|
921
1083
|
tag.dataset.plugin = "dsh-obsidian-vault";
|
|
922
1084
|
tag.dataset.pluginCss = "dsh-obsidian-vault/core.css";
|
|
@@ -1070,6 +1232,7 @@
|
|
|
1070
1232
|
// `![[附件/a.png]]` 会被当成 Vault 根下的 `附件/a.png` → 403 → 只剩断图。
|
|
1071
1233
|
assetUrl: (rel, from) => `/obsidian/file?path=${encodeURIComponent(rel)}`
|
|
1072
1234
|
+ (from ? `&from=${encodeURIComponent(from)}` : ""),
|
|
1235
|
+
graph: () => api("/graph"),
|
|
1073
1236
|
note: (rel) => api(`/note?path=${encodeURIComponent(rel)}`),
|
|
1074
1237
|
save: (rel, content) => api("/note", {
|
|
1075
1238
|
method: "POST",
|
|
@@ -1169,6 +1332,7 @@
|
|
|
1169
1332
|
const [attachDir, setAttachDirState] = useState(null);
|
|
1170
1333
|
const [attachOpen, setAttachOpen] = useState(false);
|
|
1171
1334
|
const [attachInput, setAttachInput] = useState("");
|
|
1335
|
+
const [attachSubInput, setAttachSubInput] = useState("");
|
|
1172
1336
|
// 附件位置模式 / 子目录 / 两个开关(对齐 Obsidian 的附件位置设置)
|
|
1173
1337
|
const [attachCfg, setAttachCfg] = useState(null);
|
|
1174
1338
|
|
|
@@ -1218,6 +1382,14 @@
|
|
|
1218
1382
|
void loadRoot();
|
|
1219
1383
|
}, [loadRoot, vaultRev]);
|
|
1220
1384
|
|
|
1385
|
+
// 面板每次打开都把输入框填成当前设置:旧版只有铅笔按钮会带值,点齿轮打开时
|
|
1386
|
+
// 输入框是空的,看起来像「设置丢了」。
|
|
1387
|
+
useEffect(() => {
|
|
1388
|
+
if (!attachOpen) return;
|
|
1389
|
+
setAttachInput(typeof attachDir === "string" ? attachDir : "");
|
|
1390
|
+
setAttachSubInput(attachCfg !== null ? attachCfg.subfolder : "attachments");
|
|
1391
|
+
}, [attachOpen]);
|
|
1392
|
+
|
|
1221
1393
|
const toggleDir = useCallback(async (rel) => {
|
|
1222
1394
|
const willOpen = !expanded.has(rel);
|
|
1223
1395
|
setExpanded((prev) => {
|
|
@@ -1511,63 +1683,93 @@
|
|
|
1511
1683
|
onClick: (e) => { e.stopPropagation(); void newNote(""); },
|
|
1512
1684
|
}, "+"),
|
|
1513
1685
|
),
|
|
1514
|
-
rootInfo && rootInfo.exists &&
|
|
1686
|
+
rootInfo && rootInfo.exists && createElement("div", { className: "dsh-obs-root-row", title: t("graphOpen") },
|
|
1687
|
+
createElement("span", { className: "dsh-obs-root-path" }, `🕸 ${t("graph")}`),
|
|
1688
|
+
createElement("button", {
|
|
1689
|
+
className: "dsh-obs-mini",
|
|
1690
|
+
title: t("graphOpen"),
|
|
1691
|
+
onClick: (e) => { e.stopPropagation(); patchShared({ graphOpen: true }); },
|
|
1692
|
+
}, "↗"),
|
|
1693
|
+
),
|
|
1694
|
+
rootInfo && rootInfo.exists && attachDir !== null && createElement("div", {
|
|
1695
|
+
className: "dsh-obs-root-row",
|
|
1696
|
+
title: `${t("attachLocation")}: ${attachLocationLabel(attachCfg, attachDir, t)}`,
|
|
1697
|
+
},
|
|
1515
1698
|
createElement("span", { className: "dsh-obs-root-path" },
|
|
1516
1699
|
`📎 ${t("attachLocation")}: ${attachLocationLabel(attachCfg, attachDir, t)}`),
|
|
1517
|
-
attachCfg !== null && attachCfg.mode === "fixed" && createElement("button", {
|
|
1518
|
-
className: "dsh-obs-mini",
|
|
1519
|
-
title: t("attachDirPick"),
|
|
1520
|
-
onClick: (e) => { e.stopPropagation(); void pickAttachDir(); },
|
|
1521
|
-
}, "📂"),
|
|
1522
|
-
attachCfg !== null && attachCfg.mode === "fixed" && createElement("button", {
|
|
1523
|
-
className: "dsh-obs-mini",
|
|
1524
|
-
title: t("attachDirEdit"),
|
|
1525
|
-
onClick: (e) => { e.stopPropagation(); setAttachInput(attachDir); setAttachOpen((v) => !v); },
|
|
1526
|
-
}, "✎"),
|
|
1527
1700
|
createElement("button", {
|
|
1528
|
-
className:
|
|
1701
|
+
className: `dsh-obs-mini${attachOpen ? " dsh-obs-on" : ""}`,
|
|
1529
1702
|
title: t("attachSettings"),
|
|
1703
|
+
"aria-expanded": attachOpen === true,
|
|
1530
1704
|
onClick: (e) => { e.stopPropagation(); setAttachOpen((v) => !v); },
|
|
1531
1705
|
}, "⚙"),
|
|
1532
1706
|
),
|
|
1533
|
-
//
|
|
1707
|
+
// 附件设置面板:窄侧边栏里当表单排(下拉框 + 输入行 + 开关),长标签整行放得下
|
|
1534
1708
|
attachOpen && rootInfo && rootInfo.exists && attachCfg !== null && createElement("div", { className: "dsh-obs-attachcfg" },
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
attachCfg.mode === "fixed" && createElement("input", {
|
|
1542
|
-
autoFocus: true,
|
|
1543
|
-
placeholder: t("attachDirPlaceholder"),
|
|
1544
|
-
value: attachInput,
|
|
1545
|
-
onChange: (e) => setAttachInput(e.target.value),
|
|
1546
|
-
onKeyDown: (e) => {
|
|
1547
|
-
if (e.key === "Enter") void applyAttachDir(attachInput);
|
|
1548
|
-
if (e.key === "Escape") setAttachOpen(false);
|
|
1709
|
+
createElement("label", { className: "dsh-obs-field" },
|
|
1710
|
+
createElement("span", { className: "dsh-obs-field-label" }, t("attachLocation")),
|
|
1711
|
+
createElement("select", {
|
|
1712
|
+
className: "dsh-obs-select",
|
|
1713
|
+
value: attachCfg.mode,
|
|
1714
|
+
onChange: (e) => { void applyAttachSettings({ mode: e.target.value }); },
|
|
1549
1715
|
},
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
placeholder: t("attachSubfolder"),
|
|
1553
|
-
defaultValue: attachCfg.subfolder,
|
|
1554
|
-
onKeyDown: (e) => { if (e.key === "Enter") void applyAttachSettings({ subfolder: e.target.value }); },
|
|
1555
|
-
}),
|
|
1556
|
-
createElement("label", { className: "dsh-obs-cfgline" },
|
|
1557
|
-
createElement("input", {
|
|
1558
|
-
type: "checkbox",
|
|
1559
|
-
checked: attachCfg.hideDir,
|
|
1560
|
-
onChange: (e) => { e.stopPropagation(); void applyAttachSettings({ hideAttachmentDir: e.target.checked === true }); },
|
|
1561
|
-
}),
|
|
1562
|
-
t("attachHideDir"),
|
|
1716
|
+
["root", "fixed", "note", "sub"].map((m) => createElement("option", { key: m, value: m }, t(`attachMode_${m}`))),
|
|
1717
|
+
),
|
|
1563
1718
|
),
|
|
1564
|
-
createElement("
|
|
1565
|
-
createElement("
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1719
|
+
attachCfg.mode === "fixed" && createElement("div", { className: "dsh-obs-field" },
|
|
1720
|
+
createElement("span", { className: "dsh-obs-field-label" }, t("attachDir")),
|
|
1721
|
+
createElement("div", { className: "dsh-obs-inputrow" },
|
|
1722
|
+
createElement("input", {
|
|
1723
|
+
"aria-label": t("attachDir"),
|
|
1724
|
+
placeholder: t("attachDirPlaceholder"),
|
|
1725
|
+
value: attachInput,
|
|
1726
|
+
onChange: (e) => setAttachInput(e.target.value),
|
|
1727
|
+
onKeyDown: (e) => {
|
|
1728
|
+
if (e.key === "Enter") void applyAttachDir(attachInput);
|
|
1729
|
+
if (e.key === "Escape") setAttachOpen(false);
|
|
1730
|
+
},
|
|
1731
|
+
}),
|
|
1732
|
+
createElement("button", {
|
|
1733
|
+
className: "dsh-obs-mini",
|
|
1734
|
+
title: t("attachDirPick"),
|
|
1735
|
+
onClick: (e) => { e.stopPropagation(); void pickAttachDir(); },
|
|
1736
|
+
}, "📂"),
|
|
1737
|
+
),
|
|
1738
|
+
createElement("span", { className: "dsh-obs-hint" }, t("attachDirHint")),
|
|
1739
|
+
),
|
|
1740
|
+
attachCfg.mode === "sub" && createElement("div", { className: "dsh-obs-field" },
|
|
1741
|
+
createElement("span", { className: "dsh-obs-field-label" }, t("attachSubfolder")),
|
|
1742
|
+
createElement("div", { className: "dsh-obs-inputrow" },
|
|
1743
|
+
createElement("input", {
|
|
1744
|
+
"aria-label": t("attachSubfolder"),
|
|
1745
|
+
placeholder: t("attachSubfolderPlaceholder"),
|
|
1746
|
+
value: attachSubInput,
|
|
1747
|
+
onChange: (e) => setAttachSubInput(e.target.value),
|
|
1748
|
+
onKeyDown: (e) => {
|
|
1749
|
+
if (e.key === "Enter") void applyAttachSettings({ subfolder: attachSubInput });
|
|
1750
|
+
if (e.key === "Escape") setAttachOpen(false);
|
|
1751
|
+
},
|
|
1752
|
+
}),
|
|
1753
|
+
),
|
|
1754
|
+
createElement("span", { className: "dsh-obs-hint" }, t("attachSubHint")),
|
|
1755
|
+
),
|
|
1756
|
+
createElement("div", { className: "dsh-obs-checks" },
|
|
1757
|
+
createElement("label", { className: "dsh-obs-cfgline" },
|
|
1758
|
+
createElement("input", {
|
|
1759
|
+
type: "checkbox",
|
|
1760
|
+
checked: attachCfg.hideDir,
|
|
1761
|
+
onChange: (e) => { e.stopPropagation(); void applyAttachSettings({ hideAttachmentDir: e.target.checked === true }); },
|
|
1762
|
+
}),
|
|
1763
|
+
createElement("span", null, t("attachHideDir")),
|
|
1764
|
+
),
|
|
1765
|
+
createElement("label", { className: "dsh-obs-cfgline" },
|
|
1766
|
+
createElement("input", {
|
|
1767
|
+
type: "checkbox",
|
|
1768
|
+
checked: attachCfg.deleteOrphans,
|
|
1769
|
+
onChange: (e) => { e.stopPropagation(); void applyAttachSettings({ deleteOrphanAttachments: e.target.checked === true }); },
|
|
1770
|
+
}),
|
|
1771
|
+
createElement("span", null, t("attachDeleteOrphans")),
|
|
1772
|
+
),
|
|
1571
1773
|
),
|
|
1572
1774
|
),
|
|
1573
1775
|
body,
|
|
@@ -2583,12 +2785,663 @@
|
|
|
2583
2785
|
);
|
|
2584
2786
|
}
|
|
2585
2787
|
|
|
2788
|
+
/**
|
|
2789
|
+
* 关系图谱视图:全库 .md + [[双链]] + 标签(host `/obsidian/graph` 计算,
|
|
2790
|
+
* 力导向布局在 src/graph.js)。可切到「局部」= 当前打开笔记的 2 跳邻居。
|
|
2791
|
+
*/
|
|
2792
|
+
function GraphView({ t, currentNote, onOpenNote, onClose }) {
|
|
2793
|
+
const [data, setData] = useState(null);
|
|
2794
|
+
const [error, setError] = useState("");
|
|
2795
|
+
const [loading, setLoading] = useState(true);
|
|
2796
|
+
const [mode, setMode] = useState("global");
|
|
2797
|
+
// 局部图谱跳数:与 Obsidian 局部图谱的 depth 滑杆对齐(范围 1–5,默认 1)
|
|
2798
|
+
const [fitKey, setFitKey] = useState(0);
|
|
2799
|
+
const [localDepth, setLocalDepth] = useState(() => {
|
|
2800
|
+
try {
|
|
2801
|
+
const v = parseInt(localStorage.getItem(LS_LOCAL_DEPTH) ?? "", 10);
|
|
2802
|
+
if (Number.isFinite(v) && v >= 1 && v <= 5) return v;
|
|
2803
|
+
} catch { /* ignore */ }
|
|
2804
|
+
return 1;
|
|
2805
|
+
});
|
|
2806
|
+
const [seed, setSeed] = useState(1);
|
|
2807
|
+
const [hovered, setHovered] = useState(null);
|
|
2808
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
2809
|
+
const [transform, setTransform] = useState({ x: 0, y: 0, s: 1 });
|
|
2810
|
+
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
2811
|
+
const [buildProgress, setBuildProgress] = useState(null);
|
|
2812
|
+
const dragRef = useRef(null);
|
|
2813
|
+
const wrapRef = useRef(null);
|
|
2814
|
+
const panelDragRef = useRef(null);
|
|
2815
|
+
const resizeRef = useRef(null);
|
|
2816
|
+
// 面板几何:可拖动、可缩放、可关闭(和笔记 Dock 同一套交互),位置/尺寸持久化
|
|
2817
|
+
const [geometry, setGeometry] = useState(() => {
|
|
2818
|
+
const fallback = { x: null, y: 64, width: 860, height: 600 };
|
|
2819
|
+
try {
|
|
2820
|
+
const raw = localStorage.getItem(LS_GRAPH_GEOMETRY);
|
|
2821
|
+
if (raw !== null) {
|
|
2822
|
+
const parsed = JSON.parse(raw);
|
|
2823
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
2824
|
+
return {
|
|
2825
|
+
x: typeof parsed.x === "number" ? parsed.x : null,
|
|
2826
|
+
y: typeof parsed.y === "number" ? parsed.y : 64,
|
|
2827
|
+
width: Number.isFinite(parsed.width) ? Math.min(Math.max(560, parsed.width), 1600) : 860,
|
|
2828
|
+
height: Number.isFinite(parsed.height) ? Math.min(Math.max(360, parsed.height), 1200) : 600,
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
} catch { /* 忽略坏掉的本地存储 */ }
|
|
2833
|
+
return fallback;
|
|
2834
|
+
});
|
|
2835
|
+
|
|
2836
|
+
useEffect(() => {
|
|
2837
|
+
let cancelled = false;
|
|
2838
|
+
setLoading(true);
|
|
2839
|
+
setError("");
|
|
2840
|
+
setBuildProgress(null);
|
|
2841
|
+
// 旧 host 没有 /obsidian/graph 路由时的兜底:用已有的 tree/note 接口在浏览器里
|
|
2842
|
+
// 自建同一张图(插件升级后页面刷新即可用,不要求用户重启 DSH)。
|
|
2843
|
+
const buildFromClient = async () => {
|
|
2844
|
+
const dirs = [""];
|
|
2845
|
+
const notes = [];
|
|
2846
|
+
while (dirs.length > 0 && notes.length < 1200) {
|
|
2847
|
+
const rel = dirs.shift();
|
|
2848
|
+
let res;
|
|
2849
|
+
try {
|
|
2850
|
+
res = await treeApi.tree(rel);
|
|
2851
|
+
} catch {
|
|
2852
|
+
continue;
|
|
2853
|
+
}
|
|
2854
|
+
for (const entry of res.entries ?? []) {
|
|
2855
|
+
const path = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
2856
|
+
if (entry.type === "dir") dirs.push(path);
|
|
2857
|
+
else if (entry.type === "note") notes.push({ path });
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
const collected = new Array(notes.length).fill(null);
|
|
2861
|
+
let done = 0;
|
|
2862
|
+
let cursor = 0;
|
|
2863
|
+
setBuildProgress({ done, total: notes.length });
|
|
2864
|
+
const worker = async () => {
|
|
2865
|
+
while (cursor < notes.length && !cancelled) {
|
|
2866
|
+
const index = cursor;
|
|
2867
|
+
cursor += 1;
|
|
2868
|
+
try {
|
|
2869
|
+
const note = await treeApi.note(notes[index].path);
|
|
2870
|
+
collected[index] = {
|
|
2871
|
+
path: notes[index].path,
|
|
2872
|
+
links: Array.isArray(note.links) ? note.links : [],
|
|
2873
|
+
tags: graphModule.extractTags(note.content ?? "", note.frontmatter),
|
|
2874
|
+
};
|
|
2875
|
+
} catch { /* 单篇读失败不影响整张图 */ }
|
|
2876
|
+
done += 1;
|
|
2877
|
+
if ((done % 8 === 0 || done === notes.length) && !cancelled) setBuildProgress({ done, total: notes.length });
|
|
2878
|
+
}
|
|
2879
|
+
};
|
|
2880
|
+
await Promise.all(Array.from({ length: Math.min(6, Math.max(1, notes.length)) }, worker));
|
|
2881
|
+
if (cancelled) return null;
|
|
2882
|
+
return graphModule.buildGraphFromNotes(collected.filter(Boolean));
|
|
2883
|
+
};
|
|
2884
|
+
treeApi.root().then((root) => {
|
|
2885
|
+
if (cancelled) return null;
|
|
2886
|
+
const features = root && Array.isArray(root.features) ? root.features : [];
|
|
2887
|
+
if (!features.includes("graph")) return buildFromClient();
|
|
2888
|
+
return treeApi.graph();
|
|
2889
|
+
}).then((res) => {
|
|
2890
|
+
if (cancelled || res === null || res === undefined) return;
|
|
2891
|
+
setData(res);
|
|
2892
|
+
setLoading(false);
|
|
2893
|
+
setBuildProgress(null);
|
|
2894
|
+
}).catch((err) => {
|
|
2895
|
+
if (cancelled) return;
|
|
2896
|
+
setError(String(err && err.message ? err.message : err));
|
|
2897
|
+
setLoading(false);
|
|
2898
|
+
});
|
|
2899
|
+
return () => { cancelled = true; };
|
|
2900
|
+
}, [reloadKey]);
|
|
2901
|
+
|
|
2902
|
+
// 位置/尺寸持久化
|
|
2903
|
+
useEffect(() => {
|
|
2904
|
+
try { localStorage.setItem(LS_GRAPH_GEOMETRY, JSON.stringify(geometry)); } catch { /* ignore */ }
|
|
2905
|
+
}, [geometry]);
|
|
2906
|
+
|
|
2907
|
+
useEffect(() => {
|
|
2908
|
+
try { localStorage.setItem(LS_LOCAL_DEPTH, String(localDepth)); } catch { /* ignore */ }
|
|
2909
|
+
}, [localDepth]);
|
|
2910
|
+
|
|
2911
|
+
// 拖动标题栏移动面板(按钮上不触发)
|
|
2912
|
+
const beginPanelDrag = useCallback((e) => {
|
|
2913
|
+
if (e.target && e.target.closest && e.target.closest("button")) return;
|
|
2914
|
+
e.preventDefault();
|
|
2915
|
+
const el = e.currentTarget && e.currentTarget.parentElement;
|
|
2916
|
+
const base = { x: geometry.x !== null ? geometry.x : (el && typeof el.getBoundingClientRect === "function" ? el.getBoundingClientRect().left : 80), y: geometry.y };
|
|
2917
|
+
panelDragRef.current = { startX: e.clientX, startY: e.clientY, base };
|
|
2918
|
+
const onMove = (ev) => {
|
|
2919
|
+
const st = panelDragRef.current;
|
|
2920
|
+
if (!st) return;
|
|
2921
|
+
setGeometry((prev) => ({
|
|
2922
|
+
...prev,
|
|
2923
|
+
x: Math.min(Math.max(0, st.base.x + ev.clientX - st.startX), Math.max(0, window.innerWidth - 240)),
|
|
2924
|
+
y: Math.min(Math.max(8, st.base.y + ev.clientY - st.startY), Math.max(8, window.innerHeight - 120)),
|
|
2925
|
+
}));
|
|
2926
|
+
};
|
|
2927
|
+
const onUp = () => {
|
|
2928
|
+
panelDragRef.current = null;
|
|
2929
|
+
window.removeEventListener("mousemove", onMove);
|
|
2930
|
+
window.removeEventListener("mouseup", onUp);
|
|
2931
|
+
};
|
|
2932
|
+
window.addEventListener("mousemove", onMove);
|
|
2933
|
+
window.addEventListener("mouseup", onUp);
|
|
2934
|
+
}, [geometry.x, geometry.y]);
|
|
2935
|
+
|
|
2936
|
+
// 右下角缩放面板
|
|
2937
|
+
const beginPanelResize = useCallback((e) => {
|
|
2938
|
+
e.preventDefault();
|
|
2939
|
+
e.stopPropagation();
|
|
2940
|
+
resizeRef.current = { startX: e.clientX, startY: e.clientY, baseW: geometry.width, baseH: geometry.height };
|
|
2941
|
+
const onMove = (ev) => {
|
|
2942
|
+
const st = resizeRef.current;
|
|
2943
|
+
if (!st) return;
|
|
2944
|
+
setGeometry((prev) => ({
|
|
2945
|
+
...prev,
|
|
2946
|
+
width: Math.min(Math.max(560, st.baseW + ev.clientX - st.startX), Math.round(window.innerWidth * 0.96)),
|
|
2947
|
+
height: Math.min(Math.max(360, st.baseH + ev.clientY - st.startY), Math.round(window.innerHeight * 0.94)),
|
|
2948
|
+
}));
|
|
2949
|
+
};
|
|
2950
|
+
const onUp = () => {
|
|
2951
|
+
resizeRef.current = null;
|
|
2952
|
+
window.removeEventListener("mousemove", onMove);
|
|
2953
|
+
window.removeEventListener("mouseup", onUp);
|
|
2954
|
+
};
|
|
2955
|
+
window.addEventListener("mousemove", onMove);
|
|
2956
|
+
window.addEventListener("mouseup", onUp);
|
|
2957
|
+
}, [geometry.width, geometry.height]);
|
|
2958
|
+
|
|
2959
|
+
const currentRel = currentNote && currentNote.rel ? currentNote.rel : null;
|
|
2960
|
+
const focusId = currentRel !== null ? `note:${currentRel}` : null;
|
|
2961
|
+
const localAvailable = data !== null && focusId !== null && data.nodes.some((node) => node.id === focusId);
|
|
2962
|
+
const shown = useMemo(() => {
|
|
2963
|
+
if (data === null) return { nodes: [], edges: [] };
|
|
2964
|
+
if (mode === "local" && localAvailable) {
|
|
2965
|
+
const sub = graphModule.localSubgraph(data, focusId, localDepth);
|
|
2966
|
+
return { nodes: sub.nodes, edges: sub.edges };
|
|
2967
|
+
}
|
|
2968
|
+
return { nodes: data.nodes, edges: data.edges };
|
|
2969
|
+
}, [data, mode, localAvailable, focusId, localDepth]);
|
|
2970
|
+
const plan = useMemo(
|
|
2971
|
+
() => graphModule.layoutGraph(shown.nodes, shown.edges, { seed, maxNodes: 420 }),
|
|
2972
|
+
[shown, seed],
|
|
2973
|
+
);
|
|
2974
|
+
|
|
2975
|
+
// 打开 / 切换范围 / 重新布局后把整张图适配进画布
|
|
2976
|
+
useEffect(() => {
|
|
2977
|
+
const el = wrapRef.current;
|
|
2978
|
+
if (!el || plan.nodes.length === 0) return;
|
|
2979
|
+
const w = el.clientWidth || 900;
|
|
2980
|
+
const h = el.clientHeight || 560;
|
|
2981
|
+
const fit = Math.min((w - 30) / plan.width, (h - 30) / plan.height, 1);
|
|
2982
|
+
setTransform({ x: (w - plan.width * fit) / 2, y: (h - plan.height * fit) / 2, s: fit });
|
|
2983
|
+
}, [plan, fitKey]);
|
|
2984
|
+
|
|
2985
|
+
const onWheel = useCallback((e) => {
|
|
2986
|
+
if (plan.nodes.length === 0) return;
|
|
2987
|
+
if (e.cancelable !== false && e.preventDefault) e.preventDefault();
|
|
2988
|
+
const rect = e.currentTarget && e.currentTarget.getBoundingClientRect
|
|
2989
|
+
? e.currentTarget.getBoundingClientRect()
|
|
2990
|
+
: { left: 0, top: 0 };
|
|
2991
|
+
const px = (e.clientX || 0) - rect.left;
|
|
2992
|
+
const py = (e.clientY || 0) - rect.top;
|
|
2993
|
+
setTransform((prev) => {
|
|
2994
|
+
const next = Math.min(4, Math.max(0.18, prev.s * (e.deltaY > 0 ? 0.9 : 1.11)));
|
|
2995
|
+
const k = next / prev.s;
|
|
2996
|
+
return { s: next, x: px - (px - prev.x) * k, y: py - (py - prev.y) * k };
|
|
2997
|
+
});
|
|
2998
|
+
}, [plan.nodes.length]);
|
|
2999
|
+
const onMouseDown = useCallback((e) => {
|
|
3000
|
+
if (e.button !== undefined && e.button !== 0) return;
|
|
3001
|
+
dragRef.current = { x: e.clientX, y: e.clientY, ox: transform.x, oy: transform.y };
|
|
3002
|
+
}, [transform.x, transform.y]);
|
|
3003
|
+
const onMouseMove = useCallback((e) => {
|
|
3004
|
+
const drag = dragRef.current;
|
|
3005
|
+
if (!drag) return;
|
|
3006
|
+
setTransform((prev) => ({ ...prev, x: drag.ox + (e.clientX - drag.x), y: drag.oy + (e.clientY - drag.y) }));
|
|
3007
|
+
}, []);
|
|
3008
|
+
const endDrag = useCallback(() => { dragRef.current = null; }, []);
|
|
3009
|
+
|
|
3010
|
+
const neighbourSet = useMemo(() => {
|
|
3011
|
+
if (hovered === null) return null;
|
|
3012
|
+
const set = new Set([hovered]);
|
|
3013
|
+
for (const edge of shown.edges) {
|
|
3014
|
+
if (edge.source === hovered) set.add(edge.target);
|
|
3015
|
+
if (edge.target === hovered) set.add(edge.source);
|
|
3016
|
+
}
|
|
3017
|
+
return set;
|
|
3018
|
+
}, [hovered, shown.edges]);
|
|
3019
|
+
|
|
3020
|
+
const fitLabel = (text, maxUnits) => {
|
|
3021
|
+
let out = "";
|
|
3022
|
+
let units = 0;
|
|
3023
|
+
for (const ch of text) {
|
|
3024
|
+
const w = /[\u2E80-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/.test(ch) ? 2 : 1;
|
|
3025
|
+
if (units + w > maxUnits) return `${out}…`;
|
|
3026
|
+
out += ch;
|
|
3027
|
+
units += w;
|
|
3028
|
+
}
|
|
3029
|
+
return out;
|
|
3030
|
+
};
|
|
3031
|
+
const showAllLabels = plan.nodes.length <= 150;
|
|
3032
|
+
|
|
3033
|
+
// 小按钮工厂:分段控件(全库/局部/聚焦)+ 右侧图标按钮,统一尺寸,永不换行
|
|
3034
|
+
const modeButton = (label, active, onClick, options = {}) => createElement("button", {
|
|
3035
|
+
className: "dsh-obs-mini",
|
|
3036
|
+
style: {
|
|
3037
|
+
flex: "none",
|
|
3038
|
+
width: "auto",
|
|
3039
|
+
height: 24,
|
|
3040
|
+
padding: "0 10px",
|
|
3041
|
+
border: "none",
|
|
3042
|
+
borderRight: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
|
|
3043
|
+
borderRadius: 0,
|
|
3044
|
+
background: active ? "var(--dsw-alias-interactive-bg-hover-accent, rgba(99,102,241,.18))" : "transparent",
|
|
3045
|
+
color: active ? "var(--dsw-alias-brand-primary, #8b7cf6)" : "var(--dsw-alias-label-secondary, #b3b8c2)",
|
|
3046
|
+
whiteSpace: "nowrap",
|
|
3047
|
+
cursor: options.disabled === true ? "not-allowed" : "pointer",
|
|
3048
|
+
opacity: options.disabled === true ? 0.45 : 1,
|
|
3049
|
+
},
|
|
3050
|
+
disabled: options.disabled === true,
|
|
3051
|
+
title: options.title,
|
|
3052
|
+
onClick,
|
|
3053
|
+
}, label);
|
|
3054
|
+
const iconButton = (glyph, title, onClick) => createElement("button", {
|
|
3055
|
+
className: "dsh-obs-mini",
|
|
3056
|
+
style: { flex: "none", width: "auto", height: 24, minWidth: 24, padding: "0 4px" },
|
|
3057
|
+
title,
|
|
3058
|
+
onClick,
|
|
3059
|
+
}, glyph);
|
|
3060
|
+
|
|
3061
|
+
const head = createElement("div", {
|
|
3062
|
+
className: "dsh-obs-graph-head",
|
|
3063
|
+
title: t("graphMove"),
|
|
3064
|
+
onMouseDown: beginPanelDrag,
|
|
3065
|
+
style: {
|
|
3066
|
+
display: "flex",
|
|
3067
|
+
alignItems: "center",
|
|
3068
|
+
gap: 10,
|
|
3069
|
+
padding: "8px 12px",
|
|
3070
|
+
borderBottom: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
|
|
3071
|
+
cursor: "move",
|
|
3072
|
+
userSelect: "none",
|
|
3073
|
+
flex: "none",
|
|
3074
|
+
},
|
|
3075
|
+
},
|
|
3076
|
+
createElement("div", {
|
|
3077
|
+
className: "dsh-obs-graph-title",
|
|
3078
|
+
style: { whiteSpace: "nowrap", flex: "none" },
|
|
3079
|
+
}, `🕸 ${t("graph")}`),
|
|
3080
|
+
data !== null && createElement("div", {
|
|
3081
|
+
className: "dsh-obs-graph-stats",
|
|
3082
|
+
style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "0 1 auto" },
|
|
3083
|
+
}, t("graphStats", { notes: data.stats.notes, tags: data.stats.tags, edges: data.stats.edges })),
|
|
3084
|
+
// 右侧控制区整体作为一块:分段按钮组(全库/局部/聚焦)+ 局部跳数 + 图标按钮
|
|
3085
|
+
createElement("div", {
|
|
3086
|
+
style: { display: "flex", alignItems: "center", gap: 10, marginLeft: "auto", flex: "none" },
|
|
3087
|
+
},
|
|
3088
|
+
createElement("div", {
|
|
3089
|
+
className: "dsh-obs-graph-modes",
|
|
3090
|
+
style: {
|
|
3091
|
+
display: "flex",
|
|
3092
|
+
alignItems: "center",
|
|
3093
|
+
flex: "none",
|
|
3094
|
+
border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
|
|
3095
|
+
borderRadius: 8,
|
|
3096
|
+
overflow: "hidden",
|
|
3097
|
+
width: "auto",
|
|
3098
|
+
},
|
|
3099
|
+
},
|
|
3100
|
+
modeButton(t("graphGlobal"), mode === "global", () => setMode("global")),
|
|
3101
|
+
modeButton(t("graphLocal"), mode === "local", () => setMode("local"), {
|
|
3102
|
+
disabled: !localAvailable,
|
|
3103
|
+
title: localAvailable ? t("graphLocalHint") : t("graphNoNote"),
|
|
3104
|
+
}),
|
|
3105
|
+
modeButton(t("graphFocus"), false, () => {
|
|
3106
|
+
if (!localAvailable) return;
|
|
3107
|
+
setMode("local");
|
|
3108
|
+
setFitKey((v) => v + 1);
|
|
3109
|
+
}, {
|
|
3110
|
+
disabled: !localAvailable,
|
|
3111
|
+
title: localAvailable ? t("graphFocusHint") : t("graphNoNote"),
|
|
3112
|
+
}),
|
|
3113
|
+
),
|
|
3114
|
+
// 局部模式:跳数(与 Obsidian 局部图谱 depth 滑杆同范围 1–5)
|
|
3115
|
+
mode === "local" && createElement("span", {
|
|
3116
|
+
className: "dsh-obs-graph-depth",
|
|
3117
|
+
style: { display: "flex", alignItems: "center", gap: 2, flex: "none" },
|
|
3118
|
+
title: t("graphDepthHint"),
|
|
3119
|
+
},
|
|
3120
|
+
createElement("span", {
|
|
3121
|
+
className: "dsh-obs-graph-depth-label",
|
|
3122
|
+
style: { fontSize: 11, marginRight: 2, whiteSpace: "nowrap" },
|
|
3123
|
+
}, t("graphDepth")),
|
|
3124
|
+
[1, 2, 3, 4, 5].map((depth) => createElement("button", {
|
|
3125
|
+
key: depth,
|
|
3126
|
+
className: `dsh-obs-mini${localDepth === depth ? " dsh-obs-on" : ""}`,
|
|
3127
|
+
style: { flex: "none", width: "auto", height: 24, minWidth: 22, padding: "0 5px", whiteSpace: "nowrap" },
|
|
3128
|
+
onClick: () => setLocalDepth(depth),
|
|
3129
|
+
}, String(depth))),
|
|
3130
|
+
),
|
|
3131
|
+
createElement("div", { style: { display: "flex", alignItems: "center", gap: 2, flex: "none" } },
|
|
3132
|
+
iconButton("↻", t("graphRerun"), () => setSeed((v) => v + 1)),
|
|
3133
|
+
iconButton("⟳", t("graphRefresh"), () => setReloadKey((v) => v + 1)),
|
|
3134
|
+
iconButton("✕", t("graphClose"), onClose),
|
|
3135
|
+
),
|
|
3136
|
+
),
|
|
3137
|
+
);
|
|
3138
|
+
|
|
3139
|
+
let body;
|
|
3140
|
+
if (loading) {
|
|
3141
|
+
body = createElement("div", { className: "dsh-obs-graph-empty" },
|
|
3142
|
+
buildProgress !== null
|
|
3143
|
+
? t("graphBuilding", { done: buildProgress.done, total: buildProgress.total })
|
|
3144
|
+
: t("graphLoading"));
|
|
3145
|
+
} else if (error !== "") {
|
|
3146
|
+
body = createElement("div", { className: "dsh-obs-graph-error" },
|
|
3147
|
+
createElement("div", { className: "dsh-obs-graph-error-icon" }, "⚠️"),
|
|
3148
|
+
createElement("div", { className: "dsh-obs-graph-error-title" }, t("graphErrorTitle")),
|
|
3149
|
+
createElement("div", { className: "dsh-obs-graph-error-text" }, t("graphError", { msg: error })),
|
|
3150
|
+
createElement("div", { className: "dsh-obs-graph-error-actions" },
|
|
3151
|
+
createElement("button", { className: "dsh-obs-primary", onClick: () => setReloadKey((v) => v + 1) }, `↻ ${t("graphRetry")}`),
|
|
3152
|
+
createElement("button", {
|
|
3153
|
+
onClick: () => setDetailsOpen((v) => !v),
|
|
3154
|
+
}, detailsOpen ? t("graphDetailsHide") : t("graphDetails")),
|
|
3155
|
+
),
|
|
3156
|
+
detailsOpen && createElement("pre", { className: "dsh-obs-graph-error-details" }, error),
|
|
3157
|
+
);
|
|
3158
|
+
} else if (plan.nodes.length === 0) {
|
|
3159
|
+
body = createElement("div", { className: "dsh-obs-graph-empty" },
|
|
3160
|
+
mode === "local" ? t("graphNoNote") : t("graphEmpty"));
|
|
3161
|
+
} else {
|
|
3162
|
+
const planById = new Map(plan.nodes.map((node) => [node.id, node]));
|
|
3163
|
+
body = createElement("div", {
|
|
3164
|
+
ref: wrapRef,
|
|
3165
|
+
className: "dsh-obs-graph-canvas",
|
|
3166
|
+
style: { position: "relative", flex: 1, minHeight: 0, overflow: "hidden", cursor: "grab" },
|
|
3167
|
+
onWheel,
|
|
3168
|
+
onMouseDown,
|
|
3169
|
+
onMouseMove,
|
|
3170
|
+
onMouseUp: endDrag,
|
|
3171
|
+
onMouseLeave: endDrag,
|
|
3172
|
+
},
|
|
3173
|
+
createElement("svg", { className: "dsh-obs-graph-svg" },
|
|
3174
|
+
createElement("g", { transform: `translate(${transform.x},${transform.y}) scale(${transform.s})` },
|
|
3175
|
+
shown.edges.map((edge) => {
|
|
3176
|
+
const a = planById.get(edge.source);
|
|
3177
|
+
const b = planById.get(edge.target);
|
|
3178
|
+
if (!a || !b) return null;
|
|
3179
|
+
const active = neighbourSet !== null && (neighbourSet.has(edge.source) || neighbourSet.has(edge.target));
|
|
3180
|
+
return createElement("line", {
|
|
3181
|
+
key: `${edge.source}-${edge.target}`,
|
|
3182
|
+
className: `dsh-obs-graph-edge${active ? " dsh-obs-graph-edge-on" : ""}`,
|
|
3183
|
+
x1: a.x,
|
|
3184
|
+
y1: a.y,
|
|
3185
|
+
x2: b.x,
|
|
3186
|
+
y2: b.y,
|
|
3187
|
+
});
|
|
3188
|
+
}),
|
|
3189
|
+
plan.nodes.map((node) => {
|
|
3190
|
+
const isTag = node.kind === "tag";
|
|
3191
|
+
const radius = isTag ? 7 : 4 + Math.min(9, node.degree * 1.5);
|
|
3192
|
+
const dim = neighbourSet !== null && !neighbourSet.has(node.id);
|
|
3193
|
+
const cls = `dsh-obs-graph-node ${isTag ? "dsh-obs-graph-tag" : "dsh-obs-graph-note"}`
|
|
3194
|
+
+ `${node.id === hovered ? " dsh-obs-graph-sel" : ""}${dim ? " dsh-obs-graph-dim" : ""}`
|
|
3195
|
+
+ `${focusId !== null && node.id === focusId ? " dsh-obs-graph-focus" : ""}`;
|
|
3196
|
+
const label = showAllLabels || node.degree >= 5 || node.id === hovered
|
|
3197
|
+
? fitLabel(node.label, 26)
|
|
3198
|
+
: "";
|
|
3199
|
+
return createElement("g", {
|
|
3200
|
+
key: node.id,
|
|
3201
|
+
className: cls,
|
|
3202
|
+
transform: `translate(${node.x},${node.y})`,
|
|
3203
|
+
onMouseEnter: () => setHovered(node.id),
|
|
3204
|
+
onMouseLeave: () => setHovered(null),
|
|
3205
|
+
onClick: (e) => {
|
|
3206
|
+
if (e && e.stopPropagation) e.stopPropagation();
|
|
3207
|
+
if (!isTag && onOpenNote) onOpenNote(node.path, node.label);
|
|
3208
|
+
else setHovered(node.id);
|
|
3209
|
+
},
|
|
3210
|
+
},
|
|
3211
|
+
createElement(isTag ? "rect" : "circle", isTag
|
|
3212
|
+
? { x: -radius, y: -radius, width: radius * 2, height: radius * 2, rx: 3 }
|
|
3213
|
+
: { r: radius }),
|
|
3214
|
+
createElement("title", null, node.label),
|
|
3215
|
+
label !== "" && createElement("text", { x: radius + 6, y: 4 }, label),
|
|
3216
|
+
);
|
|
3217
|
+
}),
|
|
3218
|
+
),
|
|
3219
|
+
),
|
|
3220
|
+
);
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
const truncatedNote = plan.truncated ? t("graphTruncated", { n: plan.nodes.length }) : t("graphHint");
|
|
3224
|
+
// 关键布局全部走内联样式:即使插件的 <style> 注入失败(旧 bundle / 特殊宿主),
|
|
3225
|
+
// 面板也不会变成左上角裸文本;z-index 也内联,避免被宿主容器压住。
|
|
3226
|
+
const defaultX = typeof window !== "undefined"
|
|
3227
|
+
? Math.max(16, Math.round((window.innerWidth - geometry.width) / 2))
|
|
3228
|
+
: 80;
|
|
3229
|
+
const style = {
|
|
3230
|
+
position: "fixed",
|
|
3231
|
+
left: geometry.x === null ? defaultX : geometry.x,
|
|
3232
|
+
top: geometry.y,
|
|
3233
|
+
width: geometry.width,
|
|
3234
|
+
height: geometry.height,
|
|
3235
|
+
zIndex: 2147483000,
|
|
3236
|
+
display: "flex",
|
|
3237
|
+
flexDirection: "column",
|
|
3238
|
+
boxSizing: "border-box",
|
|
3239
|
+
overflow: "hidden",
|
|
3240
|
+
background: "var(--dsw-alias-bg-overlay, #1f2328)",
|
|
3241
|
+
color: "var(--dsw-alias-label-primary, #e6e6e6)",
|
|
3242
|
+
border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.15))",
|
|
3243
|
+
borderRadius: 14,
|
|
3244
|
+
boxShadow: "0 24px 64px rgba(0,0,0,.45)",
|
|
3245
|
+
};
|
|
3246
|
+
return createElement("div", {
|
|
3247
|
+
className: "dsh-obs-graph",
|
|
3248
|
+
role: "dialog",
|
|
3249
|
+
"aria-label": t("graph"),
|
|
3250
|
+
style,
|
|
3251
|
+
},
|
|
3252
|
+
head,
|
|
3253
|
+
body,
|
|
3254
|
+
createElement("div", {
|
|
3255
|
+
className: "dsh-obs-graph-hint",
|
|
3256
|
+
style: {
|
|
3257
|
+
flex: "none",
|
|
3258
|
+
display: "flex",
|
|
3259
|
+
alignItems: "center",
|
|
3260
|
+
gap: 12,
|
|
3261
|
+
padding: "6px 14px",
|
|
3262
|
+
borderTop: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))",
|
|
3263
|
+
fontSize: 11,
|
|
3264
|
+
color: "var(--dsw-alias-label-tertiary, #9aa0aa)",
|
|
3265
|
+
},
|
|
3266
|
+
},
|
|
3267
|
+
createElement("span", {
|
|
3268
|
+
style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "0 1 auto" },
|
|
3269
|
+
}, truncatedNote),
|
|
3270
|
+
createElement("span", {
|
|
3271
|
+
className: "dsh-obs-graph-legend",
|
|
3272
|
+
style: { display: "flex", alignItems: "center", gap: 10, flex: "none", marginLeft: "auto", whiteSpace: "nowrap" },
|
|
3273
|
+
},
|
|
3274
|
+
createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4 } },
|
|
3275
|
+
createElement("span", { className: "dsh-obs-legend-dot" }), t("graphLegendNote")),
|
|
3276
|
+
createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4 } },
|
|
3277
|
+
createElement("span", { className: "dsh-obs-legend-tag" }), t("graphLegendTag")),
|
|
3278
|
+
createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4 } },
|
|
3279
|
+
createElement("span", { className: "dsh-obs-legend-focus" }), t("graphLegendFocus")),
|
|
3280
|
+
createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4 } },
|
|
3281
|
+
createElement("span", { className: "dsh-obs-legend-link" }), t("graphLegendLink")),
|
|
3282
|
+
),
|
|
3283
|
+
),
|
|
3284
|
+
createElement("div", { className: "dsh-obs-graph-resize", title: t("resize"), onMouseDown: beginPanelResize }),
|
|
3285
|
+
);
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
/** shell.overlay 包装:只有 shared.graphOpen 时才挂载 GraphView(关闭即卸载)。 */
|
|
3289
|
+
function GraphOverlay({ t }) {
|
|
3290
|
+
const { graphOpen, openNote } = useShared();
|
|
3291
|
+
if (!graphOpen) return null;
|
|
3292
|
+
return createElement(GraphView, {
|
|
3293
|
+
t,
|
|
3294
|
+
currentNote: openNote,
|
|
3295
|
+
onOpenNote: (rel, name) => patchShared({ openNote: { rel, name } }),
|
|
3296
|
+
onClose: () => patchShared({ graphOpen: false }),
|
|
3297
|
+
});
|
|
3298
|
+
}
|
|
3299
|
+
|
|
2586
3300
|
// ── 右侧笔记 Dock(shell.overlay)──────────────────────────────────────────
|
|
3301
|
+
|
|
3302
|
+
/**
|
|
3303
|
+
* 思维导图视图(B1):把 note.content 的标题/列表画成横向树。
|
|
3304
|
+
* - 滚轮缩放(以指针为中心)/ 拖动平移;
|
|
3305
|
+
* - 单击节点:选中并显示操作条(跳到源码行 / 把分支插入对话);
|
|
3306
|
+
* - 双击节点:直接跳到该节点的源码行。
|
|
3307
|
+
*/
|
|
3308
|
+
function MindMapView({ note, t, onJumpLine, onAskBranch }) {
|
|
3309
|
+
const tree = useMemo(
|
|
3310
|
+
() => mindMapModule.buildTree(note.content, { title: note.name.replace(/\.md$/i, "") }),
|
|
3311
|
+
[note.content, note.name],
|
|
3312
|
+
);
|
|
3313
|
+
const plan = useMemo(() => mindMapModule.layout(tree), [tree]);
|
|
3314
|
+
const [viewState, setViewState] = useState({ x: 0, y: 14, s: 1 });
|
|
3315
|
+
const [selected, setSelected] = useState(null);
|
|
3316
|
+
const dragRef = useRef(null);
|
|
3317
|
+
const wrapRef = useRef(null);
|
|
3318
|
+
const hasNodes = plan.nodes.length > 1;
|
|
3319
|
+
|
|
3320
|
+
// 初始把整张导图缩放进 Dock 可视区(导图比面板宽,不 fit 只能看到最左一列)
|
|
3321
|
+
useEffect(() => {
|
|
3322
|
+
const el = wrapRef.current;
|
|
3323
|
+
if (!el || !hasNodes) return;
|
|
3324
|
+
const w = el.clientWidth || 480;
|
|
3325
|
+
const h = el.clientHeight || 320;
|
|
3326
|
+
// 横向导图比面板宽:只按高度适配(保持字号可读),横向靠拖动平移
|
|
3327
|
+
const fit = Math.min(1, (h - 28) / plan.height);
|
|
3328
|
+
setViewState({
|
|
3329
|
+
x: 10,
|
|
3330
|
+
y: Math.max(10, (h - plan.height * fit) / 2),
|
|
3331
|
+
s: fit,
|
|
3332
|
+
});
|
|
3333
|
+
}, [plan, hasNodes]);
|
|
3334
|
+
|
|
3335
|
+
// SVG 无自动省略:按估算字宽截断,完整文本放 <title> 悬停显示
|
|
3336
|
+
const fitText = (text, width) => {
|
|
3337
|
+
const maxUnits = Math.max(6, Math.floor((width - 26) / 7.2));
|
|
3338
|
+
let out = "";
|
|
3339
|
+
let units = 0;
|
|
3340
|
+
for (const ch of text) {
|
|
3341
|
+
const u = /[\u2E80-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/.test(ch) ? 2 : 1;
|
|
3342
|
+
if (units + u > maxUnits) return `${out}…`;
|
|
3343
|
+
out += ch;
|
|
3344
|
+
units += u;
|
|
3345
|
+
}
|
|
3346
|
+
return out;
|
|
3347
|
+
};
|
|
3348
|
+
|
|
3349
|
+
const onWheel = useCallback((e) => {
|
|
3350
|
+
if (!hasNodes) return;
|
|
3351
|
+
if (e.cancelable !== false && e.preventDefault) e.preventDefault();
|
|
3352
|
+
const rect = e.currentTarget && e.currentTarget.getBoundingClientRect
|
|
3353
|
+
? e.currentTarget.getBoundingClientRect()
|
|
3354
|
+
: { left: 0, top: 0 };
|
|
3355
|
+
const px = (e.clientX || 0) - rect.left;
|
|
3356
|
+
const py = (e.clientY || 0) - rect.top;
|
|
3357
|
+
setViewState((prev) => {
|
|
3358
|
+
const next = Math.min(2.6, Math.max(0.35, prev.s * (e.deltaY > 0 ? 0.88 : 1.14)));
|
|
3359
|
+
const k = next / prev.s;
|
|
3360
|
+
return { s: next, x: px - (px - prev.x) * k, y: py - (py - prev.y) * k };
|
|
3361
|
+
});
|
|
3362
|
+
}, [hasNodes]);
|
|
3363
|
+
|
|
3364
|
+
const onMouseDown = useCallback((e) => {
|
|
3365
|
+
if (e.button !== undefined && e.button !== 0) return;
|
|
3366
|
+
dragRef.current = { x: e.clientX, y: e.clientY, ox: viewState.x, oy: viewState.y };
|
|
3367
|
+
}, [viewState.x, viewState.y]);
|
|
3368
|
+
const onMouseMove = useCallback((e) => {
|
|
3369
|
+
const drag = dragRef.current;
|
|
3370
|
+
if (!drag) return;
|
|
3371
|
+
setViewState((prev) => ({ ...prev, x: drag.ox + (e.clientX - drag.x), y: drag.oy + (e.clientY - drag.y) }));
|
|
3372
|
+
}, []);
|
|
3373
|
+
const endDrag = useCallback(() => { dragRef.current = null; }, []);
|
|
3374
|
+
|
|
3375
|
+
if (!hasNodes) {
|
|
3376
|
+
return createElement("div", { className: "dsh-obs-map-empty" }, t("mapEmpty"));
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
const selectedNode = selected !== null ? plan.byId[selected] : null;
|
|
3380
|
+
return createElement("div", {
|
|
3381
|
+
ref: wrapRef,
|
|
3382
|
+
className: "dsh-obs-map",
|
|
3383
|
+
onWheel,
|
|
3384
|
+
onMouseDown,
|
|
3385
|
+
onMouseMove,
|
|
3386
|
+
onMouseUp: endDrag,
|
|
3387
|
+
onMouseLeave: endDrag,
|
|
3388
|
+
},
|
|
3389
|
+
createElement("svg", { className: "dsh-obs-map-svg" },
|
|
3390
|
+
createElement("g", { transform: `translate(${viewState.x},${viewState.y}) scale(${viewState.s})` },
|
|
3391
|
+
plan.edges.map((edge) => {
|
|
3392
|
+
const a = plan.byId[edge.from];
|
|
3393
|
+
const b = plan.byId[edge.to];
|
|
3394
|
+
if (!a || !b) return null;
|
|
3395
|
+
const x1 = a.x + a.w;
|
|
3396
|
+
const x2 = b.x;
|
|
3397
|
+
const mx = (x1 + x2) / 2;
|
|
3398
|
+
return createElement("path", {
|
|
3399
|
+
key: `${edge.from}-${edge.to}`,
|
|
3400
|
+
className: "dsh-obs-map-edge",
|
|
3401
|
+
d: `M ${x1} ${a.y} C ${mx} ${a.y}, ${mx} ${b.y}, ${x2} ${b.y}`,
|
|
3402
|
+
});
|
|
3403
|
+
}),
|
|
3404
|
+
plan.nodes.map((node) => createElement("g", {
|
|
3405
|
+
key: node.id,
|
|
3406
|
+
className: `dsh-obs-map-node dsh-obs-map-${node.kind}${selected === node.id ? " dsh-obs-map-sel" : ""}`,
|
|
3407
|
+
transform: `translate(${node.x},${node.y - node.h / 2})`,
|
|
3408
|
+
onClick: (e) => {
|
|
3409
|
+
if (e && e.stopPropagation) e.stopPropagation();
|
|
3410
|
+
setSelected(node.id);
|
|
3411
|
+
},
|
|
3412
|
+
onDoubleClick: (e) => {
|
|
3413
|
+
if (e && e.stopPropagation) e.stopPropagation();
|
|
3414
|
+
if (node.line > 0) onJumpLine(node.line);
|
|
3415
|
+
},
|
|
3416
|
+
},
|
|
3417
|
+
createElement("rect", { width: node.w, height: node.h, rx: 9 }),
|
|
3418
|
+
createElement("text", { x: 13, y: node.h / 2 + 5 }, fitText(node.text, node.w)),
|
|
3419
|
+
createElement("title", null, node.text),
|
|
3420
|
+
)),
|
|
3421
|
+
),
|
|
3422
|
+
),
|
|
3423
|
+
selectedNode !== null && createElement("div", { className: "dsh-obs-map-actions" },
|
|
3424
|
+
createElement("span", { className: "dsh-obs-map-line" }, `L${selectedNode.line}`),
|
|
3425
|
+
createElement("button", {
|
|
3426
|
+
onClick: () => onJumpLine(selectedNode.line),
|
|
3427
|
+
}, `↩ ${t("mapJump")}`),
|
|
3428
|
+
createElement("button", {
|
|
3429
|
+
className: "dsh-obs-primary",
|
|
3430
|
+
onClick: () => onAskBranch(selectedNode),
|
|
3431
|
+
}, `⤴ ${t("mapAsk")}`),
|
|
3432
|
+
),
|
|
3433
|
+
createElement("div", { className: "dsh-obs-map-hint" }, t("mapHint")),
|
|
3434
|
+
);
|
|
3435
|
+
}
|
|
3436
|
+
|
|
2587
3437
|
function Dock({ t, workspaces }) {
|
|
2588
3438
|
const { openNote, vaultRev } = useShared();
|
|
2589
3439
|
const [note, setNote] = useState(null);
|
|
2590
3440
|
const [root, setRoot] = useState("");
|
|
2591
3441
|
const [editing, setEditing] = useState(false);
|
|
3442
|
+
// 视图:预览(行号)/ 思维导图;编辑态始终是编辑器
|
|
3443
|
+
const [view, setView] = useState("preview");
|
|
3444
|
+
const [jumpLine, setJumpLine] = useState(null);
|
|
2592
3445
|
const [draft, setDraft] = useState("");
|
|
2593
3446
|
const [saving, setSaving] = useState(false);
|
|
2594
3447
|
const [error, setError] = useState("");
|
|
@@ -2885,6 +3738,60 @@
|
|
|
2885
3738
|
patchShared({ openNote: { rel: found.path, name: found.name } });
|
|
2886
3739
|
}, [editing, draft, note, t]);
|
|
2887
3740
|
|
|
3741
|
+
/** 思维导图 → 预览:跳到源码行并短暂高亮(行元素带 data-line)。 */
|
|
3742
|
+
const jumpToLine = useCallback((line) => {
|
|
3743
|
+
if (!line || line < 1) return;
|
|
3744
|
+
setView("preview");
|
|
3745
|
+
setJumpLine(line);
|
|
3746
|
+
}, []);
|
|
3747
|
+
|
|
3748
|
+
/** 思维导图 → 对话:把「该节点 + 全部后代」的源码区间按选区提问同款 prompt 插入输入框。 */
|
|
3749
|
+
const askBranch = useCallback((node) => {
|
|
3750
|
+
if (!note) return;
|
|
3751
|
+
const span = mindMapModule.branchSpan(note.content, node);
|
|
3752
|
+
const prompt = buildPrompt(
|
|
3753
|
+
t,
|
|
3754
|
+
treeApi.absPath(note.path, root),
|
|
3755
|
+
note.content,
|
|
3756
|
+
{ text: span.text, start: span.start, end: span.end, mode: "preview" },
|
|
3757
|
+
"",
|
|
3758
|
+
);
|
|
3759
|
+
if (injectPrompt(prompt)) {
|
|
3760
|
+
showToast(t("branchCited", { n: span.endLine - span.startLine + 1 }));
|
|
3761
|
+
return;
|
|
3762
|
+
}
|
|
3763
|
+
try {
|
|
3764
|
+
if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
|
3765
|
+
navigator.clipboard.writeText(prompt).then(() => showToast(t("noInput"))).catch(() => showToast(t("citeFail")));
|
|
3766
|
+
} else {
|
|
3767
|
+
showToast(t("citeFail"));
|
|
3768
|
+
}
|
|
3769
|
+
} catch {
|
|
3770
|
+
showToast(t("citeFail"));
|
|
3771
|
+
}
|
|
3772
|
+
}, [note, t, root]);
|
|
3773
|
+
|
|
3774
|
+
// 切笔记时回到预览态(避免上一份笔记的导图/跳转状态残留)
|
|
3775
|
+
useEffect(() => {
|
|
3776
|
+
setView("preview");
|
|
3777
|
+
setJumpLine(null);
|
|
3778
|
+
}, [openNote]);
|
|
3779
|
+
|
|
3780
|
+
// 预览行高亮:跳到行号后滚动到该行并闪一下(1.8s 后恢复)
|
|
3781
|
+
useEffect(() => {
|
|
3782
|
+
if (jumpLine === null || view !== "preview" || !previewRef.current) return undefined;
|
|
3783
|
+
const el = previewRef.current.querySelector(`[data-line="${jumpLine}"]`);
|
|
3784
|
+
if (el) {
|
|
3785
|
+
if (typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center" });
|
|
3786
|
+
el.classList.add("dsh-obs-md-flash");
|
|
3787
|
+
}
|
|
3788
|
+
const timer = window.setTimeout(() => {
|
|
3789
|
+
if (el) el.classList.remove("dsh-obs-md-flash");
|
|
3790
|
+
setJumpLine(null);
|
|
3791
|
+
}, 1800);
|
|
3792
|
+
return () => window.clearTimeout(timer);
|
|
3793
|
+
}, [jumpLine, view, note, vaultRev]);
|
|
3794
|
+
|
|
2888
3795
|
const dirty = editing && note !== null && draft !== note.content;
|
|
2889
3796
|
|
|
2890
3797
|
// 编辑态行号(每行一个,对应源码行数;textarea 为 white-space:pre 无折行,行号与文本行一一对齐)
|
|
@@ -2908,6 +3815,11 @@
|
|
|
2908
3815
|
createElement("span", null, "📝"),
|
|
2909
3816
|
createElement("span", null, note ? note.name : openNote.name),
|
|
2910
3817
|
),
|
|
3818
|
+
!editing && note && createElement("button", {
|
|
3819
|
+
className: view === "map" ? "dsh-obs-on" : undefined,
|
|
3820
|
+
title: view === "map" ? t("preview") : t("mindMap"),
|
|
3821
|
+
onClick: () => setView((prev) => (prev === "map" ? "preview" : "map")),
|
|
3822
|
+
}, view === "map" ? `📄 ${t("preview")}` : `🧠 ${t("mindMap")}`),
|
|
2911
3823
|
!editing && note && createElement("button", { onClick: () => void cite() }, `⤴ ${t("cite")}`),
|
|
2912
3824
|
!editing && note && createElement("button", { onClick: () => { setDraft(note.content); setEditing(true); } }, t("edit")),
|
|
2913
3825
|
editing && createElement("button", {
|
|
@@ -2959,8 +3871,17 @@
|
|
|
2959
3871
|
},
|
|
2960
3872
|
}),
|
|
2961
3873
|
)
|
|
2962
|
-
:
|
|
2963
|
-
createElement("div", {
|
|
3874
|
+
: view === "map"
|
|
3875
|
+
? createElement("div", { className: "dsh-obs-dock-body dsh-obs-dock-map" },
|
|
3876
|
+
createElement(MindMapView, {
|
|
3877
|
+
note,
|
|
3878
|
+
t,
|
|
3879
|
+
onJumpLine: jumpToLine,
|
|
3880
|
+
onAskBranch: askBranch,
|
|
3881
|
+
}),
|
|
3882
|
+
)
|
|
3883
|
+
: createElement("div", { className: "dsh-obs-dock-body" },
|
|
3884
|
+
createElement("div", { ref: previewRef, className: "dsh-obs-md" },
|
|
2964
3885
|
renderNumbered(note.content, {
|
|
2965
3886
|
onWikilink: (target) => void openWikilink(target),
|
|
2966
3887
|
onText: () => {},
|
|
@@ -3249,10 +4170,15 @@
|
|
|
3249
4170
|
{ name: "shell.overlay", id: "obsidian-prompt", order: 6, label: "Obsidian text prompt", locale: NS },
|
|
3250
4171
|
Prompt,
|
|
3251
4172
|
);
|
|
4173
|
+
const d4 = ctx.slots.register(
|
|
4174
|
+
{ name: "shell.overlay", id: "obsidian-graph", order: 32, label: "Obsidian relation graph", locale: NS },
|
|
4175
|
+
GraphOverlay,
|
|
4176
|
+
);
|
|
3252
4177
|
return () => {
|
|
3253
4178
|
d1();
|
|
3254
4179
|
d2();
|
|
3255
4180
|
d3();
|
|
4181
|
+
d4();
|
|
3256
4182
|
};
|
|
3257
4183
|
});
|
|
3258
4184
|
}
|