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 +16 -0
- package/README.md +31 -77
- package/README.zh-CN.md +55 -0
- package/cordis.patch.yml +5 -3
- package/lib/client.js +512 -0
- package/lib/index.js +398 -1
- package/package.json +12 -3
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
|
-
|
|
3
|
+
A reading toolbar for DSH's official PDF preview, plus three PDF tools the model can use.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[中文说明](README.zh-CN.md)
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
**Reading toolbar** —
|
|
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
|
|
12
|
-
- Full-text search
|
|
13
|
-
-
|
|
14
|
-
-
|
|
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
|
|
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
|
|
21
|
-
| `pdf_search` | Literal full-text search;
|
|
22
|
-
| `pdf_read_pages` |
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
43
|
-
-
|
|
44
|
-
- Scanned PDFs have no text layer —
|
|
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
|
-
|
|
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)
|
package/README.zh-CN.md
ADDED
|
@@ -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-
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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({
|
|
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.
|
|
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",
|