dsh-book-reader 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.0] — 2026-10-20
8
+
9
+ ### Added
10
+
11
+ - View-state memory: the toolbar remembers the last page and zoom ratio per PDF
12
+ and restores them when the same book is reopened. The state is stored by the
13
+ Host in the `book_reader_views` storage domain through `ctx.storageDomain`
14
+ (schema-validated JSON under the DSH data directory), survives DSH restarts,
15
+ and degrades silently when that service is unavailable.
16
+ - New config key `rememberView` (default `true`).
17
+ - Host routes `GET /dsh-pdf-reader/view?path=…` and
18
+ `POST /dsh-pdf-reader/view` for the browser half.
19
+ - Docs: the README is split into a concise English `README.md` and a concise
20
+ Simplified Chinese `README.zh-CN.md`; they no longer share one page.
21
+
7
22
  ## [0.1.1] — 2026-10-18
8
23
 
9
24
  ### Changed
@@ -56,5 +71,6 @@ First public release.
56
71
  - `pdfjs-dist` is pinned to `6.3.289` to match the host process.
57
72
  - MIT licensed.
58
73
 
74
+ [0.2.0]: https://github.com/Strepsiades2/dsh-book-reader/releases/tag/v0.2.0
59
75
  [0.1.1]: https://github.com/Strepsiades2/dsh-book-reader/releases/tag/v0.1.1
60
76
  [0.1.0]: https://github.com/Strepsiades2/dsh-book-reader/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,101 +1,55 @@
1
1
  # dsh-book-reader
2
2
 
3
- Read books and papers in DSH: a reading toolbar on the official PDF preview, plus three PDF tools for the model.
3
+ A reading toolbar for DSH's official PDF preview, plus three PDF tools the model can use.
4
4
 
5
- **English** | [中文](#中文说明)
5
+ [中文说明](README.zh-CN.md)
6
6
 
7
7
  ## Features
8
8
 
9
- **Reading toolbar** — appears only on `.pdf` previews in the right sidebar; the stock previewer (zoom, selection, reload) is untouched.
9
+ **Reading toolbar** — only on `.pdf` previews in the right sidebar; the stock previewer is untouched.
10
10
 
11
- - Previous/next page, a page readout that follows scrolling, and a jump box that accepts printed page numbers (or `p152` for PDF page 152).
12
- - Full-text search panel (`Cmd/Ctrl+Shift+F`): click a result to jump there, or insert its page reference into the chat.
13
- - **AI position hint** (on by default): before a turn starts, the model may receive one short line with the page you are reading — at most once per turn, and only when the page changed since the last hint. Turn it off if you do not want the model to know your page automatically.
14
- - Every control has a hover/focus explanation in English or Chinese, following the DSH UI language.
11
+ - Previous/next page, a current-page readout, and a jump box (printed page numbers, or `p152` for PDF page 152).
12
+ - Full-text search with `Cmd/Ctrl+Shift+F`; click a result to jump, or insert its page reference into the chat.
13
+ - AI position hint: before each turn the model may receive one short “you are reading page N” line (the `AI●` toggle, on by default).
14
+ - **Remembers your place**: reopening a book restores the last page and zoom. State is stored by DSH per book and survives restarts.
15
+ - Hover/focus explanations on every control, in English or Chinese.
15
16
 
16
- **Model tools** — the model can look up the page map, search the text, or read chosen pages (as text or as page images) when you ask about a PDF.
17
+ **Model tools** — the model calls them when you ask about a PDF.
17
18
 
18
19
  | Tool | What it does |
19
20
  | --- | --- |
20
- | `pdf_info` | Page count, printed↔physical page offset, outline/TOC. Text only. |
21
- | `pdf_search` | Literal full-text search; returns page numbers and snippets. |
22
- | `pdf_read_pages` | Text of chosen pages, optionally as images (`include_image: false` for text only; images cost ~350–1024 tokens/page). |
23
-
24
- Typical loop: `pdf_info` → `pdf_search` → `pdf_read_pages` (text) → images only for formulas, diagrams, or tables.
21
+ | `pdf_info` | Page count, printed↔physical page offset, outline/TOC |
22
+ | `pdf_search` | Literal full-text search; page numbers and snippets |
23
+ | `pdf_read_pages` | Reads chosen pages as text, optionally with page images |
25
24
 
26
25
  ## Install
27
26
 
28
- - **From npm**: DSH **Plugins → Add plugin** → `dsh-book-reader` (or pin `dsh-book-reader@0.1.1`), or `dsh plugin add dsh-book-reader`.
29
- - A fresh install applies live (this profile has HMR); **upgrading a plugin that is already loaded needs a DSH restart** to load the new code.
30
- - **Optional skill** (gives the model the built-in PDF reading guide): symlink the package into the workspace:
31
- ```sh
32
- ln -sfn "<installed package dir>" "<workspace>/.dsh/skills/pdf-reader"
33
- ```
34
- - **Local checkout**: `dsh plugin add "/abs/path/to/dsh-book-reader"`; keep the checkout and its `node_modules/` in place.
27
+ 1. **Plugins → Add plugin** in DSH and enter `dsh-book-reader` (or run `dsh plugin add dsh-book-reader`).
28
+ 2. Restart DSH after installing or upgrading.
29
+ 3. Optional: give the model the reading guide by linking the skill into your workspace:
30
+
31
+ ```sh
32
+ ln -sfn "<installed package dir>" "<workspace>/.dsh/skills/pdf-reader"
33
+ ```
35
34
 
36
35
  ## Configuration
37
36
 
38
- Every option is optional and the defaults are safe. Common ones: `maxPagesPerCall` (default 4), `defaultScale` (2.5), `autoShare` (automatic position hint), `allowOutsideWorkspace` (tools stay inside the session workspace by default), `routeAllowedRoots` (restrict the local HTTP routes). The full list with defaults is commented in [`cordis.patch.yml`](cordis.patch.yml).
37
+ All keys are optional and the defaults are safe. Common ones:
38
+
39
+ - `rememberView` (default `true`) — remember page/zoom per book.
40
+ - `maxPagesPerCall` (default `4`) — page cap for `pdf_read_pages`.
41
+ - `defaultScale` (default `2.5`) — image render scale.
42
+ - `autoShare` (default `true`) — the AI position hint.
43
+
44
+ The full list with defaults is in [`cordis.patch.yml`](cordis.patch.yml).
39
45
 
40
46
  ## Notes
41
47
 
42
- - HTTP routes are loopback-only and unauthenticated; the tools are workspace-scoped by default. Other local preview plugins make the same tradeoff.
43
- - Works only with the official PDF preview: it relies on that preview's DOM markers, which a DSH upgrade may change.
44
- - Scanned PDFs have no text layer — there is no OCR, so only page images help.
48
+ - Only the official PDF preview is supported; the toolbar relies on that preview's private DOM markers, so a DSH upgrade may change behavior.
49
+ - Tools read only inside the session workspace unless `allowOutsideWorkspace` is set; the local HTTP routes are loopback-only and unauthenticated.
50
+ - Scanned PDFs have no text layer — page images are the only option (no OCR).
45
51
  - `pdfjs-dist` is pinned to `6.3.289` to match the host process.
46
52
 
47
53
  ## More
48
54
 
49
- - [`SKILL.md`](SKILL.md) — model-facing guide: costs, page numbering, pitfalls.
50
- - [`CHANGELOG.md`](CHANGELOG.md) · [`LICENSE`](LICENSE) (MIT)
51
-
52
- ---
53
-
54
- ## 中文说明
55
-
56
- 在 DSH 里读 PDF 书和论文:官方 PDF 预览上加一条读书工具栏,外加三个 PDF 工具给模型。
57
-
58
- ### 功能
59
-
60
- **阅读工具栏** —— 只在右侧栏的 `.pdf` 预览上出现;不替换官方预览器(缩放、选中、刷新都保持原样)。
61
-
62
- - 上一页/下一页、随滚动更新的页码读数、跳页框(输入印刷页码,或 `p152` 表示 PDF 第 152 页)。
63
- - 全文搜索面板(`Cmd/Ctrl+Shift+F`):点结果跳页,或把该页引用插入对话框。
64
- - **AI 位置提示**(默认开):每轮对话开始时,模型可能收到一行“你正在看第 N 页”的短提示 —— 每轮最多一次,且页码变了才发。关掉后,模型不会自动知道你在哪页。
65
- - 每个控件都有 hover / 键盘聚焦提示,跟随 DSH 界面语言(中文或英文)。
66
-
67
- **模型工具** —— 默认只花文本 token,图片是按需付的钱。
68
-
69
- | 工具 | 作用 |
70
- | --- | --- |
71
- | `pdf_info` | 页数、印刷页↔物理页偏移、目录/TOC;纯文本。 |
72
- | `pdf_search` | 字面全文搜索;返回页码和摘要。 |
73
- | `pdf_read_pages` | 选中页的文本,也可返回页面图片(`include_image: false` 只取文本;图片约 350–1024 token/页)。 |
74
-
75
- 典型流程:`pdf_info` → `pdf_search` → `pdf_read_pages`(文本)→ 只有公式、图表、表格才看图。
76
-
77
- ### 安装
78
-
79
- - **从 npm 安装**:DSH **Plugins → Add plugin** → 输入 `dsh-book-reader`(或锁定版本 `dsh-book-reader@0.1.1`),或命令行 `dsh plugin add dsh-book-reader`。
80
- - 全新安装即时生效(本 profile 已启用 HMR);**升级已加载的插件需要完整重启 DSH** 才能加载新代码。
81
- - **可选 skill**(给模型一份内置的 PDF 阅读指南):把包目录软链到工作区:
82
- ```sh
83
- ln -sfn "<已安装的包目录>" "<workspace>/.dsh/skills/pdf-reader"
84
- ```
85
- - **本地开发**:`dsh plugin add "/abs/path/to/dsh-book-reader"`;源码目录和 `node_modules/` 必须留在原处。
86
-
87
- ### 配置
88
-
89
- 全部可选,默认值即安全值。常用:`maxPagesPerCall`(默认 4)、`defaultScale`(2.5)、`autoShare`(自动位置提示)、`allowOutsideWorkspace`(工具默认只读会话工作区)、`routeAllowedRoots`(限制本地 HTTP 路由)。完整键和默认值见 [`cordis.patch.yml`](cordis.patch.yml) 注释。
90
-
91
- ### 说明
92
-
93
- - HTTP 路由只认回环地址且无鉴权;工具默认只在工作区内。其他本地预览插件也是同样的权衡。
94
- - 只支持官方 PDF 预览(依赖它的私有 DOM 标记,DSH 升级可能失效)。
95
- - 扫描件没有文本层,**没有 OCR**,只能看图。
96
- - `pdfjs-dist` 锁定 `6.3.289`,以和宿主进程保持一致。
97
-
98
- ### 更多
99
-
100
- - [`SKILL.md`](SKILL.md) —— 模型使用手册:成本、页码、常见陷阱。
101
- - [`CHANGELOG.md`](CHANGELOG.md) · [`LICENSE`](LICENSE)(MIT)
55
+ [`SKILL.md`](SKILL.md) — the model's reading guide · [`CHANGELOG.md`](CHANGELOG.md) · [`LICENSE`](LICENSE) (MIT)
@@ -0,0 +1,55 @@
1
+ # dsh-book-reader
2
+
3
+ 给 DSH 官方 PDF 预览加一条读书工具栏,并给模型三个 PDF 工具。
4
+
5
+ [English](README.md)
6
+
7
+ ## 功能
8
+
9
+ **阅读工具栏** —— 只在右侧栏的 `.pdf` 预览上出现,不替换官方预览器。
10
+
11
+ - 上一页/下一页、跟随滚动的页码读数、跳页框(印刷页码,或 `p152` 表示 PDF 第 152 页)。
12
+ - 全文搜索(`Cmd/Ctrl+Shift+F`):点结果跳页,或把该页引用插入对话框。
13
+ - AI 位置提示:每轮对话开始前,模型可能收到一行“你正在看第 N 页”的短提示(`AI●` 开关,默认开)。
14
+ - **记住阅读位置**:再次打开同一本书时恢复上次的页码和缩放;状态按书保存在 DSH 侧,重启后仍有效。
15
+ - 每个控件都有悬停/聚焦说明,跟随 DSH 界面语言(中文或英文)。
16
+
17
+ **模型工具** —— 你问 PDF 内容时由模型调用。
18
+
19
+ | 工具 | 作用 |
20
+ | --- | --- |
21
+ | `pdf_info` | 页数、印刷页↔物理页偏移、目录/TOC |
22
+ | `pdf_search` | 字面全文搜索;返回页码和摘要 |
23
+ | `pdf_read_pages` | 读取指定页的文本,也可返回页面图片 |
24
+
25
+ ## 安装
26
+
27
+ 1. DSH 的 **Plugins → Add plugin** 输入 `dsh-book-reader`(或命令行 `dsh plugin add dsh-book-reader`)。
28
+ 2. 安装或升级后重启 DSH。
29
+ 3. 可选:把 skill 软链到工作区,让模型拿到阅读流程说明:
30
+
31
+ ```sh
32
+ ln -sfn "<已安装的包目录>" "<workspace>/.dsh/skills/pdf-reader"
33
+ ```
34
+
35
+ ## 配置
36
+
37
+ 全部可选,默认值即安全值。常用:
38
+
39
+ - `rememberView`(默认 `true`)—— 按书记住页码/缩放。
40
+ - `maxPagesPerCall`(默认 `4`)—— 单次读页上限。
41
+ - `defaultScale`(默认 `2.5`)—— 图片渲染倍率。
42
+ - `autoShare`(默认 `true`)—— AI 位置提示。
43
+
44
+ 完整键和默认值见 [`cordis.patch.yml`](cordis.patch.yml) 注释。
45
+
46
+ ## 说明
47
+
48
+ - 只支持官方 PDF 预览;工具栏依赖该预览的私有 DOM 标记,DSH 升级可能影响行为。
49
+ - 工具默认只在会话工作区内读取(可设 `allowOutsideWorkspace`);本地 HTTP 路由只认回环地址、无鉴权。
50
+ - 扫描件没有文本层,只能看图(没有 OCR)。
51
+ - `pdfjs-dist` 锁定 `6.3.289`,以和宿主进程一致。
52
+
53
+ ## 更多
54
+
55
+ [`SKILL.md`](SKILL.md) —— 模型使用手册 · [`CHANGELOG.md`](CHANGELOG.md) · [`LICENSE`](LICENSE)(MIT)
package/cordis.patch.yml CHANGED
@@ -1,8 +1,9 @@
1
1
  # dsh-book-reader bundle patch: mount the plugin into the active profile.
2
2
  # Host half: pdf_info / pdf_search / pdf_read_pages tools,
3
- # /dsh-book-reader/* routes, and a minimal per-turn reading-position
4
- # line injected through agent/pre-step.
5
- # Browser half: page readout, jump, search panel, and share-to-chat on top of
3
+ # /dsh-pdf-reader/* routes (PDF info/search, reading position, saved
4
+ # page/zoom), and a minimal per-turn reading-position line injected
5
+ # through agent/pre-step.
6
+ # Browser half: page readout, jump, search panel, remembered page/zoom on top of
6
7
  # the official PDF preview's document-header slot.
7
8
  #
8
9
  # All config keys are optional. The defaults shown are the shipped behavior.
@@ -11,6 +12,7 @@
11
12
  name: 'dsh-book-reader'
12
13
  # config:
13
14
  # enabled: true
15
+ # rememberView: true # remember last page + zoom per book
14
16
  # autoShare: true # false stops position hints
15
17
  # autoShareSection: true # include the outline section in the line
16
18
  # autoShareMinIntervalMs: 0 # optional: min ms between hints (0 = no time throttle)
package/lib/client.js CHANGED
@@ -31,10 +31,14 @@ window.__ModuleLoader__.load({
31
31
  const INFO_ROUTE = '/dsh-pdf-reader/info'
32
32
  const SEARCH_ROUTE = '/dsh-pdf-reader/search'
33
33
  const CURRENT_ROUTE = '/dsh-pdf-reader/current'
34
+ const VIEW_ROUTE = '/dsh-pdf-reader/view'
34
35
 
35
36
  /** DOM markers owned by the official preview (same ones dsh-pdf-search uses). */
36
37
  const PANE_ATTR = 'data-textpreview-body'
37
38
  const SCROLL_ATTR = 'data-document-zoom-scrollport'
39
+ const ZOOM_FRAME_ATTR = 'data-document-zoom-frame'
40
+ const ZOOM_MODE_ATTR = 'data-document-zoom-mode'
41
+ const ZOOM_VAR = '--document-zoom'
38
42
  const PAGE_ATTR = 'data-pdf-page'
39
43
  const PDF_SECTION_ATTR = 'data-pdf-preview'
40
44
 
@@ -48,6 +52,28 @@ window.__ModuleLoader__.load({
48
52
  const SEARCH_LIMIT = 100
49
53
  const INFO_CACHE_LIMIT = 16
50
54
 
55
+ /**
56
+ * Reading-position memory timing.
57
+ *
58
+ * Restoring is best effort and must not block the preview: the saved view is
59
+ * fetched in parallel, then the structure/page markers are retried for about
60
+ * eight seconds. Saving starts only after restore finished (success or
61
+ * failure) so opening a book cannot overwrite its saved page with page 1.
62
+ * The save debounce is deliberately longer than the report debounce so a
63
+ * scroll settles before either write happens.
64
+ */
65
+ const VIEW_TIMEOUT_MS = 6_000
66
+ const VIEW_SAVE_DELAY_MS = 500
67
+ const VIEW_RETRY_MS = 200
68
+ const VIEW_RETRY_LIMIT = 40
69
+ const VIEW_REAPPLY_MS = 300
70
+ const VIEW_ZOOM_STEP_MS = 50
71
+ const VIEW_ZOOM_SETTLE_MS = 160
72
+ const VIEW_ZOOM_EVENT_LIMIT = 20
73
+ const VIEW_ZOOM_TOLERANCE = 0.02
74
+ const VIEW_ZOOM_MIN = 0.25
75
+ const VIEW_ZOOM_MAX = 4
76
+
51
77
  /** Non-PDF files are left entirely alone (Office previews have .docx paths). */
52
78
  function isPdfPath(value) {
53
79
  return typeof value === 'string' && /\.pdf$/iu.test(value)
@@ -267,6 +293,29 @@ window.__ModuleLoader__.load({
267
293
  return undefined
268
294
  }
269
295
 
296
+ /**
297
+ * Find the zoom wrapper that owns the preview scrollport.
298
+ *
299
+ * The PDF renderer nests the frame inside the preview body, so the frame is
300
+ * usually the scrollport's closest ancestor. A pane descendant is also
301
+ * checked so a renderer reshuffle degrades to "no zoom restore" instead of
302
+ * failing the page restore.
303
+ * @param pane - preview body.
304
+ * @param scrollport - inner scrolling element, when one exists.
305
+ * @returns the zoom frame, or `undefined` for a renderer without zoom.
306
+ */
307
+ function resolveZoomFrame(pane, scrollport) {
308
+ if (scrollport !== null && scrollport !== undefined && typeof scrollport.closest === 'function') {
309
+ const nearest = scrollport.closest(`[${ZOOM_FRAME_ATTR}]`)
310
+ if (isElement(nearest)) return nearest
311
+ }
312
+ if (pane !== null && pane !== undefined && typeof pane.querySelector === 'function') {
313
+ const nested = pane.querySelector(`[${ZOOM_FRAME_ATTR}]`)
314
+ if (isElement(nested)) return nested
315
+ }
316
+ return undefined
317
+ }
318
+
270
319
  /**
271
320
  * Scroll one page near the top of the preview.
272
321
  *
@@ -300,6 +349,118 @@ window.__ModuleLoader__.load({
300
349
  return false
301
350
  }
302
351
 
352
+ /**
353
+ * Ask the official preview to change zoom through its own gesture path.
354
+ *
355
+ * The preview implements zoom as a native `wheel` listener on the scrollport:
356
+ * it only reacts to `ctrlKey`, multiplies the current scale by
357
+ * `exp(-deltaY * 0.01)`, clamps `deltaY` to ±40, and commits the fixed
358
+ * preference 120 ms after the last event. Dispatching that synthetic event
359
+ * is therefore the only supported way for a plugin to set an arbitrary
360
+ * scale (0.25..4) without writing React state it does not own.
361
+ * @param scrollport - the preview's inner scrolling element.
362
+ * @param deltaY - wheel delta; negative zooms in.
363
+ * @returns whether an event was dispatched.
364
+ */
365
+ function dispatchZoomWheel(scrollport, deltaY) {
366
+ if (scrollport === null || scrollport === undefined || typeof scrollport.dispatchEvent !== 'function') {
367
+ return false
368
+ }
369
+ const view = scrollport.ownerDocument?.defaultView
370
+ const Wheel = view?.WheelEvent ?? (typeof WheelEvent === 'function' ? WheelEvent : undefined)
371
+ if (typeof Wheel !== 'function') return false
372
+ let event
373
+ try {
374
+ event = new Wheel('wheel', {
375
+ ctrlKey: true,
376
+ deltaY,
377
+ deltaMode: 0,
378
+ clientX: 0,
379
+ clientY: 0,
380
+ bubbles: true,
381
+ cancelable: true,
382
+ })
383
+ } catch (_error) {
384
+ return false
385
+ }
386
+ try {
387
+ scrollport.dispatchEvent(event)
388
+ } catch (_error) {
389
+ return false
390
+ }
391
+ return true
392
+ }
393
+
394
+ /**
395
+ * Clamp one zoom ratio into the range the preview and the Host agree on.
396
+ * @param value - number or CSS custom-property string.
397
+ * @returns a 3-decimal scale in 0.25..4, or `undefined` when unreadable.
398
+ */
399
+ function clampZoomScale(value) {
400
+ const numeric = typeof value === 'number' ? value : Number.parseFloat(String(value ?? ''))
401
+ if (!Number.isFinite(numeric)) return undefined
402
+ return Math.round(Math.min(VIEW_ZOOM_MAX, Math.max(VIEW_ZOOM_MIN, numeric)) * 1000) / 1000
403
+ }
404
+
405
+ /**
406
+ * Normalize one `GET /dsh-pdf-reader/view` payload.
407
+ *
408
+ * A missing row, a 404, and a malformed row all mean "nothing to restore";
409
+ * the Host owns validation, so the client only keeps values it can act on.
410
+ * @param body - parsed response body.
411
+ * @returns `{ pdfPage, zoomMode, zoomScale }`, or `undefined`.
412
+ */
413
+ function normalizeView(body) {
414
+ if (body === null || typeof body !== 'object' || body.ok !== true) return undefined
415
+ const view = body.view
416
+ if (view === null || typeof view !== 'object') return undefined
417
+ if (!positiveInteger(view.pdfPage)) return undefined
418
+ return {
419
+ pdfPage: view.pdfPage,
420
+ zoomMode: view.zoomMode === 'fixed' ? 'fixed' : 'fit-width',
421
+ zoomScale: clampZoomScale(view.zoomScale) ?? 1,
422
+ }
423
+ }
424
+
425
+ /** @returns the zoom mode the preview frame currently reports. */
426
+ function frameZoomMode(frame) {
427
+ if (frame === null || frame === undefined || typeof frame.getAttribute !== 'function') return 'fit-width'
428
+ return frame.getAttribute(ZOOM_MODE_ATTR) === 'fixed' ? 'fixed' : 'fit-width'
429
+ }
430
+
431
+ /**
432
+ * Read the effective zoom the preview frame currently shows.
433
+ *
434
+ * The renderer writes the resolved scale to the frame's inline
435
+ * `--document-zoom` (fit-width included), so the inline value is read first
436
+ * and the computed value only serves a frame whose style was not inlined.
437
+ * @param frame - zoom frame, when one exists.
438
+ * @returns a clamped, 3-decimal scale.
439
+ */
440
+ function frameZoomScale(frame) {
441
+ if (frame === null || frame === undefined || typeof frame.getAttribute !== 'function') return 1
442
+ let raw
443
+ try {
444
+ raw = typeof frame.style?.getPropertyValue === 'function'
445
+ ? frame.style.getPropertyValue(ZOOM_VAR)
446
+ : undefined
447
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
448
+ const view = frame.ownerDocument?.defaultView
449
+ if (view !== null && view !== undefined && typeof view.getComputedStyle === 'function') {
450
+ raw = view.getComputedStyle(frame).getPropertyValue(ZOOM_VAR)
451
+ }
452
+ }
453
+ } catch (_error) {
454
+ raw = undefined
455
+ }
456
+ return clampZoomScale(raw) ?? 1
457
+ }
458
+
459
+ /** @returns `{ zoomMode, zoomScale }` as the frame currently shows them. */
460
+ function readFrameZoom(frame) {
461
+ return { zoomMode: frameZoomMode(frame), zoomScale: frameZoomScale(frame) }
462
+ }
463
+
303
464
  /**
304
465
  * A cancellable trailing-edge debouncer.
305
466
  * @param delay - milliseconds.
@@ -805,6 +966,10 @@ window.__ModuleLoader__.load({
805
966
  (id) => globalThis.clearTimeout(id),
806
967
  )
807
968
  }
969
+ /** Latest sampled page, readable from the debounced view save. */
970
+ const pageRef = useRef(1)
971
+ /** Installed by the view effect; `null` when this preview keeps no view. */
972
+ const requestViewSaveRef = useRef(null)
808
973
 
809
974
  const [page, setPage] = useState(1)
810
975
  const [sampled, setSampled] = useState(false)
@@ -983,6 +1148,7 @@ window.__ModuleLoader__.load({
983
1148
  setSearching(false)
984
1149
  setSearchError(null)
985
1150
  lastReportedKeyRef.current = undefined
1151
+ pageRef.current = 1
986
1152
  reportAbortRef.current?.abort()
987
1153
  if (focusTimerRef.current !== null) {
988
1154
  globalThis.clearTimeout(focusTimerRef.current)
@@ -1254,6 +1420,347 @@ window.__ModuleLoader__.load({
1254
1420
  }
1255
1421
  }, [isPdf, absolutePath])
1256
1422
 
1423
+ // Restore the last page/zoom for this book, then persist both best-effort.
1424
+ //
1425
+ // The official preview keeps neither across tabs, so this owns one restore
1426
+ // per absolutePath: fetch the saved view in parallel with rendering, wait
1427
+ // (up to ~8 s) for the preview structure and the target page, drive the
1428
+ // renderer's own Ctrl+wheel path for a fixed zoom, and only then allow
1429
+ // saving. The gate matters: saving before the restore lands would replace
1430
+ // the saved page with page 1 every time a book is opened.
1431
+ useEffect(() => {
1432
+ if (!isPdf || absolutePath === undefined) return undefined
1433
+ const host = hostRef.current
1434
+ if (host === null) return undefined
1435
+ const pdfPath = absolutePath
1436
+
1437
+ let disposed = false
1438
+ let finished = false
1439
+ let pane
1440
+ let scrollport
1441
+ let frame
1442
+ let attributeObserver
1443
+ let structureObserver
1444
+ let retryTimer
1445
+ let retryCount = 0
1446
+ let reapplyTimer
1447
+ let zoomTimer
1448
+ let zoomEvents = 0
1449
+ let restoreTarget
1450
+ let lastAttemptKey = ''
1451
+ const restoreController = new AbortController()
1452
+ const saveController = new AbortController()
1453
+
1454
+ const saveDebouncer = createDebouncer(
1455
+ VIEW_SAVE_DELAY_MS,
1456
+ (fn, ms) => globalThis.setTimeout(fn, ms),
1457
+ (id) => globalThis.clearTimeout(id),
1458
+ )
1459
+ const structureDebouncer = createDebouncer(
1460
+ 250,
1461
+ (fn, ms) => globalThis.setTimeout(fn, ms),
1462
+ (id) => globalThis.clearTimeout(id),
1463
+ )
1464
+
1465
+ /**
1466
+ * Persist the current page and zoom. The four client fields are the whole
1467
+ * contract; the Host stamps `updatedAt`. Identical consecutive payloads
1468
+ * are suppressed, and a failed write is retried by the next trigger.
1469
+ */
1470
+ const saveNow = () => {
1471
+ if (disposed || !finished) return
1472
+ if (typeof fetch !== 'function') return
1473
+ // A debounced save is also a cheap reconnect point: if the preview
1474
+ // replaced its structure, resolve it again before reading the frame.
1475
+ refresh()
1476
+ const zoom = readFrameZoom(frame)
1477
+ const payload = {
1478
+ path: pdfPath,
1479
+ pdfPage: positiveInteger(pageRef.current) ? pageRef.current : 1,
1480
+ zoomMode: zoom.zoomMode,
1481
+ zoomScale: zoom.zoomScale,
1482
+ }
1483
+ const key = JSON.stringify(payload)
1484
+ if (key === lastAttemptKey) return
1485
+ lastAttemptKey = key
1486
+ let request
1487
+ try {
1488
+ request = fetch(VIEW_ROUTE, {
1489
+ method: 'POST',
1490
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
1491
+ body: JSON.stringify(payload),
1492
+ signal: saveController.signal,
1493
+ keepalive: true,
1494
+ })
1495
+ } catch (_error) {
1496
+ lastAttemptKey = ''
1497
+ return
1498
+ }
1499
+ Promise.resolve(request)
1500
+ .then((response) => {
1501
+ if (disposed) return
1502
+ if (response !== null && response !== undefined && response.ok === true) return
1503
+ if (lastAttemptKey === key) lastAttemptKey = ''
1504
+ })
1505
+ .catch(() => {
1506
+ if (disposed) return
1507
+ if (lastAttemptKey === key) lastAttemptKey = ''
1508
+ })
1509
+ }
1510
+
1511
+ const requestSave = () => {
1512
+ if (disposed) return
1513
+ saveDebouncer.schedule(saveNow)
1514
+ }
1515
+ requestViewSaveRef.current = requestSave
1516
+
1517
+ /** Allow saving once, including when the restore failed or timed out. */
1518
+ const finish = () => {
1519
+ if (disposed || finished) return
1520
+ finished = true
1521
+ requestSave()
1522
+ }
1523
+
1524
+ const detach = () => {
1525
+ attributeObserver?.disconnect()
1526
+ attributeObserver = undefined
1527
+ structureObserver?.disconnect()
1528
+ structureObserver = undefined
1529
+ }
1530
+
1531
+ const observeStructure = () => {
1532
+ structureObserver?.disconnect()
1533
+ structureObserver = undefined
1534
+ if (typeof MutationObserver !== 'function') return
1535
+ const root = pane?.parentElement ?? host.parentElement ?? pane ?? host
1536
+ if (root === null || root === undefined) return
1537
+ structureObserver = new MutationObserver(() => {
1538
+ if (disposed) return
1539
+ structureDebouncer.schedule(syncStructure)
1540
+ })
1541
+ structureObserver.observe(root, { childList: true, subtree: true })
1542
+ }
1543
+
1544
+ const observeFrame = (target) => {
1545
+ attributeObserver?.disconnect()
1546
+ attributeObserver = undefined
1547
+ if (target === null || target === undefined) return
1548
+ if (typeof MutationObserver !== 'function') return
1549
+ attributeObserver = new MutationObserver(() => {
1550
+ if (disposed) return
1551
+ requestSave()
1552
+ })
1553
+ attributeObserver.observe(target, {
1554
+ attributes: true,
1555
+ attributeFilter: [ZOOM_MODE_ATTR, 'style'],
1556
+ subtree: false,
1557
+ })
1558
+ }
1559
+
1560
+ const bind = (nextPane, nextScrollport, nextFrame) => {
1561
+ attributeObserver?.disconnect()
1562
+ attributeObserver = undefined
1563
+ structureObserver?.disconnect()
1564
+ structureObserver = undefined
1565
+ pane = nextPane
1566
+ scrollport = nextScrollport
1567
+ frame = nextFrame
1568
+ observeFrame(frame)
1569
+ observeStructure()
1570
+ }
1571
+
1572
+ const syncStructure = () => {
1573
+ if (disposed) return
1574
+ const nextPane = resolvePane(host)
1575
+ if (nextPane === undefined) return
1576
+ const nextScrollport = resolveScrollport(nextPane)
1577
+ const nextFrame = resolveZoomFrame(nextPane, nextScrollport)
1578
+ if (nextPane !== pane || nextScrollport !== scrollport || nextFrame !== frame) {
1579
+ bind(nextPane, nextScrollport, nextFrame)
1580
+ }
1581
+ }
1582
+
1583
+ /** Re-resolve the preview structure; true when a body exists. */
1584
+ const refresh = () => {
1585
+ const nextPane = resolvePane(host)
1586
+ if (nextPane === undefined) return false
1587
+ const nextScrollport = resolveScrollport(nextPane)
1588
+ const nextFrame = resolveZoomFrame(nextPane, nextScrollport)
1589
+ if (nextPane !== pane || nextScrollport !== scrollport || nextFrame !== frame) {
1590
+ bind(nextPane, nextScrollport, nextFrame)
1591
+ }
1592
+ return true
1593
+ }
1594
+
1595
+ const retryRestore = () => {
1596
+ if (disposed || finished || retryTimer !== undefined) return
1597
+ if (retryCount >= VIEW_RETRY_LIMIT) {
1598
+ finish()
1599
+ return
1600
+ }
1601
+ retryCount += 1
1602
+ retryTimer = globalThis.setTimeout(() => {
1603
+ retryTimer = undefined
1604
+ if (!disposed) stepRestore()
1605
+ }, VIEW_RETRY_MS)
1606
+ }
1607
+
1608
+ /**
1609
+ * Scroll the saved page into view, then re-apply once. The second scroll
1610
+ * absorbs pages that were 3:4 placeholders and grew when lazily rendered.
1611
+ */
1612
+ const placePage = () => {
1613
+ if (disposed || restoreTarget === undefined) return
1614
+ const target = restoreTarget.pdfPage
1615
+ if (!refresh() || !scrollPageIntoView(pane, scrollport, target)) {
1616
+ retryRestore()
1617
+ return
1618
+ }
1619
+ // The save debounce reads this ref, and the restore runs from a timer
1620
+ // (not a React event), so publish the page before state can render.
1621
+ pageRef.current = target
1622
+ setPage(target)
1623
+ setSampled(true)
1624
+ finish()
1625
+ if (reapplyTimer !== undefined) globalThis.clearTimeout(reapplyTimer)
1626
+ reapplyTimer = globalThis.setTimeout(() => {
1627
+ reapplyTimer = undefined
1628
+ if (disposed || !refresh()) return
1629
+ scrollPageIntoView(pane, scrollport, target)
1630
+ }, VIEW_REAPPLY_MS)
1631
+ }
1632
+
1633
+ /** Let the renderer's 120 ms zoom settlement commit before scrolling. */
1634
+ const settleZoom = () => {
1635
+ if (zoomTimer !== undefined) globalThis.clearTimeout(zoomTimer)
1636
+ zoomTimer = globalThis.setTimeout(() => {
1637
+ zoomTimer = undefined
1638
+ if (!disposed) placePage()
1639
+ }, VIEW_ZOOM_SETTLE_MS)
1640
+ }
1641
+
1642
+ /**
1643
+ * Drive Ctrl+wheel events until the frame scale is within 2% of the saved
1644
+ * fixed scale. Each event can change the ratio by at most ~1.49x, so a
1645
+ * large jump needs a few events; the event budget bounds a missing or
1646
+ * non-responsive handler.
1647
+ */
1648
+ const stepZoom = () => {
1649
+ if (disposed || restoreTarget === undefined) return
1650
+ const target = restoreTarget.zoomScale
1651
+ if (!refresh() || frame === undefined || scrollport === undefined) {
1652
+ placePage()
1653
+ return
1654
+ }
1655
+ const current = frameZoomScale(frame)
1656
+ if (Math.abs(current / target - 1) <= VIEW_ZOOM_TOLERANCE) {
1657
+ if (frameZoomMode(frame) !== 'fixed') {
1658
+ // Same scale, fit-width mode: a zero-delta gesture only asks the
1659
+ // renderer to commit `{kind:'fixed'}` at the current scale.
1660
+ if (!dispatchZoomWheel(scrollport, 0)) {
1661
+ placePage()
1662
+ return
1663
+ }
1664
+ }
1665
+ // The renderer commits its fixed preference 120 ms after the last
1666
+ // gesture and only then re-renders pages at the new scale.
1667
+ settleZoom()
1668
+ return
1669
+ }
1670
+ if (zoomEvents >= VIEW_ZOOM_EVENT_LIMIT) {
1671
+ settleZoom()
1672
+ return
1673
+ }
1674
+ const deltaY = Math.max(-40, Math.min(40, -100 * Math.log(target / current)))
1675
+ if (!Number.isFinite(deltaY) || Math.abs(deltaY) < 0.05) {
1676
+ settleZoom()
1677
+ return
1678
+ }
1679
+ zoomEvents += 1
1680
+ if (!dispatchZoomWheel(scrollport, deltaY)) {
1681
+ settleZoom()
1682
+ return
1683
+ }
1684
+ zoomTimer = globalThis.setTimeout(() => {
1685
+ zoomTimer = undefined
1686
+ if (!disposed) stepZoom()
1687
+ }, VIEW_ZOOM_STEP_MS)
1688
+ }
1689
+
1690
+ const stepRestore = () => {
1691
+ if (disposed || finished || restoreTarget === undefined) return
1692
+ const target = restoreTarget
1693
+ if (!refresh()) {
1694
+ retryRestore()
1695
+ return
1696
+ }
1697
+ const page = pane.querySelector(`[${PAGE_ATTR}="${target.pdfPage}"]`)
1698
+ if (!isElement(page)) {
1699
+ retryRestore()
1700
+ return
1701
+ }
1702
+ if (target.zoomMode === 'fixed' && frame !== undefined && scrollport !== undefined) {
1703
+ if (Math.abs(frameZoomScale(frame) / target.zoomScale - 1) > VIEW_ZOOM_TOLERANCE
1704
+ || frameZoomMode(frame) !== 'fixed') {
1705
+ stepZoom()
1706
+ return
1707
+ }
1708
+ }
1709
+ placePage()
1710
+ }
1711
+
1712
+ const loadSavedView = async () => {
1713
+ const timer = globalThis.setTimeout(() => {
1714
+ restoreController.abort()
1715
+ }, VIEW_TIMEOUT_MS)
1716
+ try {
1717
+ const { body } = await fetchJson(
1718
+ `${VIEW_ROUTE}?path=${encodeURIComponent(pdfPath)}`,
1719
+ { headers: { accept: 'application/json' }, signal: restoreController.signal },
1720
+ )
1721
+ if (disposed) return
1722
+ const saved = normalizeView(body)
1723
+ if (saved === undefined) {
1724
+ finish()
1725
+ return
1726
+ }
1727
+ restoreTarget = saved
1728
+ stepRestore()
1729
+ } catch (_error) {
1730
+ if (disposed) return
1731
+ // A missing route, a network failure, or a timeout must not block
1732
+ // saving forever: the restore is best effort.
1733
+ finish()
1734
+ } finally {
1735
+ globalThis.clearTimeout(timer)
1736
+ }
1737
+ }
1738
+
1739
+ // Observe the current structure while the GET is in flight, so a zoom the
1740
+ // reader performs before the response still gets saved once the gate opens.
1741
+ refresh()
1742
+ void loadSavedView()
1743
+
1744
+ return () => {
1745
+ disposed = true
1746
+ restoreController.abort()
1747
+ saveController.abort()
1748
+ saveDebouncer.cancel()
1749
+ structureDebouncer.cancel()
1750
+ if (retryTimer !== undefined) globalThis.clearTimeout(retryTimer)
1751
+ if (reapplyTimer !== undefined) globalThis.clearTimeout(reapplyTimer)
1752
+ if (zoomTimer !== undefined) globalThis.clearTimeout(zoomTimer)
1753
+ detach()
1754
+ if (requestViewSaveRef.current === requestSave) requestViewSaveRef.current = null
1755
+ }
1756
+ }, [isPdf, absolutePath])
1757
+
1758
+ // Save the sampled page through the same 500 ms gate as zoom changes.
1759
+ useEffect(() => {
1760
+ pageRef.current = page
1761
+ if (sampled) requestViewSaveRef.current?.()
1762
+ }, [page, sampled])
1763
+
1257
1764
  // Debounced AI-context reporting. One timer, one in-flight POST, abort on
1258
1765
  // page change, toggle-off, path change, or unmount.
1259
1766
  useEffect(() => {
@@ -1745,25 +2252,30 @@ window.__ModuleLoader__.load({
1745
2252
  PANEL_STYLE,
1746
2253
  buildSearchReference,
1747
2254
  cachedInfo,
2255
+ clampZoomScale,
1748
2256
  copyText,
1749
2257
  createDebouncer,
1750
2258
  createRafThrottle,
1751
2259
  describeSearchFailure,
1752
2260
  displayPage,
2261
+ dispatchZoomWheel,
1753
2262
  fetchJson,
1754
2263
  infoCache,
1755
2264
  insertComposerText,
1756
2265
  isPdfPath,
1757
2266
  normalizeInfo,
1758
2267
  normalizeSnippet,
2268
+ normalizeView,
1759
2269
  pageElements,
1760
2270
  parsePageInput,
1761
2271
  pickPage,
1762
2272
  positiveInteger,
1763
2273
  printedPageOf,
2274
+ readFrameZoom,
1764
2275
  rememberInfo,
1765
2276
  resolvePane,
1766
2277
  resolveScrollport,
2278
+ resolveZoomFrame,
1767
2279
  scrollPageIntoView,
1768
2280
  searchResultLabel,
1769
2281
  searchResultPage,
package/lib/index.js CHANGED
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { basename, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
22
+ import { createHash } from 'node:crypto'
22
23
  import { appendFileSync } from 'node:fs'
23
24
  import { stat } from 'node:fs/promises'
24
25
  import z from '@deepseek-ai/schemastery'
@@ -51,6 +52,8 @@ const PAGE_SELECTION_HARD_LIMIT = 256
51
52
  export const Config = z.object({
52
53
  /** Master switch: false mounts no tool, route, or listener. */
53
54
  enabled: z.boolean().default(true),
55
+ /** Remember page/zoom per PDF and restore on reopen. */
56
+ rememberView: z.boolean().default(true),
54
57
  /** Inject the current reading page into `agent/pre-step`, at most once per turn. */
55
58
  autoShare: z.boolean().default(true),
56
59
  /** Resolve the outline section for the injected message (one cached getInfo). */
@@ -94,6 +97,7 @@ export const Config = z.object({
94
97
  /** Defaults applied independently of the loader, so direct callers work too. */
95
98
  export const DEFAULT_CONFIG = Object.freeze({
96
99
  enabled: true,
100
+ rememberView: true,
97
101
  autoShare: true,
98
102
  autoShareSection: true,
99
103
  autoShareMinIntervalMs: 0,
@@ -166,6 +170,7 @@ export function normalizeConfig(raw) {
166
170
  if (typeof source !== 'object' || Array.isArray(source)) throw new TypeError('pdf-reader: config must be an object')
167
171
  return {
168
172
  enabled: configBoolean(source.enabled, DEFAULT_CONFIG.enabled, 'enabled'),
173
+ rememberView: configBoolean(source.rememberView, DEFAULT_CONFIG.rememberView, 'rememberView'),
169
174
  autoShare: configBoolean(source.autoShare, DEFAULT_CONFIG.autoShare, 'autoShare'),
170
175
  autoShareSection: configBoolean(source.autoShareSection, DEFAULT_CONFIG.autoShareSection, 'autoShareSection'),
171
176
  autoShareMinIntervalMs: configInteger(
@@ -416,6 +421,233 @@ export function createPositionStore({ now = Date.now, maxSessions = 200, maxPath
416
421
  }
417
422
  }
418
423
 
424
+ // ---------------------------------------------------------------------------
425
+ // Remembered page/zoom view state
426
+ // ---------------------------------------------------------------------------
427
+
428
+ /** Durable storage-domain name for remembered book views. */
429
+ export const VIEW_DOMAIN_NAME = 'book_reader_views'
430
+
431
+ /** Format version of {@link VIEW_DOMAIN_NAME}; bump only with a migration. */
432
+ export const VIEW_DOMAIN_VERSION = 1
433
+
434
+ /** Table holding one remembered view per book inside {@link VIEW_DOMAIN_NAME}. */
435
+ export const VIEW_TABLE_NAME = 'views'
436
+
437
+ /** Hard cap on remembered books; the oldest records are pruned past this. */
438
+ export const MAX_VIEW_RECORDS = 500
439
+
440
+ /** Longest accepted book path in a `/view` request. */
441
+ export const MAX_VIEW_PATH_LENGTH = 4096
442
+
443
+ /** Hard ceiling for a remembered PDF page number. */
444
+ export const MAX_VIEW_PAGE = 100_000
445
+
446
+ /** The only accepted zoom modes (mirrors the client's toolbar). */
447
+ export const VIEW_ZOOM_MODES = Object.freeze(['fit-width', 'fixed'])
448
+
449
+ /** Smallest accepted zoom ratio. */
450
+ export const VIEW_ZOOM_SCALE_MIN = 0.25
451
+
452
+ /** Largest accepted zoom ratio. */
453
+ export const VIEW_ZOOM_SCALE_MAX = 4
454
+
455
+ /**
456
+ * Stable storage key for one book: sha256 hex of the absolute path.
457
+ * @param path - normalized absolute `.pdf` path.
458
+ * @returns 64 lowercase hex characters.
459
+ */
460
+ export function viewKeyForPath(path) {
461
+ return createHash('sha256').update(path, 'utf8').digest('hex')
462
+ }
463
+
464
+ /**
465
+ * Declarative zod schema for one remembered view.
466
+ *
467
+ * The schema is the durable-boundary validator of the storage domain. The
468
+ * HTTP route validates requests itself, so this stays purely declarative and
469
+ * needs no zod call at request time.
470
+ * @param zod - the dynamically imported zod module namespace.
471
+ * @returns a zod object schema matching the `/view` record contract.
472
+ */
473
+ export function viewRecordSchema(zod) {
474
+ return zod.object({
475
+ path: zod.string().min(1).max(MAX_VIEW_PATH_LENGTH),
476
+ pdfPage: zod.number().int().min(1).max(MAX_VIEW_PAGE),
477
+ zoomMode: zod.enum([...VIEW_ZOOM_MODES]),
478
+ zoomScale: zod.number().min(VIEW_ZOOM_SCALE_MIN).max(VIEW_ZOOM_SCALE_MAX),
479
+ updatedAt: zod.number().int().nonnegative(),
480
+ })
481
+ }
482
+
483
+ /**
484
+ * Build the `book_reader_views` domain declaration.
485
+ *
486
+ * `zod` and the domain helpers are imported dynamically so this module still
487
+ * loads where the storage packages are absent; callers must handle rejection.
488
+ * @returns the domain spec understood by `ctx.storageDomain.open`.
489
+ */
490
+ export async function createViewDomainSpec() {
491
+ const [{ defineDomain, domainTable }, { z: zod }] = await Promise.all([
492
+ import('@deepseek-ai/dsh-storage-domain'),
493
+ import('zod'),
494
+ ])
495
+ return defineDomain({
496
+ name: VIEW_DOMAIN_NAME,
497
+ version: VIEW_DOMAIN_VERSION,
498
+ layout: 'single',
499
+ tables: {
500
+ [VIEW_TABLE_NAME]: domainTable(viewRecordSchema(zod)),
501
+ },
502
+ })
503
+ }
504
+
505
+ /**
506
+ * Open the remembered-view domain through the injected `storageDomain` service.
507
+ * @param storageDomain - the `ctx.storageDomain` facility (DomainFacility).
508
+ * @returns the opened domain handle; the caller owns `close()`.
509
+ * @throws when the service, the modules, or `open` itself are unavailable.
510
+ */
511
+ export async function openViewStateDomain(storageDomain) {
512
+ if (storageDomain === null || typeof storageDomain !== 'object' || typeof storageDomain.open !== 'function') {
513
+ throw new TypeError('pdf-reader: storageDomain service is unavailable')
514
+ }
515
+ const spec = await createViewDomainSpec()
516
+ const domain = await storageDomain.open(spec)
517
+ if (domain === null || typeof domain !== 'object' || typeof domain.table !== 'function') {
518
+ throw new TypeError('pdf-reader: storageDomain.open returned no domain')
519
+ }
520
+ return domain
521
+ }
522
+
523
+ /** Whether one stored value is a usable view record for the requested path. */
524
+ function isViewRecordFor(record, path) {
525
+ if (typeof path !== 'string' || path.length === 0 || path.length > MAX_VIEW_PATH_LENGTH) return false
526
+ if (record === null || typeof record !== 'object') return false
527
+ if (record.path !== path) return false
528
+ if (!Number.isInteger(record.pdfPage) || record.pdfPage < 1 || record.pdfPage > MAX_VIEW_PAGE) return false
529
+ if (record.zoomMode !== 'fit-width' && record.zoomMode !== 'fixed') return false
530
+ if (typeof record.zoomScale !== 'number' || !Number.isFinite(record.zoomScale)) return false
531
+ if (record.zoomScale < VIEW_ZOOM_SCALE_MIN || record.zoomScale > VIEW_ZOOM_SCALE_MAX) return false
532
+ if (!Number.isInteger(record.updatedAt) || record.updatedAt < 0) return false
533
+ return true
534
+ }
535
+
536
+ /** Project one stored record to the exact client-facing shape. */
537
+ function publicViewRecord(record) {
538
+ return {
539
+ path: record.path,
540
+ pdfPage: record.pdfPage,
541
+ zoomMode: record.zoomMode,
542
+ zoomScale: record.zoomScale,
543
+ updatedAt: record.updatedAt,
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Delete the least recently updated records once the table exceeds the cap.
549
+ * Best-effort: pruning never rejects an already-durable write.
550
+ * @param table - an open domain table handle.
551
+ */
552
+ async function pruneViewTable(table) {
553
+ try {
554
+ if (table.size <= MAX_VIEW_RECORDS) return
555
+ const excess = table.size - MAX_VIEW_RECORDS
556
+ const oldest = [...table.entries()]
557
+ .sort((left, right) => {
558
+ const leftAt = Number.isFinite(left[1]?.updatedAt) ? left[1].updatedAt : 0
559
+ const rightAt = Number.isFinite(right[1]?.updatedAt) ? right[1].updatedAt : 0
560
+ return leftAt - rightAt
561
+ })
562
+ .slice(0, excess)
563
+ for (const [key] of oldest) await table.delete(key)
564
+ } catch {
565
+ // The record is already durable; a failed prune is not a failed write.
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Route-facing adapter over the opened `book_reader_views` domain.
571
+ *
572
+ * `apply()` attaches the table once storage is open and detaches it on
573
+ * unload. Until then (or forever, when storage is unavailable) `read()`
574
+ * answers `null` and `write()` answers `false`, so routes degrade without
575
+ * throwing. Every failure is contained here and reported to `warn` once.
576
+ * @param options - warning sink; defaults to `console.warn`.
577
+ * @returns a mutable adapter read by the route handler.
578
+ */
579
+ export function createViewStateAdapter({ warn = console.warn } = {}) {
580
+ let table = null
581
+ let warned = false
582
+
583
+ function warnOnce(message, error) {
584
+ if (warned) return
585
+ warned = true
586
+ try {
587
+ const detail = error instanceof Error ? `: ${error.message}` : ''
588
+ warn(`dsh-pdf-reader: ${message}${detail}`)
589
+ } catch {
590
+ // A broken logger must not break plugin loading or routing.
591
+ }
592
+ }
593
+
594
+ return {
595
+ /** Whether an open domain table is currently attached. */
596
+ get available() {
597
+ return table !== null
598
+ },
599
+ /** Attach the table of a successfully opened domain. */
600
+ attach(nextTable) {
601
+ table = nextTable ?? null
602
+ },
603
+ /** Detach on unload so in-flight routes degrade instead of throwing. */
604
+ detach() {
605
+ table = null
606
+ },
607
+ /** Emit at most one warning for this adapter. */
608
+ warnOnce,
609
+ /**
610
+ * Read the remembered view for one normalized absolute path.
611
+ * @returns the public record, or `null` when absent/unavailable/invalid.
612
+ */
613
+ async read(path) {
614
+ const current = table
615
+ if (current === null) return null
616
+ try {
617
+ const record = current.get(viewKeyForPath(path))
618
+ if (!isViewRecordFor(record, path)) return null
619
+ return publicViewRecord(record)
620
+ } catch (error) {
621
+ warnOnce('could not read the remembered page/zoom', error)
622
+ return null
623
+ }
624
+ },
625
+ /**
626
+ * Persist one complete view record (key hashed from `record.path`).
627
+ * @returns true when durable, false when storage is unavailable/failed.
628
+ */
629
+ async write(record) {
630
+ const current = table
631
+ if (current === null) return false
632
+ try {
633
+ const value = {
634
+ path: record.path,
635
+ pdfPage: record.pdfPage,
636
+ zoomMode: record.zoomMode,
637
+ zoomScale: record.zoomScale,
638
+ updatedAt: Number.isInteger(record.updatedAt) ? record.updatedAt : Date.now(),
639
+ }
640
+ await current.put(viewKeyForPath(value.path), value)
641
+ await pruneViewTable(current)
642
+ return true
643
+ } catch (error) {
644
+ warnOnce('could not remember the page/zoom', error)
645
+ return false
646
+ }
647
+ },
648
+ }
649
+ }
650
+
419
651
  /** Normalize unicode dashes so a pasted page range parses. */
420
652
  function normalizeDashes(value) {
421
653
  return value.replace(/[\u2010-\u2015\u2212]/gu, '-')
@@ -1075,8 +1307,33 @@ function createLazyCore() {
1075
1307
  }
1076
1308
  }
1077
1309
 
1310
+ /**
1311
+ * Validate a `/view` book path without touching the filesystem.
1312
+ *
1313
+ * The remembered-view route deliberately does not stat the PDF: the client
1314
+ * may report a state for a book whose preview is still opening, and restoring
1315
+ * a missing book's state is harmless. The shape and size checks still mirror
1316
+ * the rest of the route surface.
1317
+ * @param rawPath - the raw query/body `path` value.
1318
+ * @returns the normalized absolute path, or `null` when invalid.
1319
+ */
1320
+ function validateViewPath(rawPath) {
1321
+ if (typeof rawPath !== 'string' || rawPath.length === 0 || rawPath.length > MAX_VIEW_PATH_LENGTH) return null
1322
+ if (!isAbsolute(rawPath)) return null
1323
+ const path = resolvePath(rawPath)
1324
+ if (!/\.pdf$/iu.test(path)) return null
1325
+ return path
1326
+ }
1327
+
1078
1328
  /** Build the route handler. Exported so tests can use fake core and stat. */
1079
- export function createRouteHandler({ config, pdfCore, infoCache, positions, statFile = stat }) {
1329
+ export function createRouteHandler({
1330
+ config,
1331
+ pdfCore,
1332
+ infoCache,
1333
+ positions,
1334
+ statFile = stat,
1335
+ views = createViewStateAdapter(),
1336
+ }) {
1080
1337
  async function loadPositionFor(sessionId, path) {
1081
1338
  if (typeof sessionId === 'string' && sessionId !== '') {
1082
1339
  return positions.forSession(sessionId)
@@ -1286,6 +1543,97 @@ export function createRouteHandler({ config, pdfCore, infoCache, positions, stat
1286
1543
  return
1287
1544
  }
1288
1545
 
1546
+ if (url.pathname === `${ROUTE_PATH}/view`) {
1547
+ const rememberView = config.rememberView !== false
1548
+ if (method === 'GET') {
1549
+ const path = validateViewPath(url.searchParams.get('path') ?? '')
1550
+ if (path === null) {
1551
+ sendJson(response, 400, { ok: false, error: 'invalid-path' })
1552
+ return
1553
+ }
1554
+ // Disabled remembering and unavailable storage both answer the same
1555
+ // non-blocking shape, so restoring a book never fails the preview.
1556
+ if (!rememberView) {
1557
+ sendJson(response, 200, { ok: true, view: null })
1558
+ return
1559
+ }
1560
+ let view = null
1561
+ try {
1562
+ view = await views.read(path)
1563
+ } catch {
1564
+ view = null
1565
+ }
1566
+ sendJson(response, 200, { ok: true, view: view ?? null })
1567
+ return
1568
+ }
1569
+
1570
+ if (method === 'POST') {
1571
+ const contentType = String(request.headers['content-type'] ?? '').toLowerCase()
1572
+ if (!contentType.startsWith('application/json')) {
1573
+ sendJson(response, 415, { ok: false, error: 'unsupported-media-type' })
1574
+ return
1575
+ }
1576
+ let body
1577
+ try {
1578
+ body = await readJsonBody(request, config.maxRouteBodyBytes)
1579
+ } catch (error) {
1580
+ const status = Number.isInteger(error?.status) ? error.status : 400
1581
+ sendJson(response, status, { ok: false, error: error?.error ?? 'invalid-body' })
1582
+ return
1583
+ }
1584
+ const path = validateViewPath(body.path)
1585
+ if (path === null) {
1586
+ sendJson(response, 400, { ok: false, error: 'invalid-path' })
1587
+ return
1588
+ }
1589
+ if (!Number.isInteger(body.pdfPage) || body.pdfPage < 1 || body.pdfPage > MAX_VIEW_PAGE) {
1590
+ sendJson(response, 400, { ok: false, error: 'invalid-page' })
1591
+ return
1592
+ }
1593
+ if (body.zoomMode !== 'fit-width' && body.zoomMode !== 'fixed') {
1594
+ sendJson(response, 400, { ok: false, error: 'invalid-zoom-mode' })
1595
+ return
1596
+ }
1597
+ if (
1598
+ typeof body.zoomScale !== 'number' ||
1599
+ !Number.isFinite(body.zoomScale) ||
1600
+ body.zoomScale < VIEW_ZOOM_SCALE_MIN ||
1601
+ body.zoomScale > VIEW_ZOOM_SCALE_MAX
1602
+ ) {
1603
+ sendJson(response, 400, { ok: false, error: 'invalid-zoom-scale' })
1604
+ return
1605
+ }
1606
+ // Remembering may be disabled while previews still talk to the route:
1607
+ // validation stays complete, the write becomes a deliberate no-op.
1608
+ if (!rememberView) {
1609
+ sendJson(response, 200, { ok: true })
1610
+ return
1611
+ }
1612
+ let stored = false
1613
+ try {
1614
+ stored =
1615
+ (await views.write({
1616
+ path,
1617
+ pdfPage: body.pdfPage,
1618
+ zoomMode: body.zoomMode,
1619
+ zoomScale: body.zoomScale,
1620
+ updatedAt: Date.now(),
1621
+ })) === true
1622
+ } catch {
1623
+ stored = false
1624
+ }
1625
+ if (!stored) {
1626
+ sendJson(response, 503, { ok: false, error: 'storage-unavailable' })
1627
+ return
1628
+ }
1629
+ sendJson(response, 200, { ok: true })
1630
+ return
1631
+ }
1632
+
1633
+ sendJson(response, 405, { ok: false, error: 'method-not-allowed' })
1634
+ return
1635
+ }
1636
+
1289
1637
  sendJson(response, 404, { ok: false, error: 'not-found' })
1290
1638
  }
1291
1639
 
@@ -1807,6 +2155,7 @@ export function apply(ctx, rawConfig) {
1807
2155
  limit: config.infoCacheSize,
1808
2156
  })
1809
2157
  const positions = createPositionStore()
2158
+ const views = createViewStateAdapter()
1810
2159
 
1811
2160
  const routeHandler = createRouteHandler({
1812
2161
  config,
@@ -1814,6 +2163,7 @@ export function apply(ctx, rawConfig) {
1814
2163
  infoCache,
1815
2164
  positions,
1816
2165
  statFile: stat,
2166
+ views,
1817
2167
  })
1818
2168
  ctx.effect(
1819
2169
  () => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PATH, handler: routeHandler }),
@@ -1843,6 +2193,53 @@ export function apply(ctx, rawConfig) {
1843
2193
  )
1844
2194
  })
1845
2195
 
2196
+ if (config.rememberView) {
2197
+ ctx.inject(['storageDomain'], (storageCtx) => {
2198
+ let disposed = false
2199
+ let domain = null
2200
+
2201
+ async function closeDomain() {
2202
+ const handle = domain
2203
+ domain = null
2204
+ if (handle === null) return
2205
+ try {
2206
+ await handle.close()
2207
+ } catch (error) {
2208
+ views.warnOnce('remembered page/zoom storage did not close cleanly', error)
2209
+ }
2210
+ }
2211
+
2212
+ // Opening is asynchronous while the route is already answering: the
2213
+ // adapter stays unavailable until attach(), and reverts to unavailable
2214
+ // on unload. Every failure leaves the feature disabled, never thrown.
2215
+ const opening = (async () => {
2216
+ try {
2217
+ domain = await openViewStateDomain(storageCtx.storageDomain)
2218
+ } catch (error) {
2219
+ views.warnOnce('remembered page/zoom storage is unavailable', error)
2220
+ return
2221
+ }
2222
+ if (disposed) {
2223
+ await closeDomain()
2224
+ return
2225
+ }
2226
+ try {
2227
+ views.attach(domain.table(VIEW_TABLE_NAME))
2228
+ } catch (error) {
2229
+ views.warnOnce('remembered page/zoom storage is unavailable', error)
2230
+ await closeDomain()
2231
+ }
2232
+ })()
2233
+
2234
+ return async () => {
2235
+ disposed = true
2236
+ views.detach()
2237
+ await opening
2238
+ await closeDomain()
2239
+ }
2240
+ })
2241
+ }
2242
+
1846
2243
  if (config.autoShare) {
1847
2244
  const listener = createAutoShareListener({ config, positions, infoCache })
1848
2245
  ctx.on('agent/pre-step', listener, { prepend: true })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-book-reader",
3
- "version": "0.1.1",
4
- "description": "Read books and papers in DSH: a reading toolbar on the official PDF preview, plus PDF tools for the model (page map, full-text search, page text and images).",
3
+ "version": "0.2.0",
4
+ "description": "Read books and papers in DSH: a reading toolbar on the official PDF preview that remembers your page and zoom, plus PDF tools for the model (page map, full-text search, page text and images).",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",
@@ -32,18 +32,26 @@
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
35
+ "@deepseek-ai/dsh-storage-domain": "^0.2.0-rc.2",
35
36
  "@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
36
- "@deepseek-ai/schemastery": "^3.18.4"
37
+ "@deepseek-ai/schemastery": "^3.18.4",
38
+ "zod": "^4.4.3"
37
39
  },
38
40
  "peerDependenciesMeta": {
39
41
  "@deepseek-ai/dsh-llm": {
40
42
  "optional": true
41
43
  },
44
+ "@deepseek-ai/dsh-storage-domain": {
45
+ "optional": true
46
+ },
42
47
  "@deepseek-ai/dsh-tools": {
43
48
  "optional": true
44
49
  },
45
50
  "@deepseek-ai/schemastery": {
46
51
  "optional": true
52
+ },
53
+ "zod": {
54
+ "optional": true
47
55
  }
48
56
  },
49
57
  "main": "lib/index.js",
@@ -66,6 +74,7 @@
66
74
  "SKILL.md",
67
75
  "cordis.patch.yml",
68
76
  "README.md",
77
+ "README.zh-CN.md",
69
78
  "LICENSE",
70
79
  "icon.svg",
71
80
  "locale/*.json",