dsh-obsidian-notes 0.6.1 → 0.6.2

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 CHANGED
@@ -20,8 +20,8 @@ A local-first Markdown notes panel that sits in the DSH sidebar and hands notes
20
20
  to the chat without copy-paste:
21
21
 
22
22
  1. **Your notes stay beside the conversation.** Point it at a folder of Markdown
23
- files and you get the vault tree, a line-numbered Obsidian-flavoured preview,
24
- edit/save, and search across titles and bodies — no import, no sync, no cloud.
23
+ files and you get the vault tree, an Obsidian-flavoured preview, edit/save,
24
+ and search across titles and bodies — no import, no sync, no cloud.
25
25
  2. **The handoff is the feature.** Cite a whole note as an `@path` mention, or
26
26
  select a passage and press **Ask** — the prompt in the composer carries the
27
27
  absolute path, the line range and the quoted text, so the model can refer to
@@ -31,15 +31,16 @@ to the chat without copy-paste:
31
31
  `![[Pasted image …]]` embed; moving that folder later rewrites the embeds.
32
32
  4. **Two ways to see the shape of a vault.** Turn a note into a **mind map**
33
33
  built from its headings and list items, or open the **relation graph**
34
- computed from `[[links]]` and `#tags` — vault-wide, or locally around the open
35
- note with 1–5 hops.
34
+ computed from `[[links]]` and `#tags` — vault-wide, locally around the open
35
+ note with 1–5 hops, or focused: the whole graph with the camera framed on the
36
+ open note and its neighbours.
36
37
 
37
38
  Everything runs on your machine: the plugin reads and writes only the folder you
38
39
  choose, and the client talks to the host over its own loopback routes.
39
40
 
40
41
  ## Screenshots
41
42
 
42
- | Notes tree + docked preview | Callouts, line numbers, badges |
43
+ | Notes tree + docked preview | Callouts, task lists, badges |
43
44
  | --- | --- |
