@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/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
- .tbtn { flex: none; padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); cursor: pointer; }
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
- #zh-toggle { font-weight: 700; }
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
- #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; }
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">中</button>
122
- <button id="zh-regen" class="tbtn" title="重新翻译:删除现有译文,用当前端点重跑(换模型后用)" style="display:none">↻</button>
123
- <button id="dl-btn" class="tbtn" title="下载 PDF" style="display:none">⤓</button>
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">▣</button>
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="上一页">‹</button>
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="下一页">›</button>
162
+ <button id="page-next" class="tbtn" title="下一页" data-icon="next"></button>
131
163
  </span>
132
- <button id="cfg-open" class="tbtn" title="翻译端点设置">⚙</button>
133
- <button id="zoom-out" class="tbtn">−</button>
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">+</button>
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
198
256
 
199
- // 路径复制:clipboard API 失败(iframe 权限等)退回 textarea + execCommand
257
+ // 路径复制:与「复制 LaTeX」共用同一个 copyText(剪贴板写入只有一处实现,见文件末尾定义)
200
258
  $('pdf-path').onclick = async () => {
201
259
  const p = $('pdf-path').textContent;
202
260
  if (!p) return;
203
- try { await navigator.clipboard.writeText(p); toast('路径已复制'); return; } catch { /* 走兜底 */ }
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.textContent = zhState.busy ? '译' : '中';
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').textContent = '译';
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.textContent = '↻'; btn.classList.remove('active');
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; // 待重试的是不是「重翻」(↻ 进来时旧译文还在,zh-toggle 点击语义不对,必须带 force)
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
- 图标显示「当前模式」:▣ 单页翻页(active)/ ≣ 连续滚动。 */
778
+ 图标显示「当前模式」:书本 = 单页翻页(active)/ 卷轴 = 连续滚动。 */
709
779
  function updateModeToggle() {
710
780
  const btn = $('mode-toggle');
711
- btn.textContent = pageMode === 'paged' ? '▣' : '≣';
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) { askPop.style.display = 'none'; return; }
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
- $('ask-quote').textContent = text.length > 140 ? text.slice(0, 140) + '…' : text;
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
  };