@ggboy123/dsh-paper-reader 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/dist/host.js +131 -5
- package/dist/library.js +92 -1
- package/lib/client.js +152 -5
- package/package.json +1 -1
- package/reader/index.html +268 -38
package/README.md
CHANGED
|
@@ -23,16 +23,18 @@
|
|
|
23
23
|
|
|
24
24
|
## 功能
|
|
25
25
|
|
|
26
|
-
- 📚 **侧栏文献库树**:「论文伴读」模式开启时接管工作区侧栏,专题 → 论文两级;支持新建专题、上传 PDF
|
|
26
|
+
- 📚 **侧栏文献库树**:「论文伴读」模式开启时接管工作区侧栏,专题 → 论文两级;支持新建专题、上传 PDF、**专题/论文改名与删除**(行悬停出 ✎ 🗑;删除两击确认;改名连带转录缓存 / 译文 / 学习档案一起搬家)(默认关闭,不覆盖官方界面)
|
|
27
27
|
- 💬 **原生对话伴读**:点击论文即在右侧打开它的 dsh 原生会话(流式 / 工具卡 / 用量条全是官方 UI);默认只呈现当前对话,对话列头部的 🕐 下拉回看**历史对话**(真实标题+日期,点哪条进哪条),+ 新建对话
|
|
28
28
|
- 🔗 **会话绑定论文**:会话 id 编码文献身份,工具调用自动定位当前论文——直接在输入框提问无需点名论文,回答必带页码
|
|
29
29
|
- 🤖 **专用 agent preset「论文伴读」**:paper 会话自动使用专用 preset —— 像老师一样带读:制定分步阅读计划(每步带页码和思考题)→ 逐节指导、点评 → 出题检验、逐题批改 → 成绩与薄弱点记入 `<文献>.study.json` 学习档案,跨会话累积;快速问答(选中即问)则直接回答,不上课
|
|
30
|
-
- 📖 **PDF 阅读器居中**:中间是论文(PDF.js 缩放 / Retina 高清 / 文本层选择 /
|
|
30
|
+
- 📖 **PDF 阅读器居中**:中间是论文(PDF.js 缩放 / Retina 高清 / 文本层选择 / 适宽模式 / **滚动 ↔ 单页翻页双模式**——‹ › 翻页、页码输入跳转、← → / PageUp / PageDown / Space / Home / End 键盘翻页),最右是该论文的原生对话 —— 视觉换位实现,列宽拖拽/折叠仍是 dsh 原生行为,关掉 PDF 页签即还原官方布局;状态栏显示 PDF 完整路径,点击复制
|
|
31
31
|
- 💬 **选中即问**:阅读器里选中文字 → 弹出提问框 → 注入当前会话,原生对话区实时回答
|
|
32
32
|
- 📍 **引用定位**:回答里的「第 N 页」可点击,平滑跳回 PDF 对应页并闪烁
|
|
33
|
-
- 🀄
|
|
33
|
+
- 🀄 **中英切换 + 导出**:顶栏「中」按钮原文 ↔ 纯中文切换;无译文时一键后台生成(babeldoc),生成需配置 `translate` 端点;**无需预装 Python**——首次生成时自动下载 uv + 托管 Python + babeldoc(macOS / Linux / Windows,约几分钟),全程落在用户目录;顶栏 ⤓ 一键**下载当前版本**(原文 / 纯中文 / 中英对照 PDF)
|
|
34
34
|
- 🔍 **PDF 转录 + 检索**:本地提取(pdf.js,纯 Node 无需 Python),页眉页脚剔除 / 连字 / 断词愈合 / 段落重排,产出页码偏移表;可选接入 TypeSafe/Jev 语义重排(设置面板填端点即启用,不配置时退回纯关键词排序);兼容 pdfqa 的 `data/` 缓存布局(旧缓存读取时自动补建页码索引)
|
|
35
35
|
|
|
36
|
+
> **库管理须知**:改名/删除直接作用于磁盘文件(论文的转录缓存、译文、学习档案随之改名/删除);论文/专题改名后,历史伴读会话不再挂在树上(会话本体仍在 dsh 全局会话列表里),学习进度跟着文件走不丢;翻译进行中的论文禁止改名/删除。
|
|
37
|
+
|
|
36
38
|
## 安装
|
|
37
39
|
|
|
38
40
|
### 方式一:官方桌面端装插件(推荐)
|
|
@@ -40,7 +42,7 @@
|
|
|
40
42
|
**DeepSeek 官方桌面端**([deepseek.com/download](https://www.deepseek.com/download/) 下载,即 DeepSeek Harness 桌面版):
|
|
41
43
|
|
|
42
44
|
1. 安装并启动,登录 DeepSeek 账号
|
|
43
|
-
2. 侧栏 → **插件** → **添加插件**,输入 `@ggboy123/dsh-paper-reader@1.
|
|
45
|
+
2. 侧栏 → **插件** → **添加插件**,输入 `@ggboy123/dsh-paper-reader@1.3.0` 安装
|
|
44
46
|
3. 装完默认**停用**:点进插件详情,打开「启用」开关
|
|
45
47
|
4. **重启桌面端**(热启用状态下打开论文的链路会静默失效,重启进开机组合才正常,实测)
|
|
46
48
|
|
|
@@ -67,7 +69,7 @@ dsh plugin --profile web add @ggboy123/dsh-paper-reader
|
|
|
67
69
|
**已装过的用户升级必须带显式版本号**——不带版本的 `add` 对已存在的依赖是 no-op(pnpm 按首次安装时记录的版本范围解析,不会追新):
|
|
68
70
|
|
|
69
71
|
```bash
|
|
70
|
-
dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.
|
|
72
|
+
dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.3.0
|
|
71
73
|
# 重启 dsh web 生效;浏览器 Cmd+Shift+R 强刷,避免旧阅读器页面缓存
|
|
72
74
|
```
|
|
73
75
|
|
package/dist/host.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import { createReadStream } from 'node:fs';
|
|
4
4
|
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
|
-
import { join } from 'node:path';
|
|
7
|
-
import { listPapers, listTopics, resolveDataDir, resolvePaper } from "./library.js";
|
|
6
|
+
import { basename, join } from 'node:path';
|
|
7
|
+
import { deletePaper, deleteTopic, listPapers, listTopics, paperRefFor, renamePaper, renameTopic, resolveDataDir, resolvePaper, } from "./library.js";
|
|
8
8
|
import { readTranscript, transcribePaper } from "./transcribe.js";
|
|
9
9
|
import { pdfVariantPath, restartTranslation, startTranslation, zhStatus } from "./translate.js";
|
|
10
10
|
import { clearTranslateConfig, maskApiKey, readTranslateConfig, resolveTranslateEndpoint, testTranslateEndpoint, writeTranslateConfig, } from "./translate-config.js";
|
|
@@ -169,7 +169,8 @@ export function registerRoutes(ctx, config) {
|
|
|
169
169
|
return;
|
|
170
170
|
}
|
|
171
171
|
if (sub === '/api/library' && req.method === 'GET') {
|
|
172
|
-
|
|
172
|
+
const papers = listPapers(dataDir).map((p) => ({ ...p, pdfPath: join(dataDir, p.topic, p.name + '.pdf') }));
|
|
173
|
+
json(res, 200, { dataDir, topics: listTopics(dataDir), papers });
|
|
173
174
|
return;
|
|
174
175
|
}
|
|
175
176
|
// 新建专题(目录)
|
|
@@ -213,6 +214,123 @@ export function registerRoutes(ctx, config) {
|
|
|
213
214
|
json(res, 200, { ok: true, topic: topic.trim(), name: safeName.slice(0, -4), bytes: buf.length });
|
|
214
215
|
return;
|
|
215
216
|
}
|
|
217
|
+
/* ---- 库管理:专题/文献 改名与删除。----
|
|
218
|
+
* 翻译进行中(busy)一律 409 拒绝——babeldoc 异步跑着会往旧路径写 -zh/-dual 产物,
|
|
219
|
+
* 改名/删除会让它写丢;busy 键控在旧路径上,动了就失联。
|
|
220
|
+
* Windows 下删除被阅读器 iframe 占用的文件可能 EBUSY/EPERM,转成可读文案。 */
|
|
221
|
+
const busyPapersInTopic = (topic) => listPapers(dataDir, topic)
|
|
222
|
+
.filter((p) => zhStatus(paperRefFor(dataDir, p.topic, p.name)).busy)
|
|
223
|
+
.map((p) => p.name);
|
|
224
|
+
const fsError = (res2, err) => {
|
|
225
|
+
const e = err;
|
|
226
|
+
if (e?.code === 'EBUSY' || e?.code === 'EPERM') {
|
|
227
|
+
json(res2, 409, { error: '文件被占用(可能正在阅读器或其他程序中打开),关闭后重试' });
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
json(res2, 400, { error: e instanceof Error ? e.message : String(err) });
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
if (sub === '/api/library/rename-topic' && req.method === 'POST') {
|
|
234
|
+
const body = (await readBody(req));
|
|
235
|
+
if (!body.topic?.trim()) {
|
|
236
|
+
json(res, 400, { error: '缺少 topic' });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const busy = busyPapersInTopic(body.topic.trim());
|
|
240
|
+
if (busy.length) {
|
|
241
|
+
json(res, 409, { error: `以下文献正在翻译,稍后再试:${busy.join('、')}` });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
renameTopic(dataDir, body.topic.trim(), body.to ?? '');
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
fsError(res, err);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
workspaceCache.delete(join(dataDir, body.topic.trim())); // 旧目录的工作区记录失效(残留无害)
|
|
252
|
+
json(res, 200, { ok: true, topic: body.to?.trim() });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (sub === '/api/library/delete-topic' && req.method === 'POST') {
|
|
256
|
+
const body = (await readBody(req));
|
|
257
|
+
if (!body.topic?.trim()) {
|
|
258
|
+
json(res, 400, { error: '缺少 topic' });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const busy = busyPapersInTopic(body.topic.trim());
|
|
262
|
+
if (busy.length) {
|
|
263
|
+
json(res, 409, { error: `以下文献正在翻译,稍后再试:${busy.join('、')}` });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
deleteTopic(dataDir, body.topic.trim());
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
fsError(res, err);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
json(res, 200, { ok: true });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (sub === '/api/library/rename-paper' && req.method === 'POST') {
|
|
277
|
+
const body = (await readBody(req));
|
|
278
|
+
if (!body.topic?.trim() || !body.name?.trim()) {
|
|
279
|
+
json(res, 400, { error: '缺少 topic / name' });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
let ref;
|
|
283
|
+
try {
|
|
284
|
+
ref = resolvePaper(dataDir, { topic: body.topic.trim(), name: body.name.trim() });
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
fsError(res, err);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (zhStatus(ref).busy) {
|
|
291
|
+
json(res, 409, { error: '该文献正在翻译,稍后再试' });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
let renamed;
|
|
295
|
+
try {
|
|
296
|
+
renamed = renamePaper(dataDir, ref.topic, ref.name, body.to ?? '');
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
fsError(res, err);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
json(res, 200, { ok: true, topic: ref.topic, name: body.to?.trim(), renamed });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (sub === '/api/library/delete-paper' && req.method === 'POST') {
|
|
306
|
+
const body = (await readBody(req));
|
|
307
|
+
if (!body.topic?.trim() || !body.name?.trim()) {
|
|
308
|
+
json(res, 400, { error: '缺少 topic / name' });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
let ref;
|
|
312
|
+
try {
|
|
313
|
+
ref = resolvePaper(dataDir, { topic: body.topic.trim(), name: body.name.trim() });
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
fsError(res, err);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (zhStatus(ref).busy) {
|
|
320
|
+
json(res, 409, { error: '该文献正在翻译,稍后再试' });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
let deleted;
|
|
324
|
+
try {
|
|
325
|
+
deleted = deletePaper(dataDir, ref.topic, ref.name);
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
fsError(res, err);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
json(res, 200, { ok: true, deleted });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
216
334
|
// 新建伴读会话(会话N+1)。最新可见会话还是空白草稿时直接复用它——
|
|
217
335
|
// 连点「新建」不再堆积空会话(毫无意义的点击什么都不存)。
|
|
218
336
|
// n 用全量扫描分配(含归档/空白),绝不与磁盘上已有目录撞号。
|
|
@@ -262,6 +380,7 @@ export function registerRoutes(ctx, config) {
|
|
|
262
380
|
json(res, 200, {
|
|
263
381
|
topic: ref.topic,
|
|
264
382
|
name: ref.name,
|
|
383
|
+
pdfPath: ref.pdfPath,
|
|
265
384
|
pdfBytes: st.size,
|
|
266
385
|
hasTranscript: t !== null,
|
|
267
386
|
chars: t?.text.length ?? 0,
|
|
@@ -274,11 +393,18 @@ export function registerRoutes(ctx, config) {
|
|
|
274
393
|
const ref = locate(url);
|
|
275
394
|
const pdfPath = pdfVariantPath(ref, url.searchParams.get('variant'));
|
|
276
395
|
const st = await stat(pdfPath); // 变体不存在时 404/500 由外层兜底
|
|
277
|
-
|
|
396
|
+
// download=1 → 附件下载(导出译文/原文);文件名即磁盘名 <名>.pdf / -zh.pdf / -dual.pdf
|
|
397
|
+
const headers = {
|
|
278
398
|
'content-type': 'application/pdf',
|
|
279
399
|
'content-length': st.size,
|
|
280
400
|
'cache-control': 'no-cache', // 切换文献/重新生成后必须拿到新的
|
|
281
|
-
}
|
|
401
|
+
};
|
|
402
|
+
if (url.searchParams.get('download') === '1') {
|
|
403
|
+
const name = basename(pdfPath);
|
|
404
|
+
headers['content-disposition'] =
|
|
405
|
+
`attachment; filename="paper.pdf"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
406
|
+
}
|
|
407
|
+
res.writeHead(200, headers);
|
|
282
408
|
createReadStream(pdfPath).pipe(res);
|
|
283
409
|
return;
|
|
284
410
|
}
|
package/dist/library.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// <dataDir>/<专题>/<文献>.pages.json 页码偏移表(转录时生成,检索映射页码用)
|
|
7
7
|
// pdf2zh 的产物(-en / -zh / -dual)不算用户文献。
|
|
8
8
|
// (问答不落盘存档——会话持久化交给 dsh 自己;pdfqa 时代的 -qa/ 目录不再读写。)
|
|
9
|
-
import { readdirSync, existsSync, mkdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { readdirSync, existsSync, mkdirSync, statSync, renameSync, rmSync } from 'node:fs';
|
|
10
10
|
import { join, resolve, extname, basename } from 'node:path';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
const PDF2ZH_SUFFIXES = ['-en', '-zh', '-dual'];
|
|
@@ -101,3 +101,94 @@ export function transcriptCached(ref) {
|
|
|
101
101
|
return false;
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
// ================= 库管理:改名/删除(纯文件系统操作,供 host 路由调用) =================
|
|
105
|
+
/** 名字校验(专题/文献共用)。合法返回 null,否则返回中文错误文案。 */
|
|
106
|
+
export function validateEntryName(raw) {
|
|
107
|
+
const name = raw.trim();
|
|
108
|
+
if (!name)
|
|
109
|
+
return '名字不能为空';
|
|
110
|
+
if (name.length > 120)
|
|
111
|
+
return '名字过长(最多 120 个字符)';
|
|
112
|
+
if (/[\\/]|\.\./.test(name))
|
|
113
|
+
return '名字不能包含 / \\ 或 ..';
|
|
114
|
+
if (name.startsWith('.'))
|
|
115
|
+
return '名字不能以 . 开头';
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/** 构造文献引用(不做存在性校验;给只按路径判定的调用方用,如翻译 busy 检查)。 */
|
|
119
|
+
export function paperRefFor(dataDir, topic, name) {
|
|
120
|
+
const pdfPath = join(dataDir, topic, name + '.pdf');
|
|
121
|
+
const base = pdfPath.slice(0, -'.pdf'.length);
|
|
122
|
+
return { topic, name, pdfPath, txtPath: base + '.txt', pagesPath: base + '.pages.json' };
|
|
123
|
+
}
|
|
124
|
+
/** 文献的全部关联文件后缀(含本体 .pdf):转录缓存 + 页码索引 + babeldoc 译文 + 学习档案。
|
|
125
|
+
* 命名与 translate.ts(-zh/-dual)和 study.ts(.study.json)的产物规则一致。 */
|
|
126
|
+
const PAPER_SIDECARS = ['.pdf', '.txt', '.pages.json', '-zh.pdf', '-dual.pdf', '.study.json'];
|
|
127
|
+
/** 某文献的全部关联文件路径(存在与否由调用方自查)。 */
|
|
128
|
+
export function derivedFilesFor(pdfPath) {
|
|
129
|
+
const stem = pdfPath.slice(0, -'.pdf'.length);
|
|
130
|
+
return PAPER_SIDECARS.map((s) => stem + s);
|
|
131
|
+
}
|
|
132
|
+
/** 改名专题(目录 rename)。from 不存在 / to 已存在 / 名字非法 → throw 中文 Error。
|
|
133
|
+
* 已知局限:大小写不敏感文件系统(macOS/Windows)上纯大小写改名会被「目标已存在」
|
|
134
|
+
* 拒绝——不做临时名两跳绕过,保持简单。 */
|
|
135
|
+
export function renameTopic(dataDir, from, to) {
|
|
136
|
+
const err = validateEntryName(to);
|
|
137
|
+
if (err)
|
|
138
|
+
throw new Error(err);
|
|
139
|
+
if (to.trim() === from)
|
|
140
|
+
return;
|
|
141
|
+
const src = join(dataDir, from);
|
|
142
|
+
if (!existsSync(src))
|
|
143
|
+
throw new Error(`专题不存在: ${from}`);
|
|
144
|
+
const dst = join(dataDir, to.trim());
|
|
145
|
+
if (existsSync(dst))
|
|
146
|
+
throw new Error(`已存在同名专题: ${to.trim()}`);
|
|
147
|
+
renameSync(src, dst);
|
|
148
|
+
}
|
|
149
|
+
/** 改名文献:连带派生文件(转录/页码索引/译文/学习档案)逐个跟随,不存在的跳过。
|
|
150
|
+
* 返回实际改名的文件名清单(含 .pdf 本体)。 */
|
|
151
|
+
export function renamePaper(dataDir, topic, from, to) {
|
|
152
|
+
const err = validateEntryName(to);
|
|
153
|
+
if (err)
|
|
154
|
+
throw new Error(err);
|
|
155
|
+
to = to.trim();
|
|
156
|
+
if (to === from)
|
|
157
|
+
return [];
|
|
158
|
+
const dir = join(dataDir, topic);
|
|
159
|
+
if (!existsSync(join(dir, from + '.pdf')))
|
|
160
|
+
throw new Error(`文献不存在: ${topic}/${from}`);
|
|
161
|
+
if (existsSync(join(dir, to + '.pdf')))
|
|
162
|
+
throw new Error(`已存在同名文献: ${to}`);
|
|
163
|
+
const renamed = [];
|
|
164
|
+
for (const suf of PAPER_SIDECARS) {
|
|
165
|
+
const src = join(dir, from + suf);
|
|
166
|
+
if (!existsSync(src))
|
|
167
|
+
continue;
|
|
168
|
+
renameSync(src, join(dir, to + suf));
|
|
169
|
+
renamed.push(from + suf);
|
|
170
|
+
}
|
|
171
|
+
return renamed;
|
|
172
|
+
}
|
|
173
|
+
/** 删除文献及其全部派生文件。返回实际删除的文件名清单。 */
|
|
174
|
+
export function deletePaper(dataDir, topic, name) {
|
|
175
|
+
const dir = join(dataDir, topic);
|
|
176
|
+
const pdfPath = join(dir, name + '.pdf');
|
|
177
|
+
if (!existsSync(pdfPath))
|
|
178
|
+
throw new Error(`文献不存在: ${topic}/${name}`);
|
|
179
|
+
const deleted = [];
|
|
180
|
+
for (const f of derivedFilesFor(pdfPath)) {
|
|
181
|
+
if (!existsSync(f))
|
|
182
|
+
continue;
|
|
183
|
+
rmSync(f);
|
|
184
|
+
deleted.push(basename(f));
|
|
185
|
+
}
|
|
186
|
+
return deleted;
|
|
187
|
+
}
|
|
188
|
+
/** 删除专题(递归,含其下全部文献与派生文件)。 */
|
|
189
|
+
export function deleteTopic(dataDir, topic) {
|
|
190
|
+
const dir = join(dataDir, topic);
|
|
191
|
+
if (!existsSync(dir))
|
|
192
|
+
throw new Error(`专题不存在: ${topic}`);
|
|
193
|
+
rmSync(dir, { recursive: true, force: true });
|
|
194
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -31,6 +31,8 @@ window.__ModuleLoader__.load({
|
|
|
31
31
|
empty: '文献库为空', hint: '新建专题或上传 PDF 开始', newTopic: '新建专题',
|
|
32
32
|
cancel: '取消', ok: '确定', defaultTopic: '默认专题', uploadBtn: '上传 PDF',
|
|
33
33
|
newTopicFail: '新建专题失败',
|
|
34
|
+
rename: '重命名', renameTopic: '重命名专题', renamePaper: '重命名论文',
|
|
35
|
+
del: '删除', delArm: '确认删除?', renameFail: '重命名失败', delFail: '删除失败',
|
|
34
36
|
upload: '上传 PDF 到此专题', newChat: '新建对话', session: '会话', pdfTab: '论文 PDF',
|
|
35
37
|
history: '历史对话',
|
|
36
38
|
sync: '对话还停在另一篇文献的会话里,点击切到当前文献',
|
|
@@ -52,6 +54,8 @@ window.__ModuleLoader__.load({
|
|
|
52
54
|
empty: 'Library is empty', hint: 'Create a topic or upload a PDF to start', newTopic: 'New topic',
|
|
53
55
|
cancel: 'Cancel', ok: 'OK', defaultTopic: 'Default', uploadBtn: 'Upload PDF',
|
|
54
56
|
newTopicFail: 'Failed to create topic',
|
|
57
|
+
rename: 'Rename', renameTopic: 'Rename topic', renamePaper: 'Rename paper',
|
|
58
|
+
del: 'Delete', delArm: 'Delete?', renameFail: 'Rename failed', delFail: 'Delete failed',
|
|
55
59
|
upload: 'Upload PDF into this topic', newChat: 'New chat', session: 'Session', pdfTab: 'Paper PDF',
|
|
56
60
|
history: 'Chat history',
|
|
57
61
|
sync: 'Chat is still on another paper — click to switch to the current one',
|
|
@@ -112,6 +116,37 @@ window.__ModuleLoader__.load({
|
|
|
112
116
|
return r.json()
|
|
113
117
|
}
|
|
114
118
|
|
|
119
|
+
/* ---------- 改名搬家:论文粒度的本地记忆键跟随新身份 ----------
|
|
120
|
+
dpr.variant.<topic>/<name>(语言版本记忆)、dpr.sessionN.<topic>/<name>(会话序号)、
|
|
121
|
+
dpr.last(上次打开)。论文改名精确搬单键;专题改名把该专题前缀的键整体搬。 */
|
|
122
|
+
function migrateLsKeys(oldTopic, newTopic, oldName, newName) {
|
|
123
|
+
try {
|
|
124
|
+
if (oldName != null) {
|
|
125
|
+
for (const base of ['dpr.variant.', 'dpr.sessionN.']) {
|
|
126
|
+
const from = base + oldTopic + '/' + oldName
|
|
127
|
+
const to = base + newTopic + '/' + newName
|
|
128
|
+
const v = localStorage.getItem(from)
|
|
129
|
+
if (v != null) { localStorage.setItem(to, v); localStorage.removeItem(from) }
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
for (const base of ['dpr.variant.', 'dpr.sessionN.']) {
|
|
133
|
+
const pre = base + oldTopic + '/'
|
|
134
|
+
for (let i = localStorage.length - 1; i >= 0; i--) {
|
|
135
|
+
const k = localStorage.key(i)
|
|
136
|
+
if (k && k.startsWith(pre)) {
|
|
137
|
+
localStorage.setItem(base + newTopic + '/' + k.slice(pre.length), localStorage.getItem(k))
|
|
138
|
+
localStorage.removeItem(k)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const last = JSON.parse(localStorage.getItem('dpr.last') || 'null')
|
|
144
|
+
if (last && last.topic === oldTopic && (oldName == null || last.name === oldName)) {
|
|
145
|
+
localStorage.setItem('dpr.last', JSON.stringify({ ...last, topic: newTopic, ...(oldName != null ? { name: newName } : {}) }))
|
|
146
|
+
}
|
|
147
|
+
} catch { /* 隐私模式等写不了就算了 */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
115
150
|
/* 当前在屏(主视图)会话 id:0.1.x 是会话列表快照的 .current 字段;
|
|
116
151
|
0.2.0 起快照没有该字段,官方以 retainedBy.mainView>0 标记主视图保留的会话
|
|
117
152
|
(dsh-client-ui-workspace 自己的 current() 就是这么读的)—— 两代都取到。 */
|
|
@@ -194,6 +229,8 @@ window.__ModuleLoader__.load({
|
|
|
194
229
|
const lastShownRef = React.useRef(0)
|
|
195
230
|
useEffect(() => {
|
|
196
231
|
const on = (e) => {
|
|
232
|
+
// 空库(最后一篇被删):撤掉阅读器 iframe,回到「从文献库选择一篇论文」占位
|
|
233
|
+
if (e.detail.empty) { try { localStorage.removeItem('dpr.last') } catch {}; setCur(null); return }
|
|
197
234
|
// 跳转链接挂的页码/引文(jumpToPaperPage 的跨论文路径):随 src 传给阅读器 bootstrap
|
|
198
235
|
const j = pendingJump
|
|
199
236
|
pendingJump = null
|
|
@@ -512,8 +549,8 @@ window.__ModuleLoader__.load({
|
|
|
512
549
|
(实测同版本 Electron 调用即抛 "prompt() is not supported",桌面端点「+」
|
|
513
550
|
曾因此毫无反应),一律走 createPortal 挂 document.body,避免被侧栏 overflow 裁剪。 ---------- */
|
|
514
551
|
function AskText(props) {
|
|
515
|
-
const { title, confirmText, cancelText, onConfirm, onCancel } = props
|
|
516
|
-
const [v, setV] = useState('')
|
|
552
|
+
const { title, confirmText, cancelText, onConfirm, onCancel, initial } = props
|
|
553
|
+
const [v, setV] = useState(() => initial ?? '')
|
|
517
554
|
const ref = React.useRef(null)
|
|
518
555
|
useEffect(() => { ref.current?.focus() }, [])
|
|
519
556
|
const submit = () => { const s = v.trim(); if (s) onConfirm(s) }
|
|
@@ -692,6 +729,76 @@ window.__ModuleLoader__.load({
|
|
|
692
729
|
// supported",点击无任何反应),改为 AskText 页内弹窗(见 LibraryTree return)。
|
|
693
730
|
const newTopic = useCallback(() => setAskTopic(true), [])
|
|
694
731
|
|
|
732
|
+
/* ---------- 库管理:改名/删除(用户反馈补齐) ----------
|
|
733
|
+
已知代价(README 明示):sessionId 编码旧 topic/name,改名后历史伴读会话从树上
|
|
734
|
+
消失(会话本体仍在 dsh 全局列表);study.json/译文/转录缓存随文件改名全部保留。 */
|
|
735
|
+
const [renaming, setRenaming] = useState(null) // {kind:'topic'|'paper', topic, name}
|
|
736
|
+
const [armDel, setArmDel] = useState(null) // {kind, topic, name}:两段式删除的待确认态
|
|
737
|
+
|
|
738
|
+
// 两段式删除的待确认态 3s 自动复位(复刻阅读器 ↻ 重译按钮的 arm 模式)
|
|
739
|
+
useEffect(() => {
|
|
740
|
+
if (!armDel) return undefined
|
|
741
|
+
const timer = setTimeout(() => setArmDel(null), 3000)
|
|
742
|
+
return () => clearTimeout(timer)
|
|
743
|
+
}, [armDel])
|
|
744
|
+
|
|
745
|
+
const doRename = useCallback(async (kind, topic, name, to) => {
|
|
746
|
+
const newTopicName = kind === 'topic' ? to : topic
|
|
747
|
+
const newName = kind === 'paper' ? to : name
|
|
748
|
+
try {
|
|
749
|
+
await api(`/library/rename-${kind}`, {
|
|
750
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
751
|
+
body: JSON.stringify(kind === 'topic' ? { topic, to } : { topic, name, to }),
|
|
752
|
+
})
|
|
753
|
+
} catch (err) { toast(t('renameFail') + ': ' + err.message); return }
|
|
754
|
+
// 本地记忆(语言版本/会话序号/上次打开/折叠态)搬到新身份
|
|
755
|
+
migrateLsKeys(topic, newTopicName, kind === 'paper' ? name : null, kind === 'paper' ? newName : null)
|
|
756
|
+
setSessions((m) => { const c = { ...m }; delete c[`${topic}/${name}`]; return c })
|
|
757
|
+
if (kind === 'topic') setClosed((c) => {
|
|
758
|
+
if (!(topic in c)) return c
|
|
759
|
+
const n = { ...c }; n[to] = c[topic]; delete n[topic]
|
|
760
|
+
try { localStorage.setItem('dpr.treeClosed', JSON.stringify(n)) } catch {}
|
|
761
|
+
return n
|
|
762
|
+
})
|
|
763
|
+
// 改的是当前论文(或其所在专题)→ 树与阅读器 iframe 跟到新身份
|
|
764
|
+
if (cur && (kind === 'topic' ? cur.topic === topic : (cur.topic === topic && cur.name === name))) {
|
|
765
|
+
setCur((c) => ({ ...c, topic: newTopicName, name: newName }))
|
|
766
|
+
window.dispatchEvent(new CustomEvent('dpr:paper', { detail: { topic: newTopicName, name: newName } }))
|
|
767
|
+
}
|
|
768
|
+
refresh()
|
|
769
|
+
}, [cur, refresh])
|
|
770
|
+
|
|
771
|
+
const doDelete = useCallback(async (kind, topic, name) => {
|
|
772
|
+
try {
|
|
773
|
+
await api(`/library/delete-${kind}`, {
|
|
774
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
775
|
+
body: JSON.stringify(kind === 'topic' ? { topic } : { topic, name }),
|
|
776
|
+
})
|
|
777
|
+
} catch (err) { toast(t('delFail') + ': ' + err.message); return }
|
|
778
|
+
setSessions((m) => { const c = { ...m }; delete c[`${topic}/${name}`]; return c })
|
|
779
|
+
refresh()
|
|
780
|
+
// 删的是当前论文(或其所在专题)→ 切到剩余第一篇;全空则收起阅读器
|
|
781
|
+
if (cur && (kind === 'topic' ? cur.topic === topic : (cur.topic === topic && cur.name === name))) {
|
|
782
|
+
const d = await api('/library').catch(() => null)
|
|
783
|
+
const next = d?.papers?.[0] || null
|
|
784
|
+
if (next) {
|
|
785
|
+
setCur({ topic: next.topic, name: next.name })
|
|
786
|
+
window.dispatchEvent(new CustomEvent('dpr:paper', { detail: { topic: next.topic, name: next.name } }))
|
|
787
|
+
} else {
|
|
788
|
+
setCur(null)
|
|
789
|
+
try { localStorage.removeItem('dpr.last') } catch {}
|
|
790
|
+
window.dispatchEvent(new CustomEvent('dpr:paper', { detail: { empty: true } }))
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}, [cur, refresh])
|
|
794
|
+
|
|
795
|
+
// 两段式删除点击:第一击进入待确认(按钮变红显示「确认删除?」),第二击执行
|
|
796
|
+
const clickDelete = useCallback((kind, topic, name) => {
|
|
797
|
+
const armed = armDel && armDel.kind === kind && armDel.topic === topic && armDel.name === name
|
|
798
|
+
if (armed) { setArmDel(null); doDelete(kind, topic, name); return }
|
|
799
|
+
setArmDel({ kind, topic, name })
|
|
800
|
+
}, [armDel, doDelete])
|
|
801
|
+
|
|
695
802
|
const upload = useCallback((topic) => {
|
|
696
803
|
uploadTopic.current = topic
|
|
697
804
|
fileInput.current?.click()
|
|
@@ -770,9 +877,10 @@ window.__ModuleLoader__.load({
|
|
|
770
877
|
h('button', { type: 'button', style: S.linkBtn, onClick: newTopic }, '+ ', t('newTopic')))),
|
|
771
878
|
topics.map((topic) => {
|
|
772
879
|
const isClosed = !!closed[topic]
|
|
880
|
+
const topicArmed = armDel && armDel.kind === 'topic' && armDel.topic === topic
|
|
773
881
|
return h('div', { key: topic },
|
|
774
882
|
h('div', {
|
|
775
|
-
style: S.topicHead,
|
|
883
|
+
style: S.topicHead, 'data-dpr-row': '',
|
|
776
884
|
onClick: () => {
|
|
777
885
|
const next = { ...closed, [topic]: !isClosed }
|
|
778
886
|
setClosed(next)
|
|
@@ -785,16 +893,39 @@ window.__ModuleLoader__.load({
|
|
|
785
893
|
style: S.iconBtn, title: t('upload'),
|
|
786
894
|
onClick: (e) => { e.stopPropagation(); upload(topic) },
|
|
787
895
|
}, '⬆'),
|
|
896
|
+
h('button', {
|
|
897
|
+
'data-dpr-act': 'rename', style: S.iconBtn, title: t('rename'),
|
|
898
|
+
onClick: (e) => { e.stopPropagation(); setRenaming({ kind: 'topic', topic }) },
|
|
899
|
+
}, '✎'),
|
|
900
|
+
h('button', {
|
|
901
|
+
'data-dpr-act': 'del', ...(topicArmed ? { 'data-dpr-armed': '' } : {}),
|
|
902
|
+
style: topicArmed ? { ...S.iconBtn, color: 'var(--dsw-alias-state-error-primary, #cf222e)' } : S.iconBtn,
|
|
903
|
+
title: t('del'),
|
|
904
|
+
onClick: (e) => { e.stopPropagation(); clickDelete('topic', topic, undefined) },
|
|
905
|
+
}, topicArmed ? t('delArm') : '🗑'),
|
|
788
906
|
),
|
|
789
907
|
!isClosed && h('div', { style: S.items },
|
|
790
908
|
papers.filter((p) => p.topic === topic).map((p) => {
|
|
791
909
|
const isCurrentPaper = cur && cur.topic === p.topic && cur.name === p.name
|
|
910
|
+
const paperArmed = armDel && armDel.kind === 'paper' && armDel.topic === p.topic && armDel.name === p.name
|
|
792
911
|
return h('div', {
|
|
793
912
|
key: p.name,
|
|
794
913
|
style: { ...S.item, ...(isCurrentPaper ? S.active : {}) },
|
|
795
|
-
|
|
914
|
+
'data-dpr-row': '',
|
|
915
|
+
title: p.pdfPath || p.name,
|
|
796
916
|
onClick: () => enter(p.topic, p.name),
|
|
797
|
-
}, '📄 ',
|
|
917
|
+
}, '📄 ',
|
|
918
|
+
h('span', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, p.name),
|
|
919
|
+
h('button', {
|
|
920
|
+
'data-dpr-act': 'rename', style: S.iconBtn, title: t('rename'),
|
|
921
|
+
onClick: (e) => { e.stopPropagation(); setRenaming({ kind: 'paper', topic: p.topic, name: p.name }) },
|
|
922
|
+
}, '✎'),
|
|
923
|
+
h('button', {
|
|
924
|
+
'data-dpr-act': 'del', ...(paperArmed ? { 'data-dpr-armed': '' } : {}),
|
|
925
|
+
style: paperArmed ? { ...S.iconBtn, color: 'var(--dsw-alias-state-error-primary, #cf222e)' } : S.iconBtn,
|
|
926
|
+
title: t('del'),
|
|
927
|
+
onClick: (e) => { e.stopPropagation(); clickDelete('paper', p.topic, p.name) },
|
|
928
|
+
}, paperArmed ? t('delArm') : '🗑'))
|
|
798
929
|
}),
|
|
799
930
|
),
|
|
800
931
|
)
|
|
@@ -816,6 +947,19 @@ window.__ModuleLoader__.load({
|
|
|
816
947
|
onCancel: () => setAskTopic(false),
|
|
817
948
|
}),
|
|
818
949
|
document.body),
|
|
950
|
+
renaming && createPortal(
|
|
951
|
+
h(AskText, {
|
|
952
|
+
title: (renaming.kind === 'topic' ? t('renameTopic') : t('renamePaper')) + ':',
|
|
953
|
+
initial: renaming.kind === 'topic' ? renaming.topic : renaming.name,
|
|
954
|
+
confirmText: t('ok'), cancelText: t('cancel'),
|
|
955
|
+
onConfirm: (name) => {
|
|
956
|
+
const r = renaming
|
|
957
|
+
setRenaming(null)
|
|
958
|
+
doRename(r.kind, r.topic, r.name, name)
|
|
959
|
+
},
|
|
960
|
+
onCancel: () => setRenaming(null),
|
|
961
|
+
}),
|
|
962
|
+
document.body),
|
|
819
963
|
)
|
|
820
964
|
}
|
|
821
965
|
|
|
@@ -979,6 +1123,9 @@ window.__ModuleLoader__.load({
|
|
|
979
1123
|
const styleEl = document.createElement('style')
|
|
980
1124
|
styleEl.id = 'dpr-surface-css'
|
|
981
1125
|
styleEl.textContent = '[data-dpr-rail]:hover,[data-dpr-mode]:hover{background:var(--dsw-alias-interactive-bg-hover)!important}'
|
|
1126
|
+
// 树行内的 ✎🗑 操作按钮:默认隐身,行悬停才现身(两段式删除的待确认态常亮)
|
|
1127
|
+
+ '[data-dpr-act]{opacity:0!important;transition:opacity .12s}'
|
|
1128
|
+
+ '[data-dpr-row]:hover [data-dpr-act],[data-dpr-armed]{opacity:1!important}'
|
|
982
1129
|
document.head.appendChild(styleEl)
|
|
983
1130
|
}
|
|
984
1131
|
|
package/package.json
CHANGED
package/reader/index.html
CHANGED
|
@@ -32,12 +32,22 @@
|
|
|
32
32
|
/* min-width:0 —— flex 项默认 min-width:auto 会拒绝收缩,空间不够时转而去挤按钮;
|
|
33
33
|
让标题自己截断(已有 ellipsis),按钮才能保持原样 */
|
|
34
34
|
#status { min-width: 0; color: var(--text-2); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
35
|
+
/* PDF 文件路径:可点的只读文本(点击复制),窄列自动截断 */
|
|
36
|
+
#pdf-path { flex: none; max-width: 34%; padding: 0; border: none; background: none; color: var(--text-2); font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
|
|
37
|
+
#pdf-path:hover { color: var(--accent); }
|
|
35
38
|
|
|
36
39
|
/* ---------- PDF 区 ---------- */
|
|
37
40
|
#main { display: flex; flex: 1; min-height: 0; }
|
|
38
41
|
#pdf-pane { flex: 1; overflow: auto; padding: 16px; }
|
|
39
42
|
/* safe center:页宽超过视口时退回左对齐——普通 center 会让左右同时溢出且左侧滚不到 */
|
|
40
43
|
#pdf-pages { display: flex; flex-direction: column; align-items: safe center; gap: 14px; }
|
|
44
|
+
/* 翻页模式:单页垂直居中(safe center 同理,放大后顶部仍可滚到) */
|
|
45
|
+
#pdf-pages.paged { min-height: 100%; justify-content: safe center; gap: 0; }
|
|
46
|
+
|
|
47
|
+
/* ---------- 翻页控件 ---------- */
|
|
48
|
+
#pager { display: flex; align-items: center; gap: 4px; flex: none; }
|
|
49
|
+
#page-input { width: 3.5em; padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 12.5px; text-align: right; }
|
|
50
|
+
#page-total { flex: none; color: var(--text-2); font-size: 12px; white-space: nowrap; }
|
|
41
51
|
.page { position: relative; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.18); }
|
|
42
52
|
.page.flash { outline: 3px solid var(--hl); outline-offset: 2px; transition: outline .15s; }
|
|
43
53
|
/* textLayer CSS 必须是 pdf.js 6.x 官方规则的子集:TextLayer 只往 span 写 --font-height/--scale-x/--rotate
|
|
@@ -99,9 +109,18 @@
|
|
|
99
109
|
|
|
100
110
|
<div id="bar">
|
|
101
111
|
<span id="status">论文伴读</span>
|
|
112
|
+
<button id="pdf-path" style="display:none" title="点击复制文件路径"></button>
|
|
102
113
|
<button id="zh-toggle" class="tbtn" title="切换中文译文(没有译文时可生成)" style="display:none">中</button>
|
|
103
114
|
<button id="zh-regen" class="tbtn" title="重新翻译:删除现有译文,用当前端点重跑(换模型后用)" style="display:none">↻</button>
|
|
115
|
+
<button id="dl-btn" class="tbtn" title="下载 PDF" style="display:none">⤓</button>
|
|
104
116
|
<span class="spacer"></span>
|
|
117
|
+
<button id="mode-toggle" class="tbtn" title="切换 滚动/翻页模式" style="display:none">▣</button>
|
|
118
|
+
<span id="pager" style="display:none">
|
|
119
|
+
<button id="page-prev" class="tbtn" title="上一页">‹</button>
|
|
120
|
+
<input id="page-input" type="text" inputmode="numeric" title="页码,回车跳转">
|
|
121
|
+
<span id="page-total">/ ?</span>
|
|
122
|
+
<button id="page-next" class="tbtn" title="下一页">›</button>
|
|
123
|
+
</span>
|
|
105
124
|
<button id="cfg-open" class="tbtn" title="翻译端点设置">⚙</button>
|
|
106
125
|
<button id="zoom-out" class="tbtn">−</button>
|
|
107
126
|
<span id="zoom-label">100%</span>
|
|
@@ -168,6 +187,18 @@ function toast(msg, ms = 3200) {
|
|
|
168
187
|
}
|
|
169
188
|
function esc(s) { return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
|
170
189
|
|
|
190
|
+
// 路径复制:clipboard API 失败(iframe 权限等)退回 textarea + execCommand
|
|
191
|
+
$('pdf-path').onclick = async () => {
|
|
192
|
+
const p = $('pdf-path').textContent;
|
|
193
|
+
if (!p) return;
|
|
194
|
+
try { await navigator.clipboard.writeText(p); toast('路径已复制'); return; } catch { /* 走兜底 */ }
|
|
195
|
+
const ta = document.createElement('textarea');
|
|
196
|
+
ta.value = p; document.body.appendChild(ta); ta.select();
|
|
197
|
+
try { document.execCommand('copy'); toast('路径已复制'); }
|
|
198
|
+
catch { toast('复制失败,路径见按钮悬停提示'); }
|
|
199
|
+
ta.remove();
|
|
200
|
+
};
|
|
201
|
+
|
|
171
202
|
let current = null; // { topic, name }
|
|
172
203
|
let zhState = { zh: false, dual: false, busy: false };
|
|
173
204
|
let statusMeta = ''; // openPaper 的「name · N 页」基线,翻译阶段标签褪去后恢复它
|
|
@@ -176,6 +207,11 @@ let zhVariant = null; // null=原文 'dual'=中英对照 'zh'=纯中文
|
|
|
176
207
|
// 加上这里的恢复,「中文界面跳中文」在同篇跳转/重开/跨论文三条路径上都成立
|
|
177
208
|
const variantKey = (topic, name) => `dpr.variant.${topic}/${name}`;
|
|
178
209
|
|
|
210
|
+
// 阅读模式:scroll=连续滚动(默认,原行为)paged=单页翻页;每读者记忆
|
|
211
|
+
let pageMode = localStorage.getItem('dpr.pageMode') === 'paged' ? 'paged' : 'scroll';
|
|
212
|
+
// 当前页:paged 是唯一渲染依据;scroll 由 IntersectionObserver 跟随回写(模式切换/跳页共享)
|
|
213
|
+
let curPage = 1;
|
|
214
|
+
|
|
179
215
|
/* ================= 打开文献 ================= */
|
|
180
216
|
function pdfUrl(variant) {
|
|
181
217
|
const q = `topic=${encodeURIComponent(current.topic)}&name=${encodeURIComponent(current.name)}`;
|
|
@@ -186,6 +222,7 @@ async function openPaper(topic, name, opts = {}) {
|
|
|
186
222
|
if (!name) return;
|
|
187
223
|
current = { topic, name };
|
|
188
224
|
localStorage.setItem('dpr.last', JSON.stringify(current));
|
|
225
|
+
curPage = opts.page || 1; // 换论文从第一页(或跳转目标页)开始;变体切换/重译不走这里,页码保持
|
|
189
226
|
$('status').textContent = '加载 PDF…';
|
|
190
227
|
const q = `topic=${encodeURIComponent(topic)}&name=${encodeURIComponent(name)}`;
|
|
191
228
|
const meta = await api('/paper?' + q);
|
|
@@ -193,16 +230,43 @@ async function openPaper(topic, name, opts = {}) {
|
|
|
193
230
|
? `${name} · ${meta.pageCount ?? '?'} 页`
|
|
194
231
|
: `${name} · 未转录(提问时会自动转录)`;
|
|
195
232
|
$('status').textContent = statusMeta;
|
|
233
|
+
// 文件路径展示(点击复制):独立于 #status,翻译阶段的 phase 覆写不影响它
|
|
234
|
+
const pathBtn = $('pdf-path');
|
|
235
|
+
pathBtn.textContent = meta.pdfPath || '';
|
|
236
|
+
pathBtn.title = (meta.pdfPath || '') + '(点击复制)';
|
|
237
|
+
pathBtn.style.display = meta.pdfPath ? '' : 'none';
|
|
196
238
|
await refreshZhState();
|
|
197
239
|
// 恢复该论文上次的语言版本(译文已不存在时回落原文)
|
|
198
240
|
const saved = localStorage.getItem(variantKey(topic, name));
|
|
199
241
|
zhVariant = (saved === 'zh' && zhState.zh) || (saved === 'dual' && zhState.dual) ? saved : null;
|
|
200
242
|
$('zh-toggle').classList.toggle('active', zhVariant !== null);
|
|
201
243
|
if (zhVariant) { fitMode = true; fitBtn.classList.add('active'); } // 译文版页宽不同,强制适宽
|
|
244
|
+
updateDlBtn();
|
|
202
245
|
await loadPdf(pdfUrl(zhVariant));
|
|
203
246
|
if (opts.page) jumpTo(opts.page, opts.q); // 不 await:跳转内部自带「等页面渲出来」轮询
|
|
204
247
|
}
|
|
205
248
|
|
|
249
|
+
/* ================= 下载/导出 ================= */
|
|
250
|
+
// 下载当前正在看的那个版本(原文/纯中文/中英对照)——导出译文的主要入口
|
|
251
|
+
function updateDlBtn() {
|
|
252
|
+
const btn = $('dl-btn');
|
|
253
|
+
if (!current) { btn.style.display = 'none'; return; }
|
|
254
|
+
btn.style.display = '';
|
|
255
|
+
btn.title = zhVariant === 'zh' ? '下载:纯中文版 PDF'
|
|
256
|
+
: zhVariant === 'dual' ? '下载:中英对照版 PDF' : '下载:原文 PDF';
|
|
257
|
+
}
|
|
258
|
+
function downloadCurrent() {
|
|
259
|
+
if (!current) return;
|
|
260
|
+
// 同源请求自动带 cookie(鉴权围栏);download 属性空值 → 文件名取 content-disposition
|
|
261
|
+
const a = document.createElement('a');
|
|
262
|
+
a.href = pdfUrl(zhVariant) + '&download=1';
|
|
263
|
+
a.download = '';
|
|
264
|
+
document.body.appendChild(a);
|
|
265
|
+
a.click();
|
|
266
|
+
a.remove();
|
|
267
|
+
}
|
|
268
|
+
$('dl-btn').onclick = downloadCurrent;
|
|
269
|
+
|
|
206
270
|
/* ================= 中英切换 ================= */
|
|
207
271
|
async function refreshZhState() {
|
|
208
272
|
zhState = await api(`/zh?topic=${encodeURIComponent(current.topic)}&name=${encodeURIComponent(current.name)}`);
|
|
@@ -236,6 +300,7 @@ $('zh-toggle').onclick = async () => {
|
|
|
236
300
|
zhVariant = zhVariant === null ? (zhState.zh ? 'zh' : 'dual') : null;
|
|
237
301
|
localStorage.setItem(variantKey(current.topic, current.name), zhVariant ?? '');
|
|
238
302
|
$('zh-toggle').classList.toggle('active', zhVariant !== null);
|
|
303
|
+
updateDlBtn();
|
|
239
304
|
// 切换变体后强制适宽:译文版页宽与原文不同(对照双栏≈2×),不重算会溢出裁切
|
|
240
305
|
fitMode = true; fitBtn.classList.add('active');
|
|
241
306
|
await loadPdf(pdfUrl(zhVariant));
|
|
@@ -428,7 +493,7 @@ let renderToken = 0;
|
|
|
428
493
|
mq.addEventListener('change', function onChange() {
|
|
429
494
|
mq.removeEventListener('change', onChange);
|
|
430
495
|
dpr = window.devicePixelRatio || 1;
|
|
431
|
-
if (pdfDoc)
|
|
496
|
+
if (pdfDoc) rerender();
|
|
432
497
|
watchDpr();
|
|
433
498
|
});
|
|
434
499
|
})();
|
|
@@ -437,62 +502,129 @@ async function loadPdf(url) {
|
|
|
437
502
|
await ensurePdfjs(); // 就绪前禁止触 pdfjsLib(defer 竞态,见文件头注释)
|
|
438
503
|
pdfDoc = await pdfjsLib.getDocument({ url }).promise; // v6 起不接受字符串参数,必须传对象
|
|
439
504
|
$('empty').style.display = 'none';
|
|
440
|
-
|
|
441
|
-
|
|
505
|
+
// 翻页控件与模式切换按钮:文档就绪后才出现
|
|
506
|
+
$('pager').style.display = '';
|
|
507
|
+
$('mode-toggle').style.display = '';
|
|
508
|
+
if (fitMode) { fitToWidth(); return; } // fitToWidth 内部 setZoom → rerender
|
|
509
|
+
await rerender();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** 构建单页 DOM 骨架(.page + canvas + textLayer),位图与文本由 paintPage 异步填充。 */
|
|
513
|
+
function makePageNode(i, viewport) {
|
|
514
|
+
const div = document.createElement('div');
|
|
515
|
+
div.className = 'page';
|
|
516
|
+
div.dataset.page = i;
|
|
517
|
+
div.style.width = viewport.width + 'px';
|
|
518
|
+
|
|
519
|
+
const canvas = document.createElement('canvas');
|
|
520
|
+
canvas.width = Math.ceil(viewport.width * dpr);
|
|
521
|
+
canvas.height = Math.ceil(viewport.height * dpr);
|
|
522
|
+
canvas.style.width = viewport.width + 'px';
|
|
523
|
+
canvas.style.height = viewport.height + 'px';
|
|
524
|
+
div.appendChild(canvas);
|
|
525
|
+
|
|
526
|
+
const tl = document.createElement('div');
|
|
527
|
+
tl.className = 'textLayer';
|
|
528
|
+
tl.style.width = viewport.width + 'px';
|
|
529
|
+
tl.style.height = viewport.height + 'px';
|
|
530
|
+
tl.style.setProperty('--scale-factor', String(viewport.scale));
|
|
531
|
+
div.appendChild(tl);
|
|
532
|
+
return div;
|
|
442
533
|
}
|
|
443
534
|
|
|
535
|
+
/** 渲染单页内容(canvas 位图 + pdf.js 6.x TextLayer 文本层)。token 失效(被更新的渲染
|
|
536
|
+
抢占)时中途放弃——旧 renderAllPages 循环体里的守卫原样搬进来。 */
|
|
537
|
+
async function paintPage(page, div, viewport, token) {
|
|
538
|
+
const canvas = div.firstChild;
|
|
539
|
+
const tl = div.lastChild;
|
|
540
|
+
await page.render({
|
|
541
|
+
canvasContext: canvas.getContext('2d'),
|
|
542
|
+
viewport,
|
|
543
|
+
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : null,
|
|
544
|
+
}).promise;
|
|
545
|
+
if (token !== renderToken) return;
|
|
546
|
+
const tc = await page.getTextContent();
|
|
547
|
+
if (token !== renderToken) return;
|
|
548
|
+
// pdf.js 6.x: renderTextLayer() 移除,改 TextLayer 类
|
|
549
|
+
await new pdfjsLib.TextLayer({ textContentSource: tc, container: tl, viewport }).render();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** 当前模式的全量重渲入口:缩放/DPR/换文档/换变体统一走这里。 */
|
|
553
|
+
function rerender() {
|
|
554
|
+
return pageMode === 'paged' ? renderPaged() : renderAllPages();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// 隐藏期间被跳过的重渲请求:尺寸恢复(resize)后补跑。
|
|
558
|
+
// ⚠️ 曾经的坑(用户实测「点放大后全空白」):守卫在 ++renderToken 之后——隐藏期间的
|
|
559
|
+
// 渲染请求先作废在途渲染、自己又因高度 0 提前返回,容器已被清空却无人再画;且退出
|
|
560
|
+
// 适宽模式后 resize 只重算适宽(if (fitMode)),不补渲 → 永久空白。改为:隐藏期间
|
|
561
|
+
// 的请求直接跳过(不动 token、在途渲染照常画完),标记 pending 等恢复后补画。
|
|
562
|
+
let pendingRerender = false;
|
|
563
|
+
|
|
444
564
|
async function renderAllPages() {
|
|
565
|
+
const pane = $('pdf-pane');
|
|
566
|
+
if (pane.clientHeight === 0) { pendingRerender = true; return; } // 悬浮层隐藏期间不重渲(否则锚点恢复会把滚动清零)
|
|
445
567
|
const token = ++renderToken;
|
|
446
568
|
const container = $('pdf-pages');
|
|
447
|
-
const
|
|
448
|
-
if (pane.clientHeight === 0) return; // 悬浮层隐藏期间不重渲(否则锚点恢复会把滚动清零)
|
|
569
|
+
const scale = zoomScale; // 单次渲染内快照:隐藏期间的 setZoom 改全局值也不至于画出混合缩放
|
|
449
570
|
// 滚动比例锚点:不用 DOM rect(并发重渲时旧页可能已被清空,rect 全灭 → 锚点归零)
|
|
450
571
|
const range = pane.scrollHeight - pane.clientHeight;
|
|
451
572
|
const anchorRatio = range > 0 ? pane.scrollTop / range : 0;
|
|
452
573
|
container.innerHTML = '';
|
|
453
|
-
|
|
574
|
+
container.classList.remove('paged');
|
|
575
|
+
$('zoom-label').textContent = Math.round(scale / 1.5 * 100) + '%';
|
|
454
576
|
|
|
455
577
|
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
|
456
578
|
if (token !== renderToken) return;
|
|
457
579
|
const page = await pdfDoc.getPage(i);
|
|
458
580
|
if (token !== renderToken) return;
|
|
459
|
-
const viewport = page.getViewport({ scale
|
|
460
|
-
const div =
|
|
461
|
-
div.className = 'page';
|
|
462
|
-
div.dataset.page = i;
|
|
463
|
-
div.style.width = viewport.width + 'px';
|
|
464
|
-
|
|
465
|
-
const canvas = document.createElement('canvas');
|
|
466
|
-
canvas.width = Math.ceil(viewport.width * dpr);
|
|
467
|
-
canvas.height = Math.ceil(viewport.height * dpr);
|
|
468
|
-
canvas.style.width = viewport.width + 'px';
|
|
469
|
-
canvas.style.height = viewport.height + 'px';
|
|
470
|
-
div.appendChild(canvas);
|
|
471
|
-
|
|
472
|
-
const tl = document.createElement('div');
|
|
473
|
-
tl.className = 'textLayer';
|
|
474
|
-
tl.style.width = viewport.width + 'px';
|
|
475
|
-
tl.style.height = viewport.height + 'px';
|
|
476
|
-
tl.style.setProperty('--scale-factor', String(zoomScale));
|
|
477
|
-
div.appendChild(tl);
|
|
581
|
+
const viewport = page.getViewport({ scale });
|
|
582
|
+
const div = makePageNode(i, viewport);
|
|
478
583
|
container.appendChild(div);
|
|
479
|
-
|
|
480
|
-
await page.render({
|
|
481
|
-
canvasContext: canvas.getContext('2d'),
|
|
482
|
-
viewport,
|
|
483
|
-
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : null,
|
|
484
|
-
}).promise;
|
|
485
|
-
if (token !== renderToken) return;
|
|
486
|
-
const tc = await page.getTextContent();
|
|
584
|
+
await paintPage(page, div, viewport, token);
|
|
487
585
|
if (token !== renderToken) return;
|
|
488
|
-
// pdf.js 6.x: renderTextLayer() 移除,改 TextLayer 类
|
|
489
|
-
await new pdfjsLib.TextLayer({ textContentSource: tc, container: tl, viewport }).render();
|
|
490
586
|
}
|
|
491
587
|
const newRange = pane.scrollHeight - pane.clientHeight;
|
|
492
588
|
pane.scrollTop = anchorRatio * Math.max(0, newRange);
|
|
589
|
+
observePages();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** 翻页模式:只渲染 curPage 一页(按需渲染,天然无长文档性能问题)。 */
|
|
593
|
+
async function renderPaged() {
|
|
594
|
+
if ($('pdf-pane').clientHeight === 0) { pendingRerender = true; return; } // 同 renderAllPages:跳过并标记,恢复后补画
|
|
595
|
+
const token = ++renderToken;
|
|
596
|
+
const container = $('pdf-pages');
|
|
597
|
+
const scale = zoomScale; // 同 renderAllPages:快照,防混合缩放
|
|
598
|
+
curPage = Math.min(Math.max(1, curPage), pdfDoc.numPages);
|
|
599
|
+
container.innerHTML = '';
|
|
600
|
+
container.classList.add('paged');
|
|
601
|
+
$('zoom-label').textContent = Math.round(scale / 1.5 * 100) + '%';
|
|
602
|
+
updatePager();
|
|
603
|
+
const page = await pdfDoc.getPage(curPage);
|
|
604
|
+
if (token !== renderToken) return;
|
|
605
|
+
const viewport = page.getViewport({ scale });
|
|
606
|
+
const div = makePageNode(curPage, viewport);
|
|
607
|
+
container.appendChild(div);
|
|
608
|
+
await paintPage(page, div, viewport, token);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** 滚动模式当前页探测:视口竖直中线带(±45%)压到哪页,哪页就是当前页。
|
|
612
|
+
每次全量重渲后重建(页节点全换)。翻页控件与模式切换共享 curPage。 */
|
|
613
|
+
let pageObserver = null;
|
|
614
|
+
function observePages() {
|
|
615
|
+
pageObserver?.disconnect();
|
|
616
|
+
pageObserver = new IntersectionObserver((entries) => {
|
|
617
|
+
for (const en of entries) {
|
|
618
|
+
if (en.isIntersecting) {
|
|
619
|
+
curPage = +en.target.dataset.page || curPage;
|
|
620
|
+
updatePager();
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}, { root: $('pdf-pane'), rootMargin: '-45% 0px -45% 0px', threshold: 0 });
|
|
624
|
+
document.querySelectorAll('#pdf-pages .page').forEach((d) => pageObserver.observe(d));
|
|
493
625
|
}
|
|
494
626
|
|
|
495
|
-
function setZoom(newScale) { if (!pdfDoc) return; zoomScale = Math.min(4.5, Math.max(0.6, newScale));
|
|
627
|
+
function setZoom(newScale) { if (!pdfDoc) return; zoomScale = Math.min(4.5, Math.max(0.6, newScale)); rerender(); }
|
|
496
628
|
// 默认适宽:rightbar 窄列里 1.5 倍缩放会把页面撑出视口(中英对照双栏页宽≈2×,更明显)
|
|
497
629
|
let fitMode = true;
|
|
498
630
|
const fitBtn = $('zoom-fit');
|
|
@@ -507,9 +639,99 @@ function fitToWidth() {
|
|
|
507
639
|
$('zoom-in').onclick = () => { exitFitMode(); setZoom(zoomScale + 0.25); };
|
|
508
640
|
$('zoom-out').onclick = () => { exitFitMode(); setZoom(zoomScale - 0.25); };
|
|
509
641
|
fitBtn.onclick = () => { if (!pdfDoc) return; fitMode = true; fitBtn.classList.add('active'); fitBtn.blur(); fitToWidth(); };
|
|
510
|
-
window.addEventListener('resize', () => {
|
|
642
|
+
window.addEventListener('resize', () => {
|
|
643
|
+
if (fitMode) fitToWidth(); // setZoom → rerender,顺带消化 pendingRerender
|
|
644
|
+
else if (pendingRerender && pdfDoc) { pendingRerender = false; rerender(); } // 隐藏期间欠下的重渲,恢复尺寸后补画
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
/* ================= 翻页模式:页码控件 + 模式切换 + 键盘 ================= */
|
|
648
|
+
function flashPage() {
|
|
649
|
+
const div = document.querySelector(`.page[data-page="${curPage}"]`);
|
|
650
|
+
if (!div) return;
|
|
651
|
+
div.classList.add('flash');
|
|
652
|
+
setTimeout(() => div.classList.remove('flash'), 1600);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** 页码控件刷新:输入框(未聚焦时才回写,别打断正在敲页码的人)/总页数/首末页禁用。 */
|
|
656
|
+
function updatePager() {
|
|
657
|
+
const total = pdfDoc ? pdfDoc.numPages : 0;
|
|
658
|
+
$('page-total').textContent = '/ ' + (total || '?');
|
|
659
|
+
const input = $('page-input');
|
|
660
|
+
if (document.activeElement !== input) input.value = total ? curPage : '';
|
|
661
|
+
$('page-prev').disabled = pageMode === 'paged' && curPage <= 1;
|
|
662
|
+
$('page-next').disabled = pageMode === 'paged' && curPage >= total;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** 跳到第 n 页(夹取):paged 重渲单页+闪烁;scroll 平滑滚到该页。 */
|
|
666
|
+
async function goPage(n) {
|
|
667
|
+
if (!pdfDoc) return;
|
|
668
|
+
const p = Math.min(Math.max(1, (n | 0) || 1), pdfDoc.numPages);
|
|
669
|
+
if (pageMode === 'paged') {
|
|
670
|
+
if (p === curPage) { flashPage(); return; }
|
|
671
|
+
curPage = p;
|
|
672
|
+
await renderPaged();
|
|
673
|
+
flashPage();
|
|
674
|
+
} else {
|
|
675
|
+
curPage = p;
|
|
676
|
+
jumpToPage(p);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
$('page-prev').onclick = () => goPage(curPage - 1);
|
|
681
|
+
$('page-next').onclick = () => goPage(curPage + 1);
|
|
682
|
+
function commitPageInput() {
|
|
683
|
+
const v = parseInt($('page-input').value, 10);
|
|
684
|
+
if (Number.isFinite(v) && v >= 1) goPage(v);
|
|
685
|
+
else updatePager(); // 非法输入:恢复当前页显示
|
|
686
|
+
}
|
|
687
|
+
// Enter 走 blur 统一提交;Escape 只恢复显示不跳页(标志位让 blur 知道这是放弃编辑)
|
|
688
|
+
let pageInputEscaped = false;
|
|
689
|
+
$('page-input').addEventListener('keydown', (e) => {
|
|
690
|
+
if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); }
|
|
691
|
+
else if (e.key === 'Escape') { pageInputEscaped = true; e.target.blur(); }
|
|
692
|
+
});
|
|
693
|
+
$('page-input').addEventListener('blur', () => {
|
|
694
|
+
if (pageInputEscaped) { pageInputEscaped = false; updatePager(); return; }
|
|
695
|
+
commitPageInput();
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
/** 模式切换:paged→scroll 先全量渲染再跳回当前页;scroll→paged 用观察器最近回写的 curPage。
|
|
699
|
+
图标显示「当前模式」:▣ 单页翻页(active)/ ≣ 连续滚动。 */
|
|
700
|
+
function updateModeToggle() {
|
|
701
|
+
const btn = $('mode-toggle');
|
|
702
|
+
btn.textContent = pageMode === 'paged' ? '▣' : '≣';
|
|
703
|
+
btn.classList.toggle('active', pageMode === 'paged');
|
|
704
|
+
btn.title = pageMode === 'paged' ? '当前:单页翻页,点击切回连续滚动' : '当前:连续滚动,点击切到单页翻页';
|
|
705
|
+
updatePager();
|
|
706
|
+
}
|
|
707
|
+
$('mode-toggle').onclick = async () => {
|
|
708
|
+
if (!pdfDoc) return;
|
|
709
|
+
pageMode = pageMode === 'paged' ? 'scroll' : 'paged';
|
|
710
|
+
localStorage.setItem('dpr.pageMode', pageMode);
|
|
711
|
+
updateModeToggle();
|
|
712
|
+
if (pageMode === 'paged') {
|
|
713
|
+
await renderPaged();
|
|
714
|
+
flashPage();
|
|
715
|
+
} else {
|
|
716
|
+
await renderAllPages();
|
|
717
|
+
jumpToPage(curPage);
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
updateModeToggle();
|
|
721
|
+
|
|
722
|
+
// 键盘翻页(仅 paged):输入框聚焦/带修饰键时不抢;Space 在单页模式下无滚动量,拿来做下一页
|
|
723
|
+
document.addEventListener('keydown', (e) => {
|
|
724
|
+
if (pageMode !== 'paged' || !pdfDoc) return;
|
|
725
|
+
const tag = (e.target.tagName || '').toUpperCase();
|
|
726
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
727
|
+
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); goPage(curPage + 1); }
|
|
728
|
+
else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); goPage(curPage - 1); }
|
|
729
|
+
else if (e.key === 'Home') { e.preventDefault(); goPage(1); }
|
|
730
|
+
else if (e.key === 'End') { e.preventDefault(); goPage(pdfDoc.numPages); }
|
|
731
|
+
});
|
|
511
732
|
|
|
512
733
|
function jumpToPage(n) {
|
|
734
|
+
if (pageMode === 'paged') { goPage(n); return; }
|
|
513
735
|
const div = document.querySelector(`.page[data-page="${n}"]`);
|
|
514
736
|
if (!div) return;
|
|
515
737
|
div.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
@@ -553,9 +775,17 @@ function highlightSnippet(page, q) {
|
|
|
553
775
|
}
|
|
554
776
|
|
|
555
777
|
/* 统一跳转入口:先等目标页渲染出来(fit 模式下 loadPdf 返回时页面可能还在渲),
|
|
556
|
-
有 q 就尝试片段高亮,否则整页跳转 +
|
|
778
|
+
有 q 就尝试片段高亮,否则整页跳转 + 边框闪烁。
|
|
779
|
+
paged 模式目标页不在 DOM 里(只渲当前页),轮询永远等不到 → 直接设页渲染。 */
|
|
557
780
|
async function jumpTo(page, q) {
|
|
558
781
|
const p0 = Math.max(1, (page | 0) || 1);
|
|
782
|
+
if (pageMode === 'paged') {
|
|
783
|
+
if (!pdfDoc) return;
|
|
784
|
+
const p = Math.min(p0, pdfDoc.numPages);
|
|
785
|
+
if (p !== curPage) { curPage = p; await renderPaged(); }
|
|
786
|
+
if (!(q && highlightSnippet(p, q))) flashPage();
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
559
789
|
for (let i = 0; i < 100; i++) {
|
|
560
790
|
if (pdfDoc && document.querySelector(`.page[data-page="${Math.min(p0, pdfDoc.numPages)}"]`)) {
|
|
561
791
|
const p = Math.min(p0, pdfDoc.numPages);
|