44
45
  | ![Sidebar notes tree with the preview dock open](https://raw.githubusercontent.com/revive123456/dsh-obsidian-notes/main/docs/assets/screenshot-sidebar.png) | ![Coloured Obsidian callouts with one source line per rendered line](https://raw.githubusercontent.com/revive123456/dsh-obsidian-notes/main/docs/assets/screenshot-callouts.png) |
45
46
 
@@ -47,7 +48,7 @@ choose, and the client talks to the host over its own loopback routes.
47
48
  | --- | --- |
48
49
  | ![Table with column alignment and a TypeScript code fence](https://raw.githubusercontent.com/revive123456/dsh-obsidian-notes/main/docs/assets/screenshot-markdown.png) | ![Editor with line numbers and Save button](https://raw.githubusercontent.com/revive123456/dsh-obsidian-notes/main/docs/assets/screenshot-editing.png) |
49
50
 
50
- | Mind map from headings + lists | Vault-wide graph view (notes, links, tags) |
51
+ | Mind map from headings + lists | Vault-wide graph — Vault / Local / Focus |
51
52
  | --- | --- |
52
53
  | ![Mind map view built from the note headings and list items](https://raw.githubusercontent.com/revive123456/dsh-obsidian-notes/main/docs/assets/screenshot-mindmap.png) | ![Relation graph of the whole vault: notes as circles, tags as squares, links as lines](https://raw.githubusercontent.com/revive123456/dsh-obsidian-notes/main/docs/assets/screenshot-graph.png) |
53
54
 
@@ -128,26 +129,28 @@ The default vault is `$DSH_WORKSPACE/obsidian_vault`, falling back to
128
129
  ### Typical workflow
129
130
 
130
131
  1. Open **📚 Notes** and pick a vault (above).
131
- 2. Click a note to read the line-numbered preview; **Edit** to change it, then
132
- **Save** or <kbd>Ctrl/Cmd+S</kbd>.
132
+ 2. Click a note to read the preview; **Edit** to change it, then **Save** or
133
+ <kbd>Ctrl/Cmd+S</kbd>.
133
134
  3. To bring a note into the chat, press **⤴ Cite to chat** for the whole note, or
134
135
  select text and press **Ask** to attach a question to that passage.
135
136
  4. Paste a screenshot while editing to store it in the vault.
136
137
  5. Switch the dock to **🧠 Mind map** to explore the note's structure, or open
137
138
  **🕸 Graph view** above the tree to see the vault as a graph (**Local** = around
138
- the open note, **Depth 1–5**).
139
+ the open note, **Depth 1–5**; **Focus** = whole graph, camera on the open note).
139
140
 
140
141
  ## Features
141
142
 
142
143
  - **Vault sidebar** — lazy directory tree; open a note with one click, create,
143
144
  rename or delete notes and folders, and search titles **and** file contents.
144
145
  Shows a live note count.
145
- - **Line-numbered Obsidian preview** — every source line keeps its line number.
146
- Real tables with alignment, fenced code with language labels, nested lists and
147
- task lists, all heading levels, setext headings, horizontal rules, and
148
- clickable `[[wikilinks]]` (aliases and `#sections` included). Inline Obsidian
149
- styles render too: `==highlight==`, `~~strikethrough~~`, backslash escapes,
150
- hidden `%%comments%%`, and `<https://…>` / bare URLs as links.
146
+ - **Obsidian-style preview** — every source line renders as its own row, so
147
+ tables, fenced code, nested lists and callouts line up the way they do in
148
+ Obsidian. Line numbers stay out of the reading view and show only while you
149
+ edit. Real tables with alignment, fenced code with language labels, nested
150
+ lists and task lists, all heading levels, setext headings, horizontal rules,
151
+ and clickable `[[wikilinks]]` (aliases and `#sections` included). Inline
152
+ Obsidian styles render too: `==highlight==`, `~~strikethrough~~`, backslash
153
+ escapes, hidden `%%comments%%`, and `<https://…>` / bare URLs as links.
151
154
  - **Graph view** — a vault-wide relation graph built from every note's `[[links]]`
152
155
  and `#tags` (no graph file, no export step: it is computed from your Markdown on
153
156
  the fly, like Obsidian's own graph). A vault-level entry above the note tree
@@ -155,7 +158,8 @@ The default vault is `$DSH_WORKSPACE/obsidian_vault`, falling back to
155
158
  neighbours, click a note to open it in the dock. **Local** shows the
156
159
  neighbourhood of the note that is currently open; the depth is adjustable
157
160
  from 1 to 5 hops (same range as Obsidian's local graph depth slider, default
158
- 1), and **Focus current note** jumps from the vault view straight to it.
161
+ 1). **Focus** keeps the whole graph but frames the open note and its
162
+ neighbours — click it again to re-centre.
159
163
  - **Mind map view** — toggle the preview into a horizontal mind map built from the
160
164
  note's headings and list items. Scroll to zoom, drag to pan; select a node to
161
165
  jump to its source line, or insert that whole branch (node + descendants) into
@@ -287,7 +291,7 @@ Repository layout:
287
291
  | --- | --- |
288
292
  | `src/index.js` | Host half: `/obsidian/*` routes, path containment, attachment migration |
289
293
  | `src/client.js` | Browser half: sidebar, notes tree, preview dock, session/workspace UI |
290
- | `src/markdown.js` | Zero-dependency line-numbered Markdown renderer |
294
+ | `src/markdown.js` | Zero-dependency Markdown renderer (one row per source line) |
291
295
  | `src/vault-store.js` | Vault + attachment settings persistence |
292
296
  | `src/session-store.js` | Session log discovery and deletion |
293
297
  | `lib/client.js` | Build artifact (gitignored; produced by `npm run build`) |
package/README.zh.md CHANGED
@@ -18,16 +18,16 @@
18
18
 
19
19
  一个本地优先的 Markdown 笔记面板:在 DSH 侧边栏里读笔记,并把笔记顺手递进对话,不用复制粘贴。
20
20
 
21
- 1. **笔记就在对话旁边。** 指向一个 Markdown 文件夹,就有目录树、带行号的 Obsidian 风格预览、编辑保存,以及标题和正文搜索——不导入、不同步、不上云。
21
+ 1. **笔记就在对话旁边。** 指向一个 Markdown 文件夹,就有目录树、Obsidian 风格预览、编辑保存,以及标题和正文搜索——不导入、不同步、不上云。
22
22
  2. **「递进对话」本身就是功能。** 整篇引用为 `@绝对路径`;选中一段点 **Ask**,输入框里的 prompt 带着绝对路径、行号区间和原文,模型能准确引用你正在看的这一段。
23
23
  3. **截图直接进笔记库。** 编辑时粘贴图片会存进附件目录,并写成 Obsidian 原生的 `![[Pasted image …]]`;以后迁移附件目录,全库引用会一起改写。
24
- 4. **两种看结构的方式。** 把笔记切换成由标题和列表生成的**思维导图**;或打开由 `[[双链]]` 和 `#标签` 现算的**关系图谱**——全库视角,或聚焦当前笔记的 1–5 跳局部视角。
24
+ 4. **两种看结构的方式。** 把笔记切换成由标题和列表生成的**思维导图**;或打开由 `[[双链]]` 和 `#标签` 现算的**关系图谱**——全库视角、当前笔记周围 1–5 跳的局部视角,或保留全库、只把镜头对准当前笔记的聚焦视角。
25
25
 
26
26
  全部在本机运行:插件只读写你选择的文件夹,客户端只走宿主自己的 loopback 路由。
27
27
 
28
28
  ## 截图
29
29
 
30
- | 侧边栏笔记树 + 右侧预览 | Callout、行号、类型徽标 |
30
+ | 侧边栏笔记树 + 右侧预览 | Callout、任务列表、类型徽标 |
31
31
  | --- | --- |
32
32
  | ![侧边栏笔记树与右侧预览](https://raw.githubusercontent.com/revive123456/dsh-obsidian-notes/main/docs/assets/screenshot-sidebar.png) | ![彩色 Obsidian callout,一行源码对应一行渲染](https://raw.githubusercontent.com/revive123456/dsh-obsidian-notes/main/docs/assets/screenshot-callouts.png) |
33
33
 
@@ -35,7 +35,7 @@
35
35
  | --- | --- |
36
36
  | ![带列对齐的表格与 TypeScript 代码块](https://raw.githubusercontent.com/revive123456/dsh-obsidian-notes/main/docs/assets/screenshot-markdown.png) | ![带行号的编辑器与保存按钮](https://raw.githubusercontent.com/revive123456/dsh-obsidian-notes/main/docs/assets/screenshot-editing.png) |
37
37
 
38
- | 由标题 + 列表生成的思维导图 | 全库关系图谱(笔记、双链、标签) |
38
+ | 由标题 + 列表生成的思维导图 | 全库关系图谱——全库 / 局部 / 聚焦 |
39
39
  | --- | --- |
40
40
  | ![由笔记标题与列表生成的思维导图](https://raw.githubusercontent.com/revive123456/dsh-obsidian-notes/main/docs/assets/screenshot-mindmap.png) | ![全库关系图谱:圆圈是笔记、方块是标签、线是双链/标签关系](https://raw.githubusercontent.com/revive123456/dsh-obsidian-notes/main/docs/assets/screenshot-graph.png) |
41
41
 
@@ -101,7 +101,7 @@ dsh plugin --profile web add "$(pwd)" # PowerShell 用 "$PWD"
101
101
  ### 典型用法
102
102
 
103
103
  1. 打开 **📚 笔记**,先选一个 Vault(见上)。
104
- 2. 点击笔记读带行号的预览;点 **编辑** 修改,再点 **保存** 或 <kbd>Ctrl/Cmd+S</kbd>。
104
+ 2. 点击笔记读预览;点 **编辑** 修改,再点 **保存** 或 <kbd>Ctrl/Cmd+S</kbd>。
105
105
  3. 把笔记带进对话:整篇点 **⤴ 引用到对话**;选中一段点 **Ask**,把问题附着在那段文字上。
106
106
  4. 编辑时粘贴截图,会存进 Vault 的附件目录。
107
107
  5. 把 Dock 切成 **🧠 思维导图** 看笔记结构;或点笔记树上方的 **🕸 关系图谱** 看全库图(**局部** = 当前笔记周围,**跳数 1–5**)。
@@ -109,8 +109,8 @@ dsh plugin --profile web add "$(pwd)" # PowerShell 用 "$PWD"
109
109
  ## 功能
110
110
 
111
111
  - **Vault 侧边栏**:按需加载的目录树;单击打开笔记,新建 / 重命名 / 删除笔记与文件夹,按标题**和正文**搜索,显示笔记数徽标。
112
- - **带行号的 Obsidian 预览**:一行源码对应一行渲染并带行号。真表格(支持 `:---` / `:---:` / `---:` 对齐)、带语言标签的围栏代码、嵌套列表与任务列表、`#`~`######` 标题、setext 标题、分隔线、可点击的 `[[双链]]`(含别名与 `#章节`);行内也支持 `==高亮==`、`~~删除线~~`、反斜杠转义、隐藏的 `%%注释%%`,以及 `<https://…>` / 裸 URL 自动成链。
113
- - **关系图谱**:全库关系图,节点/连线直接由每篇笔记的 `[[双链]]` 与 `#标签` 现算(没有额外图谱文件,也不导出——和 Obsidian 自己的图谱一样从 Markdown 推导)。入口在笔记树上方的 vault 级「🕸 关系图谱」行,点击后在独立视图里显示;滚轮缩放、拖动平移、悬停高亮邻居、点笔记节点在 Dock 里打开。切到「局部」可看当前打开笔记的邻居,跳数可在 1–5 之间调(与 Obsidian 局部图谱的 depth 范围一致,默认 1);「聚焦当前笔记」可从全库一键切到该视角。
112
+ - **Obsidian 风格预览**:一行源码对应一行渲染,表格、围栏代码、嵌套列表和 callout 都按 Obsidian 的排版排好;只读预览不显示行号,行号只在编辑时出现。真表格(支持 `:---` / `:---:` / `---:` 对齐)、带语言标签的围栏代码、嵌套列表与任务列表、`#`~`######` 标题、setext 标题、分隔线、可点击的 `[[双链]]`(含别名与 `#章节`);行内也支持 `==高亮==`、`~~删除线~~`、反斜杠转义、隐藏的 `%%注释%%`,以及 `<https://…>` / 裸 URL 自动成链。
113
+ - **关系图谱**:全库关系图,节点/连线直接由每篇笔记的 `[[双链]]` 与 `#标签` 现算(没有额外图谱文件,也不导出——和 Obsidian 自己的图谱一样从 Markdown 推导)。入口在笔记树上方的 vault 级「🕸 关系图谱」行,点击后在独立视图里显示;滚轮缩放、拖动平移、悬停高亮邻居、点笔记节点在 Dock 里打开。切到「局部」可看当前打开笔记的邻居,跳数可在 1–5 之间调(与 Obsidian 局部图谱的 depth 范围一致,默认 1);「聚焦」保留全库,只把镜头对准当前笔记、把笔记和邻居框进画面,再点一次重新居中。
114
114
  - **思维导图视图**:把预览切换成由标题 + 列表生成的横向思维导图。滚轮缩放、拖动平移;选中节点可跳到对应源码行,或把「该分支(节点 + 全部后代)」作为上下文插入对话。纯渲染,不改动 Markdown 文件。
115
115
  - **Callout**:`> [!type] 标题` 按类型渲染颜色、图标与徽标。13 种规范类型(`note`、`abstract`、`info`、`todo`、`tip`、`success`、`question`、`warning`、`failure`、`danger`、`bug`、`example`、`quote`)+ 15 个 Obsidian 别名(`summary`、`tldr`、`hint`、`important`、`check`、`done`、`help`、`faq`、`caution`、`attention`、`fail`、`missing`、`error`、`cite`、`none`)。
116
116
  - **编辑与保存**:带行号的源码编辑器,点 `保存` 或按 <kbd>Ctrl/Cmd+S</kbd>。YAML frontmatter 不进编辑器,保存时原样拼回;外部对 YAML 的改动不会被覆盖。
@@ -184,7 +184,7 @@ npm run check:bundle # bundle 清单一致性(patch 文件、module id、fil
184
184
  | --- | --- |
185
185
  | `src/index.js` | host 半边:`/obsidian/*` 路由、路径包含性、附件迁移 |
186
186
  | `src/client.js` | 浏览器半边:侧边栏、笔记树、预览 Dock、会话/工作区 UI |
187
- | `src/markdown.js` | 零依赖、带行号的 Markdown 渲染器 |
187
+ | `src/markdown.js` | 零依赖、逐行渲染的 Markdown 渲染器 |
188
188
  | `src/vault-store.js` | Vault 与附件设置的持久化 |
189
189
  | `src/session-store.js` | 会话日志发现与删除 |
190
190
  | `lib/client.js` | 构建产物(gitignore;由 `npm run build` 生成) |
package/lib/client.js CHANGED
@@ -9,7 +9,8 @@ window.__ModuleLoader__.load({
9
9
  * 轻量 Markdown 渲染器(无第三方依赖,纯 React.createElement),两个出口、
10
10
  * 两套块级能力,共同复用行内解析 renderInline(粗斜体/行内代码/链接/图片/双链):
11
11
  *
12
- * 1) renderNumbered —— 笔记面板预览**实际使用**的渲染器(一行源码 = 一行 + 行号)。
12
+ * 1) renderNumbered —— 笔记面板预览**实际使用**的渲染器(一行源码 = 一行 + 行号节点;
13
+ * 面板只读态的 CSS 把这批行号收起来,编辑态的行号走 client 里的 editor gutter)。
13
14
  * 块级支持:引用(逐行、嵌套,不再吞行)、callout(类型/别名/标题,正文里的
14
15
  * 列表与标题)、代码围栏(```/~~~、info string、未闭合到文末,行号保留)、
15
16
  * 列表(无序/有序/嵌套,按实际缩进,tab=4)、任务列表(只读复选框)、
@@ -846,7 +847,8 @@ window.__ModuleLoader__.load({
846
847
  }
847
848
 
848
849
  /**
849
- * 行号预览模式(预览态「每行一个行标号」):
850
+ * 行号预览模式(每个源码行一个行号节点;面板只读态用 CSS 隐藏行号,节点本身
851
+ * 还要撑住空行行高、承载 data-line 供思维导图跳行):
850
852
  * 每个源码行渲染为一行 <div class="dsh-obs-md-row">,左侧行号 .dsh-obs-md-ln、
851
853
  * 内容 .dsh-obs-md-lc(行内 markdown 保留,且允许自然折行)。行号与源码行一一对应,
852
854
  * 不依赖固定宽度/不折行,长行折行也不影响后续行号对齐。选中映射(.dsh-obs-src 偏移)
@@ -1595,6 +1597,57 @@ window.__ModuleLoader__.load({
1595
1597
  }
1596
1598
 
1597
1599
 
1600
+ /**
1601
+ * 「聚焦」模式的镜头:全库不裁剪,只把视口对准某篇笔记和它的一跳邻居。
1602
+ *
1603
+ * 和 localSubgraph 的分工:局部是「只留这块节点」,聚焦是「节点都在,镜头对准这块」。
1604
+ * 输入是 layoutGraph 的布局坐标与画布像素尺寸,输出直接可用的 SVG 平移/缩放。
1605
+ *
1606
+ * @param plan - `layoutGraph()` 的返回值(nodes 带 x/y)。
1607
+ * @param edges - 与 `plan.nodes` 对应的连线(`[{ source, target }]`)。
1608
+ * @param focusId - 焦点节点 id(笔记节点是 `note:<rel>`)。
1609
+ * @param viewport - `{ width, height }` 画布像素尺寸。
1610
+ * @param options - `{ padding, maxScale }`:padding 为布局坐标里的留白(默认 80),
1611
+ * maxScale 限制推近倍数(默认 1.6,邻居很少时不至于糊脸)。
1612
+ * @returns `{ x, y, s }`;焦点不在布局里时返回 null(调用方退回整图适配)。
1613
+ */
1614
+ function focusViewport(plan, edges, focusId, viewport, options = {}) {
1615
+ const nodes = plan && Array.isArray(plan.nodes) ? plan.nodes : [];
1616
+ const center = nodes.find((node) => node.id === focusId);
1617
+ if (center === undefined) return null;
1618
+ const near = new Set([focusId]);
1619
+ for (const edge of edges ?? []) {
1620
+ if (edge.source === focusId) near.add(edge.target);
1621
+ else if (edge.target === focusId) near.add(edge.source);
1622
+ }
1623
+ let minX = center.x;
1624
+ let maxX = center.x;
1625
+ let minY = center.y;
1626
+ let maxY = center.y;
1627
+ for (const node of nodes) {
1628
+ if (!near.has(node.id)) continue;
1629
+ if (node.x < minX) minX = node.x;
1630
+ if (node.x > maxX) maxX = node.x;
1631
+ if (node.y < minY) minY = node.y;
1632
+ if (node.y > maxY) maxY = node.y;
1633
+ }
1634
+ const padding = options.padding ?? 80;
1635
+ const maxScale = options.maxScale ?? 1.6;
1636
+ const width = viewport && Number.isFinite(viewport.width) ? viewport.width : 900;
1637
+ const height = viewport && Number.isFinite(viewport.height) ? viewport.height : 560;
1638
+ // 单点邻居(焦点没有连线)时包围盒是 0×0,+padding 后仍可算;max(1) 只为除零兜底
1639
+ const boxW = Math.max(1, maxX - minX + padding * 2);
1640
+ const boxH = Math.max(1, maxY - minY + padding * 2);
1641
+ const raw = Math.min((width - 30) / boxW, (height - 30) / boxH, maxScale);
1642
+ const scale = Math.max(0.18, raw);
1643
+ return {
1644
+ x: width / 2 - ((minX + maxX) / 2) * scale,
1645
+ y: height / 2 - ((minY + maxY) / 2) * scale,
1646
+ s: scale,
1647
+ };
1648
+ }
1649
+
1650
+
1598
1651
  /** POSIX 风格拼接 + 折叠 `.`/`..`(客户端兜底构建时与 host 同一套链接解析)。 */
1599
1652
  function joinPosix(dir, rel) {
1600
1653
  const parts = dir === "" ? [] : dir.split("/");
@@ -1737,7 +1790,7 @@ window.__ModuleLoader__.load({
1737
1790
  return [...tags];
1738
1791
  }
1739
1792
 
1740
- return { layoutGraph, localSubgraph, adjacency, rng, resolveGraphLink, buildGraphFromNotes, extractTags };
1793
+ return { layoutGraph, localSubgraph, focusViewport, adjacency, rng, resolveGraphLink, buildGraphFromNotes, extractTags };
1741
1794
  })();
1742
1795
 
1743
1796
  /**
@@ -1865,7 +1918,7 @@ window.__ModuleLoader__.load({
1865
1918
  graphHint: "滚轮缩放 · 拖动平移 · 点击笔记打开 · 悬停看邻居",
1866
1919
  graphGlobal: "全库",
1867
1920
  graphLocal: "局部",
1868
- graphLocalHint: "以当前打开的笔记为中心显示 2 跳邻居",
1921
+ graphLocalHint: "以当前打开的笔记为中心显示邻居(跳数可调)",
1869
1922
  graphRefresh: "重新读取 vault",
1870
1923
  graphRerun: "重新布局",
1871
1924
  graphClose: "关闭图谱",
@@ -1882,7 +1935,7 @@ window.__ModuleLoader__.load({
1882
1935
  graphErrorTitle: "关系图谱加载失败",
1883
1936
  graphBuilding: "正在从笔记构建图谱({done}/{total})…",
1884
1937
  graphFocus: "聚焦",
1885
- graphFocusHint: "一键切到以当前打开笔记为中心的局部图谱(局部模式下再点 = 重新居中)",
1938
+ graphFocusHint: "把镜头对准当前打开笔记:保留全库,把笔记和它的一跳邻居框进画面(再点 = 重新居中)",
1886
1939
  graphLegendNote: "笔记",
1887
1940
  graphLegendTag: "标签",
1888
1941
  graphLegendFocus: "当前笔记",
@@ -2041,7 +2094,7 @@ window.__ModuleLoader__.load({
2041
2094
  graphHint: "Scroll to zoom · drag to pan · click a note to open it · hover to see neighbours",
2042
2095
  graphGlobal: "Vault",
2043
2096
  graphLocal: "Local",
2044
- graphLocalHint: "Show the 2-hop neighbourhood of the note that is open",
2097
+ graphLocalHint: "Show the open note's neighbourhood (depth adjustable)",
2045
2098
  graphRefresh: "Re-read the vault",
2046
2099
  graphRerun: "Re-run the layout",
2047
2100
  graphClose: "Close the graph",
@@ -2058,7 +2111,7 @@ window.__ModuleLoader__.load({
2058
2111
  graphErrorTitle: "The graph view failed to load",
2059
2112
  graphBuilding: "Building the graph from notes ({done}/{total})…",
2060
2113
  graphFocus: "Focus",
2061
- graphFocusHint: "Jump to the local graph centred on the open note (click again to re-centre)",
2114
+ graphFocusHint: "Aim the camera at the open note: keep the whole graph, frame the note and its neighbours (click again to re-centre)",
2062
2115
  graphLegendNote: "note",
2063
2116
  graphLegendTag: "tag",
2064
2117
  graphLegendFocus: "current note",
@@ -2583,6 +2636,13 @@ window.__ModuleLoader__.load({
2583
2636
  `.dsh-obs-md-row{display:flex;align-items:flex-start;gap:10px}`,
2584
2637
  `.dsh-obs-md-ln{flex:none;min-width:2ch;text-align:right;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.7;user-select:none;-webkit-user-select:none}`,
2585
2638
  `.dsh-obs-md-lc{flex:1;min-width:0}`,
2639
+ // 只读预览不显示行号。行号节点留在 DOM 里但收成零宽、visibility:hidden:
2640
+ // 一是空源码行还要靠它的行高撑住段落间距;二是 data-line 仍供思维导图跳行,
2641
+ // 选区反查脚本行号走的是 .dsh-obs-src 偏移,和行号节点无关。
2642
+ // 编辑态的行号走 .dsh-obs-editor-gutter,不受这三条影响。
2643
+ `.dsh-obs-dock-body .dsh-obs-md .dsh-obs-md-row{gap:0}`,
2644
+ `.dsh-obs-dock-body .dsh-obs-md .dsh-obs-md-ln{width:0;min-width:0;overflow:hidden;visibility:hidden}`,
2645
+ `.dsh-obs-dock-body .dsh-obs-md .dsh-obs-md-tln{display:none}`,
2586
2646
  `.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}`,
2587
2647
  `.dsh-obs-md-row.dsh-obs-md-heading .dsh-obs-md-lc{font-weight:600}`,
2588
2648
  // 行内 Obsidian 语法:==高亮== / ~~删除线~~ / 裸链接与自动链接
@@ -4529,7 +4589,8 @@ window.__ModuleLoader__.load({
4529
4589
 
4530
4590
  /**
4531
4591
  * 关系图谱视图:全库 .md + [[双链]] + 标签(host `/obsidian/graph` 计算,
4532
- * 力导向布局在 src/graph.js)。可切到「局部」= 当前打开笔记的 2 跳邻居。
4592
+ * 力导向布局在 src/graph.js)。「局部」= 当前打开笔记周围 1–5 跳邻居;
4593
+ * 「聚焦」保留全库,只把镜头对准当前笔记。
4533
4594
  */
4534
4595
  function GraphView({ t, currentNote, onOpenNote, onClose }) {
4535
4596
  const [data, setData] = useState(null);
@@ -4701,28 +4762,46 @@ window.__ModuleLoader__.load({
4701
4762
  const currentRel = currentNote && currentNote.rel ? currentNote.rel : null;
4702
4763
  const focusId = currentRel !== null ? `note:${currentRel}` : null;
4703
4764
  const localAvailable = data !== null && focusId !== null && data.nodes.some((node) => node.id === focusId);
4765
+ // 全库数据只包一次:全库 ↔ 聚焦之间切换不重跑力导向布局(同一 seed 结果一样,重跑一遍纯属浪费)
4766
+ const fullGraph = useMemo(
4767
+ () => (data === null ? { nodes: [], edges: [] } : { nodes: data.nodes, edges: data.edges }),
4768
+ [data],
4769
+ );
4704
4770
  const shown = useMemo(() => {
4705
- if (data === null) return { nodes: [], edges: [] };
4706
- if (mode === "local" && localAvailable) {
4771
+ if (mode === "local" && localAvailable && data !== null) {
4707
4772
  const sub = graphModule.localSubgraph(data, focusId, localDepth);
4708
4773
  return { nodes: sub.nodes, edges: sub.edges };
4709
4774
  }
4710
- return { nodes: data.nodes, edges: data.edges };
4711
- }, [data, mode, localAvailable, focusId, localDepth]);
4775
+ return fullGraph;
4776
+ }, [fullGraph, data, mode, localAvailable, focusId, localDepth]);
4712
4777
  const plan = useMemo(
4713
4778
  () => graphModule.layoutGraph(shown.nodes, shown.edges, { seed, maxNodes: 420 }),
4714
4779
  [shown, seed],
4715
4780
  );
4716
4781
 
4717
- // 打开 / 切换范围 / 重新布局后把整张图适配进画布
4782
+ // 打开 / 切换范围 / 重新布局后摆好镜头:全库与局部把整张图适配进画布;
4783
+ // 聚焦模式保留全库,但把镜头对准当前笔记:笔记和它的一跳邻居框进画面(不裁节点)。
4718
4784
  useEffect(() => {
4719
4785
  const el = wrapRef.current;
4720
4786
  if (!el || plan.nodes.length === 0) return;
4721
4787
  const w = el.clientWidth || 900;
4722
4788
  const h = el.clientHeight || 560;
4789
+ if (mode === "focus") {
4790
+ const framed = graphModule.focusViewport(plan, shown.edges, focusId, { width: w, height: h });
4791
+ if (framed !== null) {
4792
+ setTransform(framed);
4793
+ return;
4794
+ }
4795
+ }
4723
4796
  const fit = Math.min((w - 30) / plan.width, (h - 30) / plan.height, 1);
4724
4797
  setTransform({ x: (w - plan.width * fit) / 2, y: (h - plan.height * fit) / 2, s: fit });
4725
- }, [plan, fitKey]);
4798
+ }, [plan, fitKey, mode, focusId, shown.edges]);
4799
+
4800
+ // 当前笔记被关掉 / 从图里消失后,局部与聚焦都没有中心:退回全库,
4801
+ // 否则会留下一个「按不动却亮着」的分段按钮。
4802
+ useEffect(() => {
4803
+ if (!localAvailable && (mode === "local" || mode === "focus")) setMode("global");
4804
+ }, [localAvailable, mode]);
4726
4805
 
4727
4806
  const onWheel = useCallback((e) => {
4728
4807
  if (plan.nodes.length === 0) return;
@@ -4844,10 +4923,11 @@ window.__ModuleLoader__.load({
4844
4923
  disabled: !localAvailable,
4845
4924
  title: localAvailable ? t("graphLocalHint") : t("graphNoNote"),
4846
4925
  }),
4847
- modeButton(t("graphFocus"), false, () => {
4926
+ modeButton(t("graphFocus"), mode === "focus", () => {
4848
4927
  if (!localAvailable) return;
4849
- setMode("local");
4850
- setFitKey((v) => v + 1);
4928
+ // 聚焦是独立模式(全库图 + 镜头对准当前笔记):再点一次 = 重新居中
4929
+ if (mode === "focus") setFitKey((v) => v + 1);
4930
+ else setMode("focus");
4851
4931
  }, {
4852
4932
  disabled: !localAvailable,
4853
4933
  title: localAvailable ? t("graphFocusHint") : t("graphNoNote"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-obsidian-notes",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Obsidian-style notes panel for DeepSeek Harness Web: vault directory tree, Markdown preview/edit/save, cite-to-chat, batch archive/delete of sessions and workspaces. DSH 内嵌 Obsidian 风格笔记面板:目录树 + Markdown 预览/编辑/保存 + 引用到对话 + 会话/工作区批量操作。",
5
5
  "engines": {
6
6
  "node": "^22.19 || >=24"
package/src/client.js CHANGED
@@ -123,7 +123,7 @@
123
123
  graphHint: "滚轮缩放 · 拖动平移 · 点击笔记打开 · 悬停看邻居",
124
124
  graphGlobal: "全库",
125
125
  graphLocal: "局部",
126
- graphLocalHint: "以当前打开的笔记为中心显示 2 跳邻居",
126
+ graphLocalHint: "以当前打开的笔记为中心显示邻居(跳数可调)",
127
127
  graphRefresh: "重新读取 vault",
128
128
  graphRerun: "重新布局",
129
129
  graphClose: "关闭图谱",
@@ -140,7 +140,7 @@
140
140
  graphErrorTitle: "关系图谱加载失败",
141
141
  graphBuilding: "正在从笔记构建图谱({done}/{total})…",
142
142
  graphFocus: "聚焦",
143
- graphFocusHint: "一键切到以当前打开笔记为中心的局部图谱(局部模式下再点 = 重新居中)",
143
+ graphFocusHint: "把镜头对准当前打开笔记:保留全库,把笔记和它的一跳邻居框进画面(再点 = 重新居中)",
144
144
  graphLegendNote: "笔记",
145
145
  graphLegendTag: "标签",
146
146
  graphLegendFocus: "当前笔记",
@@ -299,7 +299,7 @@
299
299
  graphHint: "Scroll to zoom · drag to pan · click a note to open it · hover to see neighbours",
300
300
  graphGlobal: "Vault",
301
301
  graphLocal: "Local",
302
- graphLocalHint: "Show the 2-hop neighbourhood of the note that is open",
302
+ graphLocalHint: "Show the open note's neighbourhood (depth adjustable)",
303
303
  graphRefresh: "Re-read the vault",
304
304
  graphRerun: "Re-run the layout",
305
305
  graphClose: "Close the graph",
@@ -316,7 +316,7 @@
316
316
  graphErrorTitle: "The graph view failed to load",
317
317
  graphBuilding: "Building the graph from notes ({done}/{total})…",
318
318
  graphFocus: "Focus",
319
- graphFocusHint: "Jump to the local graph centred on the open note (click again to re-centre)",
319
+ graphFocusHint: "Aim the camera at the open note: keep the whole graph, frame the note and its neighbours (click again to re-centre)",
320
320
  graphLegendNote: "note",
321
321
  graphLegendTag: "tag",
322
322
  graphLegendFocus: "current note",
@@ -841,6 +841,13 @@
841
841
  `.dsh-obs-md-row{display:flex;align-items:flex-start;gap:10px}`,
842
842
  `.dsh-obs-md-ln{flex:none;min-width:2ch;text-align:right;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.7;user-select:none;-webkit-user-select:none}`,
843
843
  `.dsh-obs-md-lc{flex:1;min-width:0}`,
844
+ // 只读预览不显示行号。行号节点留在 DOM 里但收成零宽、visibility:hidden:
845
+ // 一是空源码行还要靠它的行高撑住段落间距;二是 data-line 仍供思维导图跳行,
846
+ // 选区反查脚本行号走的是 .dsh-obs-src 偏移,和行号节点无关。
847
+ // 编辑态的行号走 .dsh-obs-editor-gutter,不受这三条影响。
848
+ `.dsh-obs-dock-body .dsh-obs-md .dsh-obs-md-row{gap:0}`,
849
+ `.dsh-obs-dock-body .dsh-obs-md .dsh-obs-md-ln{width:0;min-width:0;overflow:hidden;visibility:hidden}`,
850
+ `.dsh-obs-dock-body .dsh-obs-md .dsh-obs-md-tln{display:none}`,
844
851
  `.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}`,
845
852
  `.dsh-obs-md-row.dsh-obs-md-heading .dsh-obs-md-lc{font-weight:600}`,
846
853
  // 行内 Obsidian 语法:==高亮== / ~~删除线~~ / 裸链接与自动链接
@@ -2787,7 +2794,8 @@
2787
2794
 
2788
2795
  /**
2789
2796
  * 关系图谱视图:全库 .md + [[双链]] + 标签(host `/obsidian/graph` 计算,
2790
- * 力导向布局在 src/graph.js)。可切到「局部」= 当前打开笔记的 2 跳邻居。
2797
+ * 力导向布局在 src/graph.js)。「局部」= 当前打开笔记周围 1–5 跳邻居;
2798
+ * 「聚焦」保留全库,只把镜头对准当前笔记。
2791
2799
  */
2792
2800
  function GraphView({ t, currentNote, onOpenNote, onClose }) {
2793
2801
  const [data, setData] = useState(null);
@@ -2959,28 +2967,46 @@
2959
2967
  const currentRel = currentNote && currentNote.rel ? currentNote.rel : null;
2960
2968
  const focusId = currentRel !== null ? `note:${currentRel}` : null;
2961
2969
  const localAvailable = data !== null && focusId !== null && data.nodes.some((node) => node.id === focusId);
2970
+ // 全库数据只包一次:全库 ↔ 聚焦之间切换不重跑力导向布局(同一 seed 结果一样,重跑一遍纯属浪费)
2971
+ const fullGraph = useMemo(
2972
+ () => (data === null ? { nodes: [], edges: [] } : { nodes: data.nodes, edges: data.edges }),
2973
+ [data],
2974
+ );
2962
2975
  const shown = useMemo(() => {
2963
- if (data === null) return { nodes: [], edges: [] };
2964
- if (mode === "local" && localAvailable) {
2976
+ if (mode === "local" && localAvailable && data !== null) {
2965
2977
  const sub = graphModule.localSubgraph(data, focusId, localDepth);
2966
2978
  return { nodes: sub.nodes, edges: sub.edges };
2967
2979
  }
2968
- return { nodes: data.nodes, edges: data.edges };
2969
- }, [data, mode, localAvailable, focusId, localDepth]);
2980
+ return fullGraph;
2981
+ }, [fullGraph, data, mode, localAvailable, focusId, localDepth]);
2970
2982
  const plan = useMemo(
2971
2983
  () => graphModule.layoutGraph(shown.nodes, shown.edges, { seed, maxNodes: 420 }),
2972
2984
  [shown, seed],
2973
2985
  );
2974
2986
 
2975
- // 打开 / 切换范围 / 重新布局后把整张图适配进画布
2987
+ // 打开 / 切换范围 / 重新布局后摆好镜头:全库与局部把整张图适配进画布;
2988
+ // 聚焦模式保留全库,但把镜头对准当前笔记:笔记和它的一跳邻居框进画面(不裁节点)。
2976
2989
  useEffect(() => {
2977
2990
  const el = wrapRef.current;
2978
2991
  if (!el || plan.nodes.length === 0) return;
2979
2992
  const w = el.clientWidth || 900;
2980
2993
  const h = el.clientHeight || 560;
2994
+ if (mode === "focus") {
2995
+ const framed = graphModule.focusViewport(plan, shown.edges, focusId, { width: w, height: h });
2996
+ if (framed !== null) {
2997
+ setTransform(framed);
2998
+ return;
2999
+ }
3000
+ }
2981
3001
  const fit = Math.min((w - 30) / plan.width, (h - 30) / plan.height, 1);
2982
3002
  setTransform({ x: (w - plan.width * fit) / 2, y: (h - plan.height * fit) / 2, s: fit });
2983
- }, [plan, fitKey]);
3003
+ }, [plan, fitKey, mode, focusId, shown.edges]);
3004
+
3005
+ // 当前笔记被关掉 / 从图里消失后,局部与聚焦都没有中心:退回全库,
3006
+ // 否则会留下一个「按不动却亮着」的分段按钮。
3007
+ useEffect(() => {
3008
+ if (!localAvailable && (mode === "local" || mode === "focus")) setMode("global");
3009
+ }, [localAvailable, mode]);
2984
3010
 
2985
3011
  const onWheel = useCallback((e) => {
2986
3012
  if (plan.nodes.length === 0) return;
@@ -3102,10 +3128,11 @@
3102
3128
  disabled: !localAvailable,
3103
3129
  title: localAvailable ? t("graphLocalHint") : t("graphNoNote"),
3104
3130
  }),
3105
- modeButton(t("graphFocus"), false, () => {
3131
+ modeButton(t("graphFocus"), mode === "focus", () => {
3106
3132
  if (!localAvailable) return;
3107
- setMode("local");
3108
- setFitKey((v) => v + 1);
3133
+ // 聚焦是独立模式(全库图 + 镜头对准当前笔记):再点一次 = 重新居中
3134
+ if (mode === "focus") setFitKey((v) => v + 1);
3135
+ else setMode("focus");
3109
3136
  }, {
3110
3137
  disabled: !localAvailable,
3111
3138
  title: localAvailable ? t("graphFocusHint") : t("graphNoNote"),
package/src/graph.js CHANGED
@@ -202,6 +202,57 @@ const graphModule = (() => {
202
202
  }
203
203
 
204
204
 
205
+ /**
206
+ * 「聚焦」模式的镜头:全库不裁剪,只把视口对准某篇笔记和它的一跳邻居。
207
+ *
208
+ * 和 localSubgraph 的分工:局部是「只留这块节点」,聚焦是「节点都在,镜头对准这块」。
209
+ * 输入是 layoutGraph 的布局坐标与画布像素尺寸,输出直接可用的 SVG 平移/缩放。
210
+ *
211
+ * @param plan - `layoutGraph()` 的返回值(nodes 带 x/y)。
212
+ * @param edges - 与 `plan.nodes` 对应的连线(`[{ source, target }]`)。
213
+ * @param focusId - 焦点节点 id(笔记节点是 `note:<rel>`)。
214
+ * @param viewport - `{ width, height }` 画布像素尺寸。
215
+ * @param options - `{ padding, maxScale }`:padding 为布局坐标里的留白(默认 80),
216
+ * maxScale 限制推近倍数(默认 1.6,邻居很少时不至于糊脸)。
217
+ * @returns `{ x, y, s }`;焦点不在布局里时返回 null(调用方退回整图适配)。
218
+ */
219
+ function focusViewport(plan, edges, focusId, viewport, options = {}) {
220
+ const nodes = plan && Array.isArray(plan.nodes) ? plan.nodes : [];
221
+ const center = nodes.find((node) => node.id === focusId);
222
+ if (center === undefined) return null;
223
+ const near = new Set([focusId]);
224
+ for (const edge of edges ?? []) {
225
+ if (edge.source === focusId) near.add(edge.target);
226
+ else if (edge.target === focusId) near.add(edge.source);
227
+ }
228
+ let minX = center.x;
229
+ let maxX = center.x;
230
+ let minY = center.y;
231
+ let maxY = center.y;
232
+ for (const node of nodes) {
233
+ if (!near.has(node.id)) continue;
234
+ if (node.x < minX) minX = node.x;
235
+ if (node.x > maxX) maxX = node.x;
236
+ if (node.y < minY) minY = node.y;
237
+ if (node.y > maxY) maxY = node.y;
238
+ }
239
+ const padding = options.padding ?? 80;
240
+ const maxScale = options.maxScale ?? 1.6;
241
+ const width = viewport && Number.isFinite(viewport.width) ? viewport.width : 900;
242
+ const height = viewport && Number.isFinite(viewport.height) ? viewport.height : 560;
243
+ // 单点邻居(焦点没有连线)时包围盒是 0×0,+padding 后仍可算;max(1) 只为除零兜底
244
+ const boxW = Math.max(1, maxX - minX + padding * 2);
245
+ const boxH = Math.max(1, maxY - minY + padding * 2);
246
+ const raw = Math.min((width - 30) / boxW, (height - 30) / boxH, maxScale);
247
+ const scale = Math.max(0.18, raw);
248
+ return {
249
+ x: width / 2 - ((minX + maxX) / 2) * scale,
250
+ y: height / 2 - ((minY + maxY) / 2) * scale,
251
+ s: scale,
252
+ };
253
+ }
254
+
255
+
205
256
  /** POSIX 风格拼接 + 折叠 `.`/`..`(客户端兜底构建时与 host 同一套链接解析)。 */
206
257
  function joinPosix(dir, rel) {
207
258
  const parts = dir === "" ? [] : dir.split("/");
@@ -344,5 +395,5 @@ const graphModule = (() => {
344
395
  return [...tags];
345
396
  }
346
397
 
347
- return { layoutGraph, localSubgraph, adjacency, rng, resolveGraphLink, buildGraphFromNotes, extractTags };
398
+ return { layoutGraph, localSubgraph, focusViewport, adjacency, rng, resolveGraphLink, buildGraphFromNotes, extractTags };
348
399
  })();
package/src/markdown.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * 轻量 Markdown 渲染器(无第三方依赖,纯 React.createElement),两个出口、
3
3
  * 两套块级能力,共同复用行内解析 renderInline(粗斜体/行内代码/链接/图片/双链):
4
4
  *
5
- * 1) renderNumbered —— 笔记面板预览**实际使用**的渲染器(一行源码 = 一行 + 行号)。
5
+ * 1) renderNumbered —— 笔记面板预览**实际使用**的渲染器(一行源码 = 一行 + 行号节点;
6
+ * 面板只读态的 CSS 把这批行号收起来,编辑态的行号走 client 里的 editor gutter)。
6
7
  * 块级支持:引用(逐行、嵌套,不再吞行)、callout(类型/别名/标题,正文里的
7
8
  * 列表与标题)、代码围栏(```/~~~、info string、未闭合到文末,行号保留)、
8
9
  * 列表(无序/有序/嵌套,按实际缩进,tab=4)、任务列表(只读复选框)、
@@ -839,7 +840,8 @@ const renderMarkdownModule = (() => {
839
840
  }
840
841
 
841
842
  /**
842
- * 行号预览模式(预览态「每行一个行标号」):
843
+ * 行号预览模式(每个源码行一个行号节点;面板只读态用 CSS 隐藏行号,节点本身
844
+ * 还要撑住空行行高、承载 data-line 供思维导图跳行):
843
845
  * 每个源码行渲染为一行 <div class="dsh-obs-md-row">,左侧行号 .dsh-obs-md-ln、
844
846
  * 内容 .dsh-obs-md-lc(行内 markdown 保留,且允许自然折行)。行号与源码行一一对应,
845
847
  * 不依赖固定宽度/不折行,长行折行也不影响后续行号对齐。选中映射(.dsh-obs-src 偏移)