@ggboy123/dsh-paper-reader 1.3.5 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -70
- package/dist/embed-config.js +116 -0
- package/dist/embed.js +349 -0
- package/dist/formulas.js +272 -0
- package/dist/host.js +681 -145
- package/dist/library-manage.js +752 -0
- package/dist/library.js +88 -67
- package/dist/mineru-config.js +343 -0
- package/dist/mineru.js +1168 -0
- package/dist/search.js +52 -0
- package/dist/tools.js +150 -21
- package/dist/transcribe.js +316 -39
- package/dist/translate-config.js +8 -2
- package/lib/client.js +479 -60
- package/package.json +2 -2
- package/reader/index.html +465 -46
package/reader/index.html
CHANGED
|
@@ -24,11 +24,16 @@
|
|
|
24
24
|
#bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; row-gap: 6px; padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--border); flex: none; }
|
|
25
25
|
#bar button { font: inherit; }
|
|
26
26
|
/* flex:none —— 列被拖窄时按钮不许被压缩(否则文字竖排、按钮变窄条) */
|
|
27
|
-
|
|
27
|
+
/* inline-flex + 固定高:图标/文字统一垂直居中,按钮高度不再随各家字体字形漂移 */
|
|
28
|
+
.tbtn { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 5px; height: 28px; padding: 0 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); cursor: pointer; }
|
|
28
29
|
.tbtn:hover { background: var(--bg); }
|
|
29
30
|
.tbtn.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
|
|
30
31
|
.tbtn:disabled { opacity: .45; cursor: default; }
|
|
31
|
-
|
|
32
|
+
/* 统一 SVG 图标:stroke=currentColor,active/disabled 的变色自动继承到图标 */
|
|
33
|
+
.tbtn svg.ic { width: 16px; height: 16px; flex: none; }
|
|
34
|
+
.tbtn svg.spin { transform-origin: center; animation: dpr-spin 1s linear infinite; }
|
|
35
|
+
@keyframes dpr-spin { to { transform: rotate(360deg); } }
|
|
36
|
+
@media (prefers-reduced-motion: reduce) { .tbtn svg.spin { animation-duration: 2.4s; } }
|
|
32
37
|
#zoom-label { flex: none; min-width: 44px; text-align: center; color: var(--text-2); }
|
|
33
38
|
#bar .spacer { flex: 1; }
|
|
34
39
|
/* min-width:0 —— flex 项默认 min-width:auto 会拒绝收缩,空间不够时转而去挤按钮;
|
|
@@ -43,7 +48,7 @@
|
|
|
43
48
|
@media (max-width: 560px) { #ver { display: none; } }
|
|
44
49
|
|
|
45
50
|
/* ---------- PDF 区 ---------- */
|
|
46
|
-
#main { display: flex; flex: 1; min-height: 0; }
|
|
51
|
+
#main { display: flex; flex: 1; min-height: 0; position: relative; } /* relative:公式面板绝对定位的锚 */
|
|
47
52
|
#pdf-pane { flex: 1; overflow: auto; padding: 16px; }
|
|
48
53
|
/* safe center:页宽超过视口时退回左对齐——普通 center 会让左右同时溢出且左侧滚不到 */
|
|
49
54
|
#pdf-pages { display: flex; flex-direction: column; align-items: safe center; gap: 14px; }
|
|
@@ -52,7 +57,9 @@
|
|
|
52
57
|
|
|
53
58
|
/* ---------- 翻页控件 ---------- */
|
|
54
59
|
#pager { display: flex; align-items: center; gap: 4px; flex: none; }
|
|
55
|
-
|
|
60
|
+
/* 数字居中:text-align:right 会让单页码贴右框边、左侧空一块;
|
|
61
|
+
上 padding 多 1px —— 数字无下伸部,光字心本就偏高,对称内边距看起来反而偏上 */
|
|
62
|
+
#page-input { width: 3.5em; padding: 4px 6px 2px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 12.5px; line-height: 20px; text-align: center; }
|
|
56
63
|
#page-total { flex: none; color: var(--text-2); font-size: 12px; white-space: nowrap; }
|
|
57
64
|
/* 插件版本号(host 注入;未被替换时隐藏——比如旧 host 配新页面的错位场景) */
|
|
58
65
|
#ver { flex: none; font-size: 10.5px; color: var(--text-2); opacity: .65; user-select: none; }
|
|
@@ -87,6 +94,31 @@
|
|
|
87
94
|
.textLayer span.dpr-hl { background: rgba(255,196,0,.45); border-radius: 2px; }
|
|
88
95
|
#empty { margin: 60px auto; color: var(--text-2); text-align: center; max-width: 420px; }
|
|
89
96
|
|
|
97
|
+
/* ---------- 公式引用(MinerU bbox 热区 + 列表面板)----------
|
|
98
|
+
契约 §1.3/§1.4:热区**不用** pointer-events:auto 的覆盖层——那会夺走 textLayer 的
|
|
99
|
+
mousedown/拖拽,破坏既有划词提问。这里 .eqLayer 只负责画描边(pointer-events:none),
|
|
100
|
+
命中判定在 JS 里按坐标算。 */
|
|
101
|
+
.eqLayer { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
|
|
102
|
+
.eq-box { position: absolute; border-radius: 3px; }
|
|
103
|
+
.eq-box.hover { outline: 1.5px dashed rgba(9,105,218,.85); background: rgba(9,105,218,.07); }
|
|
104
|
+
.eq-box.hit { outline: 2px solid rgba(255,140,0,.9); background: rgba(255,196,0,.16); }
|
|
105
|
+
#eq-panel { position: absolute; top: 0; right: 0; bottom: 0; width: min(320px, 88%); display: none;
|
|
106
|
+
background: var(--panel); border-left: 1px solid var(--border); box-shadow: -4px 0 16px rgba(0,0,0,.08);
|
|
107
|
+
overflow: auto; z-index: 3; padding: 10px 12px; }
|
|
108
|
+
#eq-panel h3 { margin: 0 0 4px; font-size: 13px; }
|
|
109
|
+
#eq-meta { font-size: 11.5px; color: var(--text-2); margin-bottom: 6px; line-height: 1.5; }
|
|
110
|
+
.eq-page { margin: 10px 0 2px; font-size: 12px; font-weight: 600; color: var(--text-2); }
|
|
111
|
+
.eq-item { display: block; width: 100%; text-align: left; border: 1px solid var(--border); border-radius: 6px;
|
|
112
|
+
background: var(--bg); color: var(--text); padding: 6px 8px; margin: 4px 0; cursor: pointer; font: inherit; }
|
|
113
|
+
.eq-item:hover { border-color: var(--accent); }
|
|
114
|
+
.eq-item code { display: block; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11.5px;
|
|
115
|
+
white-space: pre-wrap; word-break: break-word; margin-top: 2px; }
|
|
116
|
+
.eq-item .eq-tag { font-size: 11px; color: var(--text-2); }
|
|
117
|
+
#eq-note { font-size: 12px; color: var(--text-2); line-height: 1.6; }
|
|
118
|
+
#eq-transcribe { margin-top: 8px; }
|
|
119
|
+
#eq-toggle.muted { opacity: .55; }
|
|
120
|
+
#ask-copy { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text-2); cursor: pointer; margin-right: auto; }
|
|
121
|
+
|
|
90
122
|
/* ---------- 选中即问浮层 ---------- */
|
|
91
123
|
#ask-pop { position: fixed; z-index: 50; display: none; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 6px 24px rgba(0,0,0,.16); padding: 10px; width: 320px; }
|
|
92
124
|
#ask-quote { font-size: 12px; color: var(--text-2); max-height: 64px; overflow: hidden; margin-bottom: 8px; border-left: 3px solid var(--accent-soft); padding-left: 8px; }
|
|
@@ -118,22 +150,23 @@
|
|
|
118
150
|
<div id="bar">
|
|
119
151
|
<span id="status">论文伴读</span>
|
|
120
152
|
<button id="pdf-path" style="display:none" title="点击复制文件路径"></button>
|
|
121
|
-
<button id="zh-toggle" class="tbtn" title="切换中文译文(没有译文时可生成)" style="display:none"
|
|
122
|
-
<button id="zh-regen" class="tbtn" title="重新翻译:删除现有译文,用当前端点重跑(换模型后用)" style="display:none"
|
|
123
|
-
<button id="dl-btn" class="tbtn" title="下载 PDF" style="display:none"
|
|
153
|
+
<button id="zh-toggle" class="tbtn" title="切换中文译文(没有译文时可生成)" style="display:none" data-icon="translate"></button>
|
|
154
|
+
<button id="zh-regen" class="tbtn" title="重新翻译:删除现有译文,用当前端点重跑(换模型后用)" style="display:none" data-icon="regen"></button>
|
|
155
|
+
<button id="dl-btn" class="tbtn" title="下载 PDF" style="display:none" data-icon="download"></button>
|
|
124
156
|
<span class="spacer"></span>
|
|
125
|
-
<button id="mode-toggle" class="tbtn" title="切换 滚动/翻页模式" style="display:none"
|
|
157
|
+
<button id="mode-toggle" class="tbtn" title="切换 滚动/翻页模式" style="display:none" data-icon="paged"></button>
|
|
126
158
|
<span id="pager" style="display:none">
|
|
127
|
-
<button id="page-prev" class="tbtn" title="上一页"
|
|
159
|
+
<button id="page-prev" class="tbtn" title="上一页" data-icon="prev"></button>
|
|
128
160
|
<input id="page-input" type="text" inputmode="numeric" title="页码,回车跳转">
|
|
129
161
|
<span id="page-total">/ ?</span>
|
|
130
|
-
<button id="page-next" class="tbtn" title="下一页"
|
|
162
|
+
<button id="page-next" class="tbtn" title="下一页" data-icon="next"></button>
|
|
131
163
|
</span>
|
|
132
|
-
<button id="cfg-open" class="tbtn" title="翻译端点设置"
|
|
133
|
-
<button id="zoom-out" class="tbtn"
|
|
164
|
+
<button id="cfg-open" class="tbtn" title="翻译端点设置" data-icon="settings"></button>
|
|
165
|
+
<button id="zoom-out" class="tbtn" title="缩小" data-icon="minus"></button>
|
|
134
166
|
<span id="zoom-label">100%</span>
|
|
135
|
-
<button id="zoom-in" class="tbtn"
|
|
167
|
+
<button id="zoom-in" class="tbtn" title="放大" data-icon="plus"></button>
|
|
136
168
|
<button id="zoom-fit" class="tbtn">适宽</button>
|
|
169
|
+
<button id="eq-toggle" class="tbtn" title="公式引用(MinerU LaTeX)" data-icon="sigma">公式</button>
|
|
137
170
|
<span id="ver" title="插件版本">__DPR_VERSION__</span>
|
|
138
171
|
</div>
|
|
139
172
|
|
|
@@ -142,12 +175,18 @@
|
|
|
142
175
|
<div id="pdf-pages"></div>
|
|
143
176
|
<div id="empty" style="display:none"></div>
|
|
144
177
|
</div>
|
|
178
|
+
<div id="eq-panel">
|
|
179
|
+
<h3>公式</h3>
|
|
180
|
+
<div id="eq-meta"></div>
|
|
181
|
+
<div id="eq-list"></div>
|
|
182
|
+
</div>
|
|
145
183
|
</div>
|
|
146
184
|
|
|
147
185
|
<div id="ask-pop">
|
|
148
186
|
<div id="ask-quote"></div>
|
|
149
187
|
<textarea id="ask-input" rows="2" placeholder="围绕选中内容提问…"></textarea>
|
|
150
188
|
<div id="ask-actions">
|
|
189
|
+
<button id="ask-copy" style="display:none">复制 LaTeX</button>
|
|
151
190
|
<button id="ask-cancel">取消</button>
|
|
152
191
|
<button id="ask-send">提问</button>
|
|
153
192
|
</div>
|
|
@@ -179,6 +218,25 @@
|
|
|
179
218
|
'use strict';
|
|
180
219
|
/* ================= 基础 ================= */
|
|
181
220
|
const $ = (id) => document.getElementById(id);
|
|
221
|
+
/* 顶栏图标:统一的 24×24 描边图标集(stroke=currentColor,active/disabled 变色自动跟随按钮)。
|
|
222
|
+
曾经混用 Unicode 字符(↻/⤓/▣/⚙/+…)——各自渲染自不同字体,粗细、大小、基线互不一致。 */
|
|
223
|
+
const ICONS = {
|
|
224
|
+
translate: '<path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/>',
|
|
225
|
+
regen: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
|
|
226
|
+
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
|
227
|
+
paged: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
|
|
228
|
+
scroll: '<path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
|
|
229
|
+
prev: '<path d="m15 18-6-6 6-6"/>',
|
|
230
|
+
next: '<path d="m9 18 6-6-6-6"/>',
|
|
231
|
+
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
|
232
|
+
minus: '<path d="M5 12h14"/>',
|
|
233
|
+
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
|
234
|
+
sigma: '<path d="M18 7V5H6l6 7-6 7h12v-2"/>',
|
|
235
|
+
spin: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>',
|
|
236
|
+
};
|
|
237
|
+
const iconHtml = (name) => `<svg class="ic${name === 'spin' ? ' spin' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`;
|
|
238
|
+
// 静态按钮按 data-icon 注入一次;动态按钮(翻译/重翻/模式切换)在状态变化时用同一函数换图标
|
|
239
|
+
document.querySelectorAll('#bar [data-icon]').forEach((b) => b.insertAdjacentHTML('afterbegin', iconHtml(b.dataset.icon)));
|
|
182
240
|
const api = (p, opts) => fetch('/paper-reader/api' + p, opts).then(async (r) => {
|
|
183
241
|
if (!r.ok) {
|
|
184
242
|
// 服务端错误体有两种:{error}(参数/权限)与 {reason}(zh/generate 的未启动原因,带完整说明)
|
|
@@ -196,16 +254,11 @@ function toast(msg, ms = 3200) {
|
|
|
196
254
|
}
|
|
197
255
|
function esc(s) { return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
|
198
256
|
|
|
199
|
-
//
|
|
257
|
+
// 路径复制:与「复制 LaTeX」共用同一个 copyText(剪贴板写入只有一处实现,见文件末尾定义)
|
|
200
258
|
$('pdf-path').onclick = async () => {
|
|
201
259
|
const p = $('pdf-path').textContent;
|
|
202
260
|
if (!p) return;
|
|
203
|
-
|
|
204
|
-
const ta = document.createElement('textarea');
|
|
205
|
-
ta.value = p; document.body.appendChild(ta); ta.select();
|
|
206
|
-
try { document.execCommand('copy'); toast('路径已复制'); }
|
|
207
|
-
catch { toast('复制失败,路径见按钮悬停提示'); }
|
|
208
|
-
ta.remove();
|
|
261
|
+
toast((await copyText(p)) ? '路径已复制' : '复制失败,路径见按钮悬停提示');
|
|
209
262
|
};
|
|
210
263
|
|
|
211
264
|
let current = null; // { topic, name }
|
|
@@ -250,6 +303,10 @@ async function openPaper(topic, name, opts = {}) {
|
|
|
250
303
|
zhVariant = (saved === 'zh' && zhState.zh) || (saved === 'dual' && zhState.dual) ? saved : null;
|
|
251
304
|
$('zh-toggle').classList.toggle('active', zhVariant !== null);
|
|
252
305
|
if (zhVariant) { fitMode = true; fitBtn.classList.add('active'); } // 译文版页宽不同,强制适宽
|
|
306
|
+
// 公式索引(MinerU content_list):**必须在 loadPdf 之前到货**——热区是在 paintPage 里
|
|
307
|
+
// 按 eqIndex 铺的,先渲染会漏画/残留上一篇的矩形;又必须排在 zhVariant 定稿之后——
|
|
308
|
+
// eqHotEnabled()/renderEqPanel() 都按 zhVariant 决定「译文视图关热区」。失败不影响打开文献。
|
|
309
|
+
await loadFormulas();
|
|
253
310
|
updateDlBtn();
|
|
254
311
|
await loadPdf(pdfUrl(zhVariant));
|
|
255
312
|
if (opts.page) jumpTo(opts.page, opts.q); // 不 await:跳转内部自带「等页面渲出来」轮询
|
|
@@ -282,7 +339,7 @@ async function refreshZhState() {
|
|
|
282
339
|
const btn = $('zh-toggle');
|
|
283
340
|
btn.style.display = '';
|
|
284
341
|
btn.disabled = zhState.busy;
|
|
285
|
-
btn.
|
|
342
|
+
btn.innerHTML = iconHtml(zhState.busy ? 'spin' : 'translate'); // busy:旋转弧图标(原「译」字)
|
|
286
343
|
btn.title = zhState.busy ? (zhState.phase || `翻译进行中${zhState.model ? `(${zhState.model})` : ''}…`)
|
|
287
344
|
: (zhState.zh || zhState.dual) ? '切换中文译文'
|
|
288
345
|
: '还没有中文版,点击生成(babeldoc,需要几分钟)';
|
|
@@ -295,7 +352,7 @@ async function refreshZhState() {
|
|
|
295
352
|
delete statusEl.dataset.phaseShown;
|
|
296
353
|
if (statusMeta) statusEl.textContent = statusMeta;
|
|
297
354
|
}
|
|
298
|
-
//
|
|
355
|
+
// 重翻按钮只在「已有译文且空闲」时出现;状态变化时顺手拆掉两击确认的待确认态
|
|
299
356
|
const regen = $('zh-regen');
|
|
300
357
|
const canRegen = (zhState.zh || zhState.dual) && !zhState.busy;
|
|
301
358
|
regen.style.display = canRegen ? '' : 'none';
|
|
@@ -312,6 +369,7 @@ $('zh-toggle').onclick = async () => {
|
|
|
312
369
|
updateDlBtn();
|
|
313
370
|
// 切换变体后强制适宽:译文版页宽与原文不同(对照双栏≈2×),不重算会溢出裁切
|
|
314
371
|
fitMode = true; fitBtn.classList.add('active');
|
|
372
|
+
renderEqPanel(); // 译文视图要给出「没有位置信息」的说明(热区同时被 eqHotEnabled 关掉)
|
|
315
373
|
await loadPdf(pdfUrl(zhVariant));
|
|
316
374
|
return;
|
|
317
375
|
}
|
|
@@ -334,7 +392,7 @@ async function startGenerate(force) {
|
|
|
334
392
|
if (e.code === 'no-endpoint') { cfgRetryForce = force; openCfgPop(true); return; }
|
|
335
393
|
toast(e.message, 8000); return;
|
|
336
394
|
}
|
|
337
|
-
$('zh-toggle').disabled = true; $('zh-toggle').
|
|
395
|
+
$('zh-toggle').disabled = true; $('zh-toggle').innerHTML = iconHtml('spin');
|
|
338
396
|
$('zh-regen').style.display = 'none';
|
|
339
397
|
const timer = setInterval(async () => {
|
|
340
398
|
await refreshZhState();
|
|
@@ -351,14 +409,14 @@ async function startGenerate(force) {
|
|
|
351
409
|
}, 5000);
|
|
352
410
|
}
|
|
353
411
|
|
|
354
|
-
/* =================
|
|
412
|
+
/* ================= 重新翻译(重翻按钮,两击确认) ================= */
|
|
355
413
|
// 不用原生 confirm:阅读器跑在 dsh 桌面壳的 iframe 里,原生对话框行为不可靠。
|
|
356
414
|
// 第一击把按钮变成「重翻?」待确认态(3 秒有效),第二击才真删真跑。
|
|
357
415
|
let regenArmTimer = null;
|
|
358
416
|
function disarmRegen() {
|
|
359
417
|
clearTimeout(regenArmTimer); regenArmTimer = null;
|
|
360
418
|
const btn = $('zh-regen');
|
|
361
|
-
btn.
|
|
419
|
+
btn.innerHTML = iconHtml('regen'); btn.classList.remove('active');
|
|
362
420
|
}
|
|
363
421
|
$('zh-regen').onclick = async () => {
|
|
364
422
|
if (!current || zhState.busy) return;
|
|
@@ -403,9 +461,9 @@ const providerOf = (baseUrl) => PROVIDERS.find((p) => p.id !== 'custom' && normU
|
|
|
403
461
|
};
|
|
404
462
|
})();
|
|
405
463
|
|
|
406
|
-
//
|
|
464
|
+
// 保存成功后是否自动接着生成:点「中译」/「重翻」发现没配 → 弹本表单 → 填完直接开跑
|
|
407
465
|
let cfgRetryGenerate = false;
|
|
408
|
-
let cfgRetryForce = false; //
|
|
466
|
+
let cfgRetryForce = false; // 待重试的是不是「重翻」(重翻进来时旧译文还在,zh-toggle 点击语义不对,必须带 force)
|
|
409
467
|
|
|
410
468
|
async function openCfgPop(retryGenerate) {
|
|
411
469
|
cfgRetryGenerate = Boolean(retryGenerate);
|
|
@@ -465,7 +523,7 @@ $('cfg-save').onclick = async () => {
|
|
|
465
523
|
}
|
|
466
524
|
closeCfgPop();
|
|
467
525
|
await refreshZhState();
|
|
468
|
-
if (retry) startGenerate(force); //
|
|
526
|
+
if (retry) startGenerate(force); // 沿用触发来源的语义:「中译」=生成,「重翻」=重翻
|
|
469
527
|
else toast('翻译端点已保存');
|
|
470
528
|
};
|
|
471
529
|
|
|
@@ -556,6 +614,15 @@ async function paintPage(page, div, viewport, token) {
|
|
|
556
614
|
if (token !== renderToken) return;
|
|
557
615
|
// pdf.js 6.x: renderTextLayer() 移除,改 TextLayer 类
|
|
558
616
|
await new pdfjsLib.TextLayer({ textContentSource: tc, container: tl, viewport }).render();
|
|
617
|
+
// 公式热区层(pointer-events:none,只画描边;命中在 JS 里按坐标算)。
|
|
618
|
+
// 唯一铺层点放在这里(不是各自的渲染循环里):滚动与翻页两条路径共用它——
|
|
619
|
+
// renderPaged 完全绕过 renderAllPages 的循环,写在那边翻页模式热区会全哑。
|
|
620
|
+
// 也不放进 makePageNode:它靠 div.firstChild/lastChild 取 canvas/textLayer,
|
|
621
|
+
// 提前 append 热区层会让 lastChild 变成热区层 → TextLayer 挂错容器、文本层错位。
|
|
622
|
+
// token 守卫(t4 评审 O5):TextLayer.render() 也是 await,期间可能已被更新的渲染抢占;
|
|
623
|
+
// 少了这一行,被抢占的收尾会把**上一档缩放**的几何写回 EQ_BY_PAGE → 点击热区整体偏移。
|
|
624
|
+
if (token !== renderToken) return;
|
|
625
|
+
paintEqLayer(div, viewport, +div.dataset.page);
|
|
559
626
|
}
|
|
560
627
|
|
|
561
628
|
/** 当前模式的全量重渲入口:缩放/DPR/换文档/换变体统一走这里。 */
|
|
@@ -580,7 +647,8 @@ async function renderAllPages() {
|
|
|
580
647
|
const range = pane.scrollHeight - pane.clientHeight;
|
|
581
648
|
const anchorRatio = range > 0 ? pane.scrollTop / range : 0;
|
|
582
649
|
container.innerHTML = '';
|
|
583
|
-
container.classList.remove('paged');
|
|
650
|
+
container.classList.remove('paged'); // 切回滚动:必须摘掉单页布局类(paged 由 renderPaged 加上)
|
|
651
|
+
clearEqGeometry(); // 旧页几何 + 跨页残留的 hover/hit 描边随 DOM 一起作废
|
|
584
652
|
$('zoom-label').textContent = Math.round(scale / 1.5 * 100) + '%';
|
|
585
653
|
|
|
586
654
|
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
|
@@ -590,6 +658,7 @@ async function renderAllPages() {
|
|
|
590
658
|
const viewport = page.getViewport({ scale });
|
|
591
659
|
const div = makePageNode(i, viewport);
|
|
592
660
|
container.appendChild(div);
|
|
661
|
+
// 位图 + 文本层 + 公式热区层都在 paintPage 里(滚动/翻页两条路径共用同一个铺层点)
|
|
593
662
|
await paintPage(page, div, viewport, token);
|
|
594
663
|
if (token !== renderToken) return;
|
|
595
664
|
}
|
|
@@ -606,6 +675,7 @@ async function renderPaged() {
|
|
|
606
675
|
const scale = zoomScale; // 同 renderAllPages:快照,防混合缩放
|
|
607
676
|
curPage = Math.min(Math.max(1, curPage), pdfDoc.numPages);
|
|
608
677
|
container.innerHTML = '';
|
|
678
|
+
clearEqGeometry(); // 同上:翻页模式下换页/缩放时,旧页几何与 hover 残留必须一起作废(否则命中整体偏移)
|
|
609
679
|
container.classList.add('paged');
|
|
610
680
|
$('zoom-label').textContent = Math.round(scale / 1.5 * 100) + '%';
|
|
611
681
|
updatePager();
|
|
@@ -705,10 +775,10 @@ $('page-input').addEventListener('blur', () => {
|
|
|
705
775
|
});
|
|
706
776
|
|
|
707
777
|
/** 模式切换:paged→scroll 先全量渲染再跳回当前页;scroll→paged 用观察器最近回写的 curPage。
|
|
708
|
-
|
|
778
|
+
图标显示「当前模式」:书本 = 单页翻页(active)/ 卷轴 = 连续滚动。 */
|
|
709
779
|
function updateModeToggle() {
|
|
710
780
|
const btn = $('mode-toggle');
|
|
711
|
-
btn.
|
|
781
|
+
btn.innerHTML = iconHtml(pageMode === 'paged' ? 'paged' : 'scroll');
|
|
712
782
|
btn.classList.toggle('active', pageMode === 'paged');
|
|
713
783
|
btn.title = pageMode === 'paged' ? '当前:单页翻页,点击切回连续滚动' : '当前:连续滚动,点击切到单页翻页';
|
|
714
784
|
updatePager();
|
|
@@ -753,6 +823,7 @@ function jumpToPage(n) {
|
|
|
753
823
|
找不到(如用户在中文界面而 q 是英文原文)返回 false,调用方退回仅跳页。 */
|
|
754
824
|
function clearHighlights() {
|
|
755
825
|
document.querySelectorAll('.textLayer span.dpr-hl').forEach((sp) => sp.classList.remove('dpr-hl'));
|
|
826
|
+
clearEqHit(); // 公式命中描边与引文高亮同生共死(用户在别处点一下即清)
|
|
756
827
|
}
|
|
757
828
|
// 高亮的消失交给用户:阅读器里任意点一下即清(mousedown 比 click 早,划选文字的那一下也算)
|
|
758
829
|
document.addEventListener('mousedown', clearHighlights);
|
|
@@ -805,35 +876,379 @@ async function jumpTo(page, q) {
|
|
|
805
876
|
}
|
|
806
877
|
}
|
|
807
878
|
|
|
808
|
-
/* =================
|
|
879
|
+
/* ================= 公式引用(MinerU LaTeX,契约 §1) =================
|
|
880
|
+
PDF 文本层里的公式是乱码(`𝑧𝑧1 𝑧𝑧2`),鼠标选中拿不到可用内容;真相源是 MinerU
|
|
881
|
+
content_list 的 equation 块(LaTeX + bbox)。bbox 坐标系实测为「页面归一化坐标 ×1000」
|
|
882
|
+
(x÷页宽、y÷页高,各自归一),映射直接用渲染时的同一个 viewport,因此与缩放无关:
|
|
883
|
+
x = bbox[0]/1000*viewport.width ,y = bbox[1]/1000*viewport.height
|
|
884
|
+
热区外扩 6pt(实测最坏欠覆盖 4.60pt;6pt 覆盖全部 17/17 公式块)。 */
|
|
885
|
+
const EQ_PAD = 6; // pt(= scale 1 时的 CSS px),随 zoomScale 放大
|
|
886
|
+
let eqIndex = { source: 'none', equations: [], reason: 'no-mineru-artifact', bboxSpace: 'normalized-1000' };
|
|
887
|
+
const EQ_BY_PAGE = new Map(); // pageNo → [{eq,x0,y0,x1,y1,w,h,pad}]
|
|
888
|
+
let eqHoverId = null;
|
|
889
|
+
|
|
890
|
+
/** 页节点被整体重建(renderAllPages / renderPaged 里 `container.innerHTML = ''`)时调用:
|
|
891
|
+
旧页的几何必须一起作废,否则命中测试会打到已卸载的页/上一档缩放的矩形(翻页模式下表现为
|
|
892
|
+
「点击整体偏移」);顺手清掉跨页残留的 hover 态 —— eqHoverId 不清的话,setEqHover(id) 会在
|
|
893
|
+
`id === eqHoverId` 时提前 return,换页后鼠标停在公式上也不再出现虚线描边。 */
|
|
894
|
+
function clearEqGeometry() {
|
|
895
|
+
EQ_BY_PAGE.clear();
|
|
896
|
+
eqHoverId = null;
|
|
897
|
+
clearEqHit();
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** 去掉 MinerU 文本外层 `$$`:`$$\n\\pi…\n$$` → `\pi…` */
|
|
901
|
+
function eqLatex(raw) {
|
|
902
|
+
return String(raw == null ? '' : raw).replace(/^\s*\$\$?/, '').replace(/\$\$?\s*$/, '').trim();
|
|
903
|
+
}
|
|
904
|
+
/** 进会话的引用串:保留 `$$` 定界(模型对数学最友好) */
|
|
905
|
+
function eqCite(eq) { return '$$ ' + eqLatex(eq.latex) + ' $$'; }
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* MinerU **生效 mode**(GET /mineru/config 的 mode 字段):'off' | 'local' | 'cloud' | null(未知)。
|
|
909
|
+
* 它是「重新转录」按钮的门控依据——`source:'auto'` 会按 mode 选引擎,而 mode==='off' 时
|
|
910
|
+
* auto 落到 pdf.js,pdf.js 重解析会**删掉** <文献>.mineru.md/.mineru.json 富产物
|
|
911
|
+
* (src/transcribe.ts);宁可少一个按钮,也不给一个会反噬的按钮。
|
|
912
|
+
*/
|
|
913
|
+
let mineruMode = null;
|
|
914
|
+
async function loadMineruMode() {
|
|
915
|
+
try {
|
|
916
|
+
const c = await api('/mineru/config');
|
|
917
|
+
mineruMode = c && typeof c.mode === 'string' ? c.mode : null;
|
|
918
|
+
} catch { mineruMode = null; } // 取不到按「未知」处理(照常给按钮,行为与旧版一致)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function loadFormulas() {
|
|
922
|
+
return (async () => {
|
|
923
|
+
eqIndex = { source: 'none', equations: [], reason: 'no-mineru-artifact', bboxSpace: 'normalized-1000' };
|
|
924
|
+
if (!current) { renderEqPanel(); return; }
|
|
925
|
+
await loadMineruMode();
|
|
926
|
+
const q = `topic=${encodeURIComponent(current.topic)}&name=${encodeURIComponent(current.name)}`;
|
|
927
|
+
try {
|
|
928
|
+
const idx = await api('/formulas?' + q);
|
|
929
|
+
if (idx && Array.isArray(idx.equations)) eqIndex = idx;
|
|
930
|
+
} catch (e) {
|
|
931
|
+
// 接口异常绝不打崩阅读器:降级成「无公式」,划词提问照常
|
|
932
|
+
eqIndex = { source: 'none', equations: [], reason: 'bad-artifact', warning: e.message, bboxSpace: 'normalized-1000' };
|
|
933
|
+
}
|
|
934
|
+
const btn = $('eq-toggle');
|
|
935
|
+
btn.classList.toggle('muted', eqIndex.equations.length === 0);
|
|
936
|
+
btn.title = eqIndex.equations.length
|
|
937
|
+
? `公式引用:${eqIndex.equations.length} 个(点击 PDF 上的公式即可引用其 LaTeX)`
|
|
938
|
+
: '公式引用:本论文还没有 MinerU 解析产物';
|
|
939
|
+
renderEqPanel();
|
|
940
|
+
})();
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** 热区只在「原文视图 + 有 bbox」时开启:译文版(-zh/-dual)页面几何与原文不同。 */
|
|
944
|
+
function eqHotEnabled() {
|
|
945
|
+
return zhVariant === null && eqIndex.equations.some((e) => e.bbox);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function eqGeom(eq, viewport) {
|
|
949
|
+
if (!eq.bbox) return null;
|
|
950
|
+
const space = eqIndex.bboxSpace === 'page-units'; // 兜底分支(云端版本差异,见需求 R3)
|
|
951
|
+
const kx = space ? zoomScale : viewport.width / 1000;
|
|
952
|
+
const ky = space ? zoomScale : viewport.height / 1000;
|
|
953
|
+
const x = eq.bbox[0] * kx;
|
|
954
|
+
const y = eq.bbox[1] * ky;
|
|
955
|
+
const w = (eq.bbox[2] - eq.bbox[0]) * kx;
|
|
956
|
+
const h = (eq.bbox[3] - eq.bbox[1]) * ky;
|
|
957
|
+
if (!(w > 0) || !(h > 0)) return null;
|
|
958
|
+
return {
|
|
959
|
+
eq, x, y, w, h, pad: EQ_PAD * zoomScale,
|
|
960
|
+
x0: Math.max(0, x), y0: Math.max(0, y),
|
|
961
|
+
x1: Math.min(viewport.width, x + w), y1: Math.min(viewport.height, y + h),
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** 为已渲染的一页铺热区层(只画描边,不接事件)。 */
|
|
966
|
+
function paintEqLayer(pageDiv, viewport, pageNo) {
|
|
967
|
+
const old = pageDiv.querySelector('.eqLayer');
|
|
968
|
+
if (old) old.remove();
|
|
969
|
+
const items = [];
|
|
970
|
+
if (eqHotEnabled()) {
|
|
971
|
+
const lay = document.createElement('div');
|
|
972
|
+
lay.className = 'eqLayer';
|
|
973
|
+
lay.style.width = viewport.width + 'px';
|
|
974
|
+
lay.style.height = viewport.height + 'px';
|
|
975
|
+
for (const eq of eqIndex.equations) {
|
|
976
|
+
if (eq.page !== pageNo) continue;
|
|
977
|
+
const g = eqGeom(eq, viewport);
|
|
978
|
+
if (!g) continue;
|
|
979
|
+
items.push(g);
|
|
980
|
+
const b = document.createElement('div');
|
|
981
|
+
b.className = 'eq-box';
|
|
982
|
+
b.dataset.eqId = eq.id;
|
|
983
|
+
b.style.left = g.x0 + 'px';
|
|
984
|
+
b.style.top = g.y0 + 'px';
|
|
985
|
+
b.style.width = Math.max(1, g.x1 - g.x0) + 'px';
|
|
986
|
+
b.style.height = Math.max(1, g.y1 - g.y0) + 'px';
|
|
987
|
+
lay.appendChild(b);
|
|
988
|
+
}
|
|
989
|
+
pageDiv.appendChild(lay);
|
|
990
|
+
}
|
|
991
|
+
EQ_BY_PAGE.set(pageNo, items);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* 命中判定(契约 §1.4):
|
|
996
|
+
* 1) 未外扩矩形包含点击点 → 取面积最小者(并列取阅读序靠前);
|
|
997
|
+
* 2) 否则取外扩后包含该点、且到未外扩矩形距离最小者(并列同上);
|
|
998
|
+
* 3) 都没有 → 不是公式点击。
|
|
999
|
+
* 为什么必须严格:实测同一页相邻公式块竖直间隙最小只有 0.8pt,6pt 外扩后必然重叠。
|
|
1000
|
+
*/
|
|
1001
|
+
function hitTestFormula(e) {
|
|
1002
|
+
if (!eqHotEnabled()) return null;
|
|
1003
|
+
const el = e.target;
|
|
1004
|
+
const pageDiv = el && el.closest ? el.closest('.page[data-page]') : null;
|
|
1005
|
+
if (!pageDiv) return null;
|
|
1006
|
+
const items = EQ_BY_PAGE.get(+pageDiv.dataset.page);
|
|
1007
|
+
if (!items || !items.length) return null;
|
|
1008
|
+
const box = pageDiv.getBoundingClientRect();
|
|
1009
|
+
const px = e.clientX - box.left;
|
|
1010
|
+
const py = e.clientY - box.top;
|
|
1011
|
+
let best = null;
|
|
1012
|
+
for (const it of items) {
|
|
1013
|
+
if (px >= it.x0 && px <= it.x1 && py >= it.y0 && py <= it.y1) {
|
|
1014
|
+
if (!best || it.w * it.h < best.w * best.h) best = it;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (best) return best;
|
|
1018
|
+
let bestD = Infinity;
|
|
1019
|
+
for (const it of items) {
|
|
1020
|
+
if (px < it.x0 - it.pad || px > it.x1 + it.pad || py < it.y0 - it.pad || py > it.y1 + it.pad) continue;
|
|
1021
|
+
const dx = px < it.x0 ? it.x0 - px : (px > it.x1 ? px - it.x1 : 0);
|
|
1022
|
+
const dy = py < it.y0 ? it.y0 - py : (py > it.y1 ? py - it.y1 : 0);
|
|
1023
|
+
const d = dx * dx + dy * dy;
|
|
1024
|
+
if (d < bestD - 1e-6) { bestD = d; best = it; }
|
|
1025
|
+
else if (Math.abs(d - bestD) <= 1e-6 && best && it.w * it.h < best.w * best.h) best = it;
|
|
1026
|
+
}
|
|
1027
|
+
return best;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function setEqHover(id) {
|
|
1031
|
+
if (id === eqHoverId) return;
|
|
1032
|
+
eqHoverId = id;
|
|
1033
|
+
document.querySelectorAll('.eq-box.hover').forEach((el) => el.classList.remove('hover'));
|
|
1034
|
+
if (id) {
|
|
1035
|
+
const el = document.querySelector(`.eq-box[data-eq-id="${id}"]`);
|
|
1036
|
+
if (el) el.classList.add('hover');
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function clearEqHit() { document.querySelectorAll('.eq-box.hit').forEach((el) => el.classList.remove('hit')); }
|
|
1041
|
+
|
|
1042
|
+
function markEqHit(eq) {
|
|
1043
|
+
clearEqHit();
|
|
1044
|
+
const el = document.querySelector(`.eq-box[data-eq-id="${eq.id}"]`);
|
|
1045
|
+
if (el) el.classList.add('hit');
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** 引用一个公式:热区点击与面板条目共用这一条链路(契约 §1.5)。 */
|
|
1049
|
+
function citeFormula(eq, x, y) {
|
|
1050
|
+
askSelection = {
|
|
1051
|
+
text: eqCite(eq),
|
|
1052
|
+
page: eq.page,
|
|
1053
|
+
kind: 'equation',
|
|
1054
|
+
latex: eqLatex(eq.latex),
|
|
1055
|
+
equationIndex: eq.index,
|
|
1056
|
+
};
|
|
1057
|
+
markEqHit(eq);
|
|
1058
|
+
const tag = `第 ${eq.page} 页 · 公式 ${eq.index}`;
|
|
1059
|
+
const body = eqLatex(eq.latex).replace(/\s+/g, ' ');
|
|
1060
|
+
showAskPop({ quote: body ? `${tag}:${body}` : tag, x, y });
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function renderEqPanel() {
|
|
1064
|
+
const meta = $('eq-meta');
|
|
1065
|
+
const list = $('eq-list');
|
|
1066
|
+
const eqs = eqIndex.equations;
|
|
1067
|
+
list.innerHTML = '';
|
|
1068
|
+
if (eqs.length) {
|
|
1069
|
+
const src = [eqIndex.producer || 'MinerU', eqIndex.backend].filter(Boolean).join(' · ');
|
|
1070
|
+
meta.textContent = `${eqs.length} 个公式 · ${src}${eqIndex.warning ? '(' + eqIndex.warning + ')' : ''}`;
|
|
1071
|
+
} else {
|
|
1072
|
+
meta.textContent = '';
|
|
1073
|
+
}
|
|
1074
|
+
if (zhVariant !== null) {
|
|
1075
|
+
const n = document.createElement('div');
|
|
1076
|
+
n.id = 'eq-note';
|
|
1077
|
+
n.textContent = '当前是译文视图:没有公式位置信息(bbox 只对原文页面成立),切回原文即可点击公式。';
|
|
1078
|
+
list.appendChild(n);
|
|
1079
|
+
}
|
|
1080
|
+
if (!eqs.length) {
|
|
1081
|
+
const n = document.createElement('div');
|
|
1082
|
+
n.id = 'eq-note';
|
|
1083
|
+
n.textContent = eqIndex.reason === 'bad-artifact'
|
|
1084
|
+
? 'MinerU 产物损坏或不可读,已忽略(不影响阅读与划词提问)。'
|
|
1085
|
+
: eqIndex.reason === 'no-equations'
|
|
1086
|
+
? '这篇论文没有解析出公式块(MinerU 产物存在,但 content_list 里没有 equation)。'
|
|
1087
|
+
: mineruMode === 'off'
|
|
1088
|
+
? '未启用 MinerU:请到 dsh 设置 → 论文伴读 → MinerU 解析后端 选择「本地」或「云端」并保存,然后回到这里点「重新转录」。'
|
|
1089
|
+
: '未找到 MinerU 解析产物。在 dsh 设置 → 论文伴读 → MinerU 解析后端 启用后点下面按钮重新转录。';
|
|
1090
|
+
list.appendChild(n);
|
|
1091
|
+
// 门控:mode==='off' 时不渲染按钮(auto 会落到 pdf.js 并删掉已有的 MinerU 富产物);
|
|
1092
|
+
// mode 未知(接口不可用)时照旧给按钮,保持与旧版一致的行为。
|
|
1093
|
+
const canRetranscribe = (eqIndex.reason === 'no-mineru-artifact' || eqIndex.reason === 'no-equations') && mineruMode !== 'off';
|
|
1094
|
+
if (canRetranscribe) {
|
|
1095
|
+
const b = document.createElement('button');
|
|
1096
|
+
b.id = 'eq-transcribe';
|
|
1097
|
+
b.className = 'tbtn';
|
|
1098
|
+
b.textContent = '用 MinerU 重新转录';
|
|
1099
|
+
b.onclick = () => transcribeWithMineru(b);
|
|
1100
|
+
list.appendChild(b);
|
|
1101
|
+
}
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
const byPage = new Map();
|
|
1105
|
+
for (const eq of eqs) {
|
|
1106
|
+
if (!byPage.has(eq.page)) byPage.set(eq.page, []);
|
|
1107
|
+
byPage.get(eq.page).push(eq);
|
|
1108
|
+
}
|
|
1109
|
+
for (const [page, arr] of byPage) {
|
|
1110
|
+
const h = document.createElement('div');
|
|
1111
|
+
h.className = 'eq-page';
|
|
1112
|
+
h.textContent = `第 ${page} 页`;
|
|
1113
|
+
list.appendChild(h);
|
|
1114
|
+
for (const eq of arr) {
|
|
1115
|
+
const item = document.createElement('button');
|
|
1116
|
+
item.type = 'button';
|
|
1117
|
+
item.className = 'eq-item';
|
|
1118
|
+
item.dataset.eqId = eq.id;
|
|
1119
|
+
const tag = document.createElement('span');
|
|
1120
|
+
tag.className = 'eq-tag';
|
|
1121
|
+
tag.textContent = `公式 ${eq.index}${eq.bbox ? '' : ' · 无位置信息'}`;
|
|
1122
|
+
const code = document.createElement('code');
|
|
1123
|
+
code.textContent = eqLatex(eq.latex) || '(空)';
|
|
1124
|
+
item.appendChild(tag);
|
|
1125
|
+
item.appendChild(code);
|
|
1126
|
+
item.onclick = () => {
|
|
1127
|
+
const r = item.getBoundingClientRect();
|
|
1128
|
+
citeFormula(eq, r.left, r.bottom + 6);
|
|
1129
|
+
};
|
|
1130
|
+
list.appendChild(item);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async function transcribeWithMineru(btn) {
|
|
1136
|
+
if (!current) return;
|
|
1137
|
+
const old = btn.textContent;
|
|
1138
|
+
btn.disabled = true;
|
|
1139
|
+
btn.textContent = '转录中(可能需要几十秒)…';
|
|
1140
|
+
try {
|
|
1141
|
+
await api('/transcribe', {
|
|
1142
|
+
method: 'POST',
|
|
1143
|
+
headers: { 'content-type': 'application/json' },
|
|
1144
|
+
// source:'auto' = 按**当前生效 mode** 选引擎(local → 本地 MinerU;cloud → mineru.net 云端;
|
|
1145
|
+
// off → pdf.js)。硬编码 'mineru-local' 会让配了云端的用户点一次失败一次(上游评审第 6 条)。
|
|
1146
|
+
// 按钮本身在 mode==='off' 时不渲染(见 renderEqPanel 的门控),所以走到这里 mode 必为 local/cloud。
|
|
1147
|
+
body: JSON.stringify({ ...current, force: true, source: 'auto' }),
|
|
1148
|
+
});
|
|
1149
|
+
toast('MinerU 转录完成');
|
|
1150
|
+
await loadFormulas();
|
|
1151
|
+
// 新索引到手:按**当前模式**重渲一次把热区铺上去(翻页模式下必须仍是 renderPaged,
|
|
1152
|
+
// 否则会把全量页渲出来、摘掉 #pdf-pages 的 paged 类,与顶栏模式切换按钮的 active 状态脱节)
|
|
1153
|
+
if (pdfDoc) await rerender();
|
|
1154
|
+
} catch (e) {
|
|
1155
|
+
toast('MinerU 转录失败:' + e.message, 6000);
|
|
1156
|
+
} finally {
|
|
1157
|
+
if (document.body.contains(btn)) { btn.disabled = false; btn.textContent = old; }
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
$('eq-toggle').onclick = () => {
|
|
1162
|
+
const open = $('eq-panel').style.display !== 'block';
|
|
1163
|
+
$('eq-panel').style.display = open ? 'block' : 'none';
|
|
1164
|
+
$('eq-toggle').classList.toggle('active', open);
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
let lastMove = { x: -1, y: -1 };
|
|
1168
|
+
document.addEventListener('mousemove', (e) => {
|
|
1169
|
+
if (!eqHotEnabled()) { setEqHover(null); return; }
|
|
1170
|
+
if (Math.abs(e.clientX - lastMove.x) < 2 && Math.abs(e.clientY - lastMove.y) < 2) return;
|
|
1171
|
+
lastMove = { x: e.clientX, y: e.clientY };
|
|
1172
|
+
const hit = hitTestFormula(e);
|
|
1173
|
+
setEqHover(hit ? hit.eq.id : null);
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
/* ================= 选中即问(文字 + 公式共用一条链路) ================= */
|
|
809
1177
|
const askPop = $('ask-pop');
|
|
810
1178
|
let askSelection = null;
|
|
1179
|
+
// 拖拽起点:公式「点击」判定要求 mousedown→mouseup 位移很小(拖选文字不算点击)
|
|
1180
|
+
let downPt = null;
|
|
1181
|
+
|
|
1182
|
+
/** 弹层共用的展示动作(文字引用与公式引用都走这里)。 */
|
|
1183
|
+
function showAskPop(o) {
|
|
1184
|
+
$('ask-quote').textContent = o.quote.length > 140 ? o.quote.slice(0, 140) + '…' : o.quote;
|
|
1185
|
+
$('ask-input').value = '';
|
|
1186
|
+
$('ask-copy').style.display = askSelection && askSelection.kind === 'equation' ? 'inline-block' : 'none';
|
|
1187
|
+
askPop.style.display = 'block';
|
|
1188
|
+
// 夹在 iframe 视口内(rightbar 窄列里也要能用)
|
|
1189
|
+
const popW = 336, popH = 200;
|
|
1190
|
+
askPop.style.left = Math.max(8, Math.min(o.x, window.innerWidth - popW - 8)) + 'px';
|
|
1191
|
+
askPop.style.top = Math.max(8, Math.min(o.y, window.innerHeight - popH - 8)) + 'px';
|
|
1192
|
+
$('ask-input').focus();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
document.addEventListener('mousedown', (e) => { downPt = { x: e.clientX, y: e.clientY }; }, true);
|
|
811
1196
|
|
|
812
1197
|
document.addEventListener('mouseup', (e) => {
|
|
813
1198
|
if (askPop.contains(e.target)) return;
|
|
814
1199
|
setTimeout(() => {
|
|
1200
|
+
const moved = downPt ? Math.abs(e.clientX - downPt.x) + Math.abs(e.clientY - downPt.y) > 4 : false;
|
|
1201
|
+
downPt = null;
|
|
815
1202
|
const sel = window.getSelection();
|
|
816
1203
|
const text = sel ? sel.toString().trim() : '';
|
|
817
|
-
if (!text || !current) {
|
|
1204
|
+
if (!text || !current) {
|
|
1205
|
+
// 无选区(或没有当前文献)时才尝试公式点击 —— 有选区时「划词提问」优先,绝不抢。
|
|
1206
|
+
if (!text && !moved && current) {
|
|
1207
|
+
const hit = hitTestFormula(e);
|
|
1208
|
+
if (hit) { citeFormula(hit.eq, e.clientX, e.clientY + 6); return; }
|
|
1209
|
+
}
|
|
1210
|
+
askPop.style.display = 'none';
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
818
1213
|
const node = sel.anchorNode && sel.anchorNode.parentElement;
|
|
819
1214
|
if (!node || !node.closest('.textLayer')) { askPop.style.display = 'none'; return; }
|
|
820
1215
|
const range = sel.getRangeAt(0).getBoundingClientRect();
|
|
821
|
-
askSelection = { text, page: +((node.closest('.page') || {}).dataset?.page || 0) || null };
|
|
822
|
-
|
|
823
|
-
$('ask-input').value = '';
|
|
824
|
-
askPop.style.display = 'block';
|
|
825
|
-
// 夹在 iframe 视口内(rightbar 窄列里也要能用)
|
|
826
|
-
const popW = 336, popH = 190;
|
|
827
|
-
const x = Math.max(8, Math.min(e.clientX, window.innerWidth - popW - 8));
|
|
828
|
-
const y = Math.max(8, Math.min(range.bottom + 8, window.innerHeight - popH - 8));
|
|
829
|
-
askPop.style.left = x + 'px';
|
|
830
|
-
askPop.style.top = y + 'px';
|
|
831
|
-
$('ask-input').focus();
|
|
1216
|
+
askSelection = { text, page: +((node.closest('.page') || {}).dataset?.page || 0) || null, kind: 'text' };
|
|
1217
|
+
showAskPop({ quote: text, x: e.clientX, y: range.bottom + 8 });
|
|
832
1218
|
}, 10);
|
|
833
1219
|
});
|
|
834
1220
|
document.addEventListener('mousedown', (e) => { if (!askPop.contains(e.target)) askPop.style.display = 'none'; });
|
|
835
1221
|
$('ask-cancel').onclick = () => { askPop.style.display = 'none'; };
|
|
836
1222
|
|
|
1223
|
+
// 复制 LaTeX(显式动作:不自动写剪贴板,静默覆盖用户剪贴板是意外副作用)
|
|
1224
|
+
$('ask-copy').onclick = async () => {
|
|
1225
|
+
const latex = askSelection && askSelection.kind === 'equation' ? askSelection.text : '';
|
|
1226
|
+
if (!latex) return;
|
|
1227
|
+
// 只在用户显式点按钮时写剪贴板;引用公式/弹气泡本身绝不自动写
|
|
1228
|
+
toast((await copyText(latex)) ? '已复制公式 LaTeX' : '复制失败,请手动选择文本', 5000);
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
/** 剪贴板写入的**唯一**实现(路径复制与复制 LaTeX 共用):clipboard API 优先,
|
|
1232
|
+
* 失败/不可用(iframe 权限、非安全上下文)退回 textarea + execCommand。
|
|
1233
|
+
* 返回是否成功——文案由调用方决定。 */
|
|
1234
|
+
async function copyText(text) {
|
|
1235
|
+
try {
|
|
1236
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1237
|
+
await navigator.clipboard.writeText(text);
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1240
|
+
} catch { /* 走下面的兜底 */ }
|
|
1241
|
+
try {
|
|
1242
|
+
const ta = document.createElement('textarea');
|
|
1243
|
+
ta.value = text;
|
|
1244
|
+
document.body.appendChild(ta);
|
|
1245
|
+
ta.select();
|
|
1246
|
+
document.execCommand('copy');
|
|
1247
|
+
ta.remove();
|
|
1248
|
+
return true;
|
|
1249
|
+
} catch { return false; }
|
|
1250
|
+
}
|
|
1251
|
+
|
|
837
1252
|
$('ask-send').onclick = async () => {
|
|
838
1253
|
const question = $('ask-input').value.trim();
|
|
839
1254
|
if (!question || !askSelection || !current) return;
|
|
@@ -842,12 +1257,16 @@ $('ask-send').onclick = async () => {
|
|
|
842
1257
|
// 会话序号由侧栏树激活时写入 localStorage(默认最新)
|
|
843
1258
|
const key = `dpr.sessionN.${current.topic}/${current.name}`;
|
|
844
1259
|
const n = +(localStorage.getItem(key) || 0) || undefined;
|
|
1260
|
+
// 公式引用额外带 kind/equationIndex:host 侧据此换用「引用了公式(LaTeX 源)」文案
|
|
1261
|
+
const eq = askSelection.kind === 'equation'
|
|
1262
|
+
? { kind: 'equation', equationIndex: askSelection.equationIndex }
|
|
1263
|
+
: {};
|
|
845
1264
|
const r = await api('/ask', {
|
|
846
1265
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
847
|
-
body: JSON.stringify({ ...current, question, selectedText: askSelection.text, page: askSelection.page, ...(n ? { n } : {}) }),
|
|
1266
|
+
body: JSON.stringify({ ...current, question, selectedText: askSelection.text, page: askSelection.page, ...eq, ...(n ? { n } : {}) }),
|
|
848
1267
|
});
|
|
849
1268
|
askPop.style.display = 'none';
|
|
850
|
-
toast(`已发送到 会话${r.n},回答见对话区`);
|
|
1269
|
+
toast(askSelection.kind === 'equation' ? `已引用公式并发送到 会话${r.n}` : `已发送到 会话${r.n},回答见对话区`);
|
|
851
1270
|
} catch (e) { toast('提问失败:' + e.message, 6000); }
|
|
852
1271
|
$('ask-send').disabled = false;
|
|
853
1272
|
};
|