PageSourceSearch

https://www.hord.asia/hub/modules/reader.js?v=2026082602

js hord.asia collected 2026-10-02 14:37:24 UTC 268,091 bytes, 6,647 lines download raw bytes

1/**
2 * HORD Hub - 阅读器模块
3 * 版本: 1.1.0
4 *
5 * 功能:
6 *   - 书架视图(上传书籍列表、封面、进度、最后打开时间)
7 *   - 文件上传(拖放 / 文件选择,支持 EPUB / TXT)
8 *   - 阅读视图(可调节字号、主题、行高)
9 *   - 词汇高亮(根据用户词表标记红/黄/绿)
10 *   - 进度跟踪(localStorage 保存 / 恢复阅读位置)
11 *   - 基础书签(添加 / 删除 / 列表)
12 *
13 * Chrome API 替换:
14 *   - chrome.storage.local → localStorage + IndexedDB
15 *   - chrome.runtime.* → 跳过(Hub 使用 HordCloudSync)
16 *
17 * 依赖(globalThis):
18 *   - HordIdbKv      — get / set
19 *   - HordHubToast   — show
20 *
21 * @module reader
22 */
23
24(function (global) {
25  'use strict';
26
27// ── 从 globalThis 获取依赖(已通过 index.html <script> 加载)──
28// 使用 var 避免与 reader-popup.js / streak.js 的函数声明冲突
29var R = globalThis.HordReaderPopup || {};
30var initReaderPopup = R.initReaderPopup;
31var destroyReaderPopup = R.destroyReaderPopup;
32var showWordPopup = R.showWordPopup;
33var closeReaderPopup = R.closeReaderPopup;
34var isPopupPinned = R.isPopupPinned;
35var recordStreakActivity = (globalThis.HordStreak || {}).recordActivity;
36
37// ── 构建版本标记(探针阶段:截图直接读 body[data-ver] 区分构建;bump 时须连同下方 ?v= 一起改)──
38const READER_VER = '2026081124';
39(function markReaderVersion() {
40  function mark() {
41    if (typeof document !== 'undefined' && document.body) {
42      document.body.setAttribute('data-ver', READER_VER);
43    }
44  }
45  mark();
46  if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', mark);
47  console.log('%c[HORD-READER] build ' + READER_VER + ' (F-⑫ 探针)', 'color:#0a8f5b;font-weight:bold');
48})();
49
50// ── 调试开关(探针资产化)──
51// 默认静默;localStorage hord_debug=1 才输出 [HORD-PROBE]/[HORD-LEGACY] trace。
52// 保留 F-⑫ 全轮探针(可观测性教训),但默认不吵。
53function hordDebugEnabled() {
54  try { return typeof localStorage !== 'undefined' && localStorage.getItem('hord_debug') === '1'; } catch (e) { return false; }
55}
56function hordProbe() {
57  if (!hordDebugEnabled()) return;
58  console.warn.apply(console, arguments);
59}
60function hordLegacy() {
61  if (!hordDebugEnabled()) return;
62  console.info.apply(console, arguments);
63}
64
65// ================================================================
66// 常量
67// ================================================================
68
69const STYLE_ID = 'hord-reader-module-styles';
70const LS_BOOKSHELF = 'rd_bookshelf';
71const LS_ACTIVE_BOOK = 'rd_active_book';
72const LS_PROG = (id) => `rd_prog_${id}`;
73const LS_STATE = (id) => `rd_state_${id}`;
74const LS_BM = (id) => `rd_bm_${id}`;
75const IDB_NAME = 'HordReaderDB';
76const IDB_STORE = 'books';
77const VOCAB_KEY = 'hord_mobile_asset_v2';
78
79// 主题(6 种,与扩展一致);name 已收归共享层 HordCore.THEME_LABEL(P0-1 三源归一)
80const THEMES = {
81  brand: { bg: '#f5f0ff', fg: '#2d1b69' },
82  light: { bg: '#ffffff', fg: '#1a1a1a' },
83  sepia: { bg: '#f4ecd8', fg: '#5b4636' },
84  green: { bg: '#eef6ee', fg: '#2d3b2d' },
85  dark:  { bg: '#1e1e2e', fg: '#cdd6f4' },
86  oled:  { bg: '#000000', fg: '#a6adc8' },
87};
88
89// === 统一词汇状态映射(对齐 reader-popup.js / words.js)===
90const STATUS_MAP = {
91  red:    { status: 'red',    mastery: 0, proficiency: 0 },
92  yellow: { status: 'yellow', mastery: 1, proficiency: 3 },
93  green:  { status: 'green',  mastery: 2, proficiency: 5 },
94};
95function statusFromProficiency(p) {
96  if (typeof p !== 'number') return 'red';
97  if (p <= 2) return 'red';
98  if (p <= 4) return 'yellow';
99  return 'green';
100}
101
102// 默认阅读状态
103const DEFAULT_STATE = {
104  fontSize: 18,
105  epubFontSize: 18,   // EPUB 独立字号(随 state 持久化,S0-14/B15)
106  fontFamily: 'inherit', // 字体族(随 state 持久化,B19)
107  theme: 'light',
108  lineHeight: 1.8,
109  paraSpacing: 0.6,
110  paragraphIndex: 0,
111  spread: 'smart',
112  spreadAggression: 85,
113  contentMaxWidth: 1800,
114};
115
116// ================================================================
117// 状态
118// ================================================================
119
120let $root = null;
121let view = 'shelf';        // 'shelf' | 'reading'
122let bookshelf = [];         // 书籍列表
123let currentBook = null;     // 当前阅读的书籍
124let paragraphs = [];        // 当前书籍段落
125let state = { ...DEFAULT_STATE };
126let bookmarks = [];
127let vocabSet = new Set();   // 所有词汇(小写)
128let vocabMeta = {};         // word → status (red/yellow/green)
129let vocabSets = { red: new Set(), yellow: new Set(), green: new Set(), note: new Set() };  // CSS Highlight API 用
130let sessionStart = 0;       // 阅读会话开始时间
131let chapters = [];          // TOC 章节列表 [{title, paragraphIndex}]
132let sidebarOpen = false;    // 侧边栏是否打开
133let sidebarTab = 'toc';     // 'toc' | 'bookmarks'
134let settingsOpen = false;   // 设置面板是否打开
135
136// R7 T0:统一关闭设置面板(模块级,供外部关闭守卫 / EPUB iframe 桥 / 划选入口复用)
137function closeSettingsPanel() {
138  if (!settingsOpen) return;
139  const panel = document.querySelector('#rdSettingsPanel');
140  const toggle = document.querySelector('#rdSettingsToggle');
141  settingsOpen = false;
142  panel?.classList.remove('open');
143  toggle?.classList.remove('active');
144}
145let margin = 0;             // 左右边距 (px)
146let epubLayout = null;      // EPUB rendition 布局引用
147let searchOpen = false;     // 搜索面板
148let searchOrigin = null;    // 搜索前位置(用于"返回原页")
149let isFullscreen = false;   // 全屏状态
150let readingStartTime = 0;   // 阅读开始时间戳
151let _readingTimer = null;   // 阅读计时器 interval
152let quoteData = [];         // 金句收藏数据
153let quoteRangeCache = new WeakMap();  // document → [{range, text, type}]
154
155// ---- 书架 UI 状态 (Manager-style) ----
156let shelfViewMode = 'grid';     // 'grid' | 'list'
157let shelfSearch = '';
158let shelfSort = 'recent';
159let shelfTypeFilter = 'all';
160let shelfStatusFilter = 'all';
161let shelfDensity = 'comfortable'; // 'comfortable' | 'compact'
162
163// ================================================================
164// IndexedDB 帮助函数
165// ================================================================
166
167function openIDB() {
168  return new Promise((resolve, reject) => {
169    const req = indexedDB.open(IDB_NAME, 1);
170    req.onupgradeneeded = () => {
171      const db = req.result;
172      if (!db.objectStoreNames.contains(IDB_STORE)) {
173        db.createObjectStore(IDB_STORE);
174      }
175    };
176    req.onsuccess = () => resolve(req.result);
177    req.onerror = () => reject(req.error);
178  });
179}
180
181async function idbPut(key, value) {
182  const db = await openIDB();
183  return new Promise((resolve, reject) => {
184    const tx = db.transaction(IDB_STORE, 'readwrite');
185    tx.objectStore(IDB_STORE).put(value, key);
186    tx.oncomplete = () => resolve();
187    tx.onerror = () => reject(tx.error);
188  });
189}
190
191async function idbGet(key) {
192  const db = await openIDB();
193  return new Promise((resolve, reject) => {
194    const tx = db.transaction(IDB_STORE, 'readonly');
195    const req = tx.objectStore(IDB_STORE).get(key);
196    req.onsuccess = () => resolve(req.result);
197    req.onerror = () => reject(req.error);
198  });
199}
200
201async function idbDelete(key) {
202  const db = await openIDB();
203  return new Promise((resolve, reject) => {
204    const tx = db.transaction(IDB_STORE, 'readwrite');
205    tx.objectStore(IDB_STORE).delete(key);
206    tx.oncomplete = () => resolve();
207    tx.onerror = () => reject(tx.error);
208  });
209}
210
211// ================================================================
212// localStorage 帮助函数
213// ================================================================
214
215function lsGet(key, fallback = null) {
216  try {
217    const raw = localStorage.getItem(key);
218    return raw ? JSON.parse(raw) : fallback;
219  } catch { return fallback; }
220}
221
222function lsSet(key, value) {
223  try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
224}
225
226// ── Phase F: IDB 同步帮助函数 ──
227// 读取 vocab_builder_db 中的阅读器子字段
228async function idbGetReaderField(field, fallback) {
229  try {
230    if (typeof globalThis.HordIdbKv?.get !== 'function') return fallback;
231    const asset = await globalThis.HordIdbKv.get(VOCAB_KEY);
232    return (asset && asset[field] != null) ? asset[field] : fallback;
233  } catch { return fallback; }
234}
235
236// 写入 vocab_builder_db 中的阅读器子字段并触发云同步
237// Phase 3:统一走 HordAssetStore.mutateAsset(),由其完成 dirty/hash/updatedAt 标记、IDB 持久化与防抖同步
238async function idbSetReaderField(field, value) {
239  try {
240    if (typeof globalThis.HordAssetStore?.mutateAsset !== 'function') return;
241    await globalThis.HordAssetStore.mutateAsset((ctx) => {
242      ctx.asset[field] = value;
243    }, { syncDelay: 2000 });
244  } catch (e) { console.warn('[Reader] IDB write failed:', e); }
245}
246
247// Phase F: localStorage → IDB 数据迁移(首次加载时执行一次)
248// Phase 3:统一走 HordAssetStore.mutateAsset();批量一次性迁移使用 syncDelay: 0 立即同步
249const RD_MIGRATED_KEY = 'rd_reader_migrated_to_idb_v1';
250async function migrateReaderDataIfNeeded() {
251  try {
252    if (localStorage.getItem(RD_MIGRATED_KEY)) return;
253    if (typeof globalThis.HordAssetStore?.mutateAsset !== 'function') {
254      localStorage.setItem(RD_MIGRATED_KEY, '1');
255      return;
256    }
257
258    await globalThis.HordAssetStore.mutateAsset((ctx) => {
259      const asset = ctx.asset;
260
261      // 仅当 IDB 中书架为空时才迁移(避免覆盖已有云端数据)
262      if (asset.bookshelfMeta && asset.bookshelfMeta.length > 0) {
263        ctx.abort();
264        return;
265      }
266
267      const localBooks = lsGet(LS_BOOKSHELF, []);
268      if (localBooks.length === 0) {
269        ctx.abort();
270        return;
271      }
272
273      // 迁移书架 + 嵌入状态
274      for (const book of localBooks) {
275        const state = lsGet(LS_STATE(book.id), null);
276        if (state) book.state = state;
277        book.progress = book.progress || 0;
278      }
279      asset.bookshelfMeta = localBooks;
280
281      // 迁移书签
282      if (!asset.readerBookmarks) asset.readerBookmarks = {};
283      for (const book of localBooks) {
284        const bm = lsGet(LS_BM(book.id), null);
285        if (bm && bm.length > 0) asset.readerBookmarks[book.id] = bm;
286      }
287    }, { syncDelay: 0 });
288
289    localStorage.setItem(RD_MIGRATED_KEY, '1');
290  } catch (e) { console.warn('[Reader] Migration failed:', e); }
291}
292
293// ================================================================
294// 书架管理
295// ================================================================
296
297async function loadBookshelf() {
298  // Phase F: 优先从 IDB(vocab_builder_db.bookshelfMeta)读取,fallback 到 localStorage
299  const idbBooks = await idbGetReaderField('bookshelfMeta', null);
300  if (idbBooks) {
301    bookshelf = idbBooks.filter(b => !b.isDeleted);
302  } else {
303    bookshelf = lsGet(LS_BOOKSHELF, []).filter(b => !b.isDeleted);
304  }
305  // 同时触发迁移
306  migrateReaderDataIfNeeded();
307}
308
309async function saveBookshelf() {
310  // 保留 localStorage 写入作为 fallback(同步,立即可见)
311  lsSet(LS_BOOKSHELF, bookshelf);
312  // Phase F: 同步写入 IDB → 自动进入云端同步管道(await 确保 IDB 落盘后再被 loadBookshelf 读回)
313  await idbSetReaderField('bookshelfMeta', bookshelf);
314}
315
316function findBook(id) {
317  return bookshelf.find(b => b.id === id);
318}
319
320async function addBook(file) {
321  const buf = await file.arrayBuffer();
322  const id = await sha1(buf);
323  const type = file.name.endsWith('.epub') ? 'epub' : 'txt';
324
325  // 若已存在则跳过
326  if (findBook(id)) {
327    showToast('此书已在书架中');
328    return findBook(id);
329  }
330
331  const book = {
332    id,
333    name: file.name,
334    title: file.name.replace(/\.(epub|txt)$/i, ''),
335    type,
336    size: file.size,
337    progress: 0,
338    totalSeconds: 0,
339    lastOpened: Date.now(),
340    cover: '',
341    isDeleted: false,
342  };
343
344  // EPUB 封面提取
345  if (type === 'epub') {
346    try {
347      book.cover = await extractEpubCover(new Blob([buf])) || '';
348    } catch (e) {
349      console.warn('[Reader] EPUB cover extraction failed:', e);
350    }
351  }
352
353  // 存入 IDB
354  await idbPut(id, buf);
355
356  bookshelf.unshift(book);
357  await saveBookshelf();
358  return book;
359}
360
361async function removeBook(id) {
362  const idx = bookshelf.findIndex(b => b.id === id);
363  if (idx === -1) return;
364  bookshelf[idx].isDeleted = true;
365  saveBookshelf();
366  await idbDelete(id);
367  // Phase F: localStorage 清理保留(向后兼容),IDB 同步由 saveBookshelf 处理
368  try { localStorage.removeItem(LS_PROG(id)); } catch {}
369  try { localStorage.removeItem(LS_STATE(id)); } catch {}
370  try { localStorage.removeItem(LS_BM(id)); } catch {}
371  if (lsGet(LS_ACTIVE_BOOK, '') === id) lsSet(LS_ACTIVE_BOOK, '');
372}
373
374// ================================================================
375// 文本处理
376// ================================================================
377
378async function sha1(buf) {
379  const hash = await crypto.subtle.digest('SHA-1', buf);
380  return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
381}
382
383function detectEncoding(buf) {
384  const bytes = new Uint8Array(buf);
385  // 简单检测:如果全是有效 UTF-8 则返回 UTF-8,否则 GBK
386  let i = 0;
387  while (i < bytes.length) {
388    const b = bytes[i];
389    if (b <= 0x7f) { i++; continue; }
390    let len = 0;
391    if ((b & 0xe0) === 0xc0) len = 1;
392    else if ((b & 0xf0) === 0xe0) len = 2;
393    else if ((b & 0xf8) === 0xf0) len = 3;
394    else return 'gbk';
395    for (let j = 1; j <= len; j++) {
396      if (i + j >= bytes.length || (bytes[i + j] & 0xc0) !== 0x80) return 'gbk';
397    }
398    i += len + 1;
399  }
400  return 'utf-8';
401}
402
403async function readTxtBook(buf) {
404  const encoding = detectEncoding(buf);
405  const decoder = new TextDecoder(encoding);
406  const text = decoder.decode(buf);
407  // Stage A 等价接线:split 上收核心引擎(扩展语义,不 trim 行内容);
408  // Hub 段落须 trim,故接 .map(l => l.trim()) 适配——输出与旧 .split().map(trim).filter() 逐项一致。
409  return HordCore.TxtEngine.splitTxtParagraphs(text)
410    .map(l => l.trim());
411}
412
413/**
414 * 检测 TXT 文件中的章节标题
415 * 匹配模式: Chapter N, 第N章, 第N节, Part N, Book N, 以及常见中文格式
416 */
417function detectChapters(paragraphs) {
418  // Stage A 等价接线:薄包装,委托核心引擎 hub7 分支(逐字搬运旧实现,零行为变化)。
419  return HordCore.TxtEngine.detectTxtChapters(paragraphs, {
420    set: 'hub7',
421    maxTitleLength: 80,
422    addOpeningEntry: true,
423    minCount: 2,
424    titleSlice: 60,
425  });
426}
427
428// ================================================================
429// 词汇数据
430// ================================================================
431
432async function loadVocabData() {
433  try {
434    if (typeof globalThis.HordIdbKv?.get !== 'function') return;
435    const asset = await globalThis.HordIdbKv.get(VOCAB_KEY);
436    if (!asset || !asset.words) return;
437
438    vocabSet = new Set();
439    vocabMeta = {};
440    vocabSets = { red: new Set(), yellow: new Set(), green: new Set(), note: new Set() };
441
442    for (const w of asset.words) {
443      const word = (w.word || w.id || '').toLowerCase().trim();
444      if (!word) continue;
445      if (w.isDeleted) continue;  // 跳过软删除词
446
447      vocabSet.add(word);
448
449      // 根据熟练度映射状态
450      let status = 'yellow'; // 默认学习中
451      const prof = w.proficiency ?? w.status ?? '';
452      if (typeof prof === 'number') {
453        if (prof <= 2) status = 'red';
454        else if (prof <= 4) status = 'yellow';
455        else status = 'green';
456      } else if (typeof prof === 'string') {
457        if (prof === 'new' || prof === 'red') status = 'red';
458        else if (prof === 'mastered' || prof === 'green') status = 'green';
459        else status = 'yellow';
460      }
461
462      // 有批注的词优先归入 note
463      const note = (w.note || w.annotation || '').trim();
464      const bucket = note ? 'note' : status;
465
466      vocabMeta[word] = { status: bucket, meaning: w.meaning || w.def || '' };
467      vocabSets[bucket].add(word);
468    }
469    console.info('[Reader] vocabSet size =', vocabSet.size);
470  } catch (e) {
471    console.warn('[Reader] 加载词汇数据失败:', e);
472  }
473}
474
475// Apply a popup status change to the currently visible reader surface before
476// IDB/cloud persistence settles. The later onStatusChange callback remains the
477// authoritative reload after the write succeeds.
478function applyOptimisticReaderWordStatus(key, colorStatus, record) {
479  const word = String(key || '').toLowerCase().trim();
480  if (!word) return;
481  for (const bucket of ['red', 'yellow', 'green', 'note']) vocabSets[bucket].delete(word);
482  if (!colorStatus) {
483    vocabSet.delete(word);
484    delete vocabMeta[word];
485  } else {
486    vocabSet.add(word);
487    const previous = vocabMeta[word] || {};
488    const next = Object.assign({}, previous, record || {}, { status: colorStatus });
489    const note = String(next.note || next.annotation || '').trim();
490    const bucket = note ? 'note' : colorStatus;
491    next.status = bucket;
492    vocabMeta[word] = next;
493    vocabSets[bucket].add(word);
494  }
495
496  if (currentBook?.type === 'epub') {
497    try {
498      const iframes = Array.from($root?.querySelector('#epubViewer')?.querySelectorAll('iframe') || []);
499      for (const iframe of iframes) {
500        if (iframe.contentDocument) applyVocabHighlights(iframe.contentDocument, iframe.contentDocument.body);
501      }
502    } catch (_) {}
503  } else if (currentBook) {
504    renderParagraphs();
505  }
506}
507
508/** 加载金句收藏数据(F 队列 Stage 1:改委托共享 ReaderComponent,legacy-hub 档逐项等价) */
509async function loadQuoteData() {
510  if (readerComponent) {
511    try {
512      await readerComponent.loadQuotes();
513    } catch (e) {
514      console.warn('[Reader] 加载金句数据失败:', e);
515    }
516    quoteData = readerComponent.getRawQuotes() || [];
517    return;
518  }
519  // 兜底:组件未就绪(不应发生,仅防御)时直读,保持原行为
520  try {
521    if (typeof globalThis.HordIdbKv?.get !== 'function') return;
522    const asset = await globalThis.HordIdbKv.get(VOCAB_KEY);
523    if (!asset) { quoteData = []; return; }
524    const sources = [asset.quotes, asset.sentences, asset.collectedSentences, asset.quotesList, asset.goldenSentences];
525    for (const s of sources) {
526      if (Array.isArray(s) && s.length) {
527        quoteData = s.filter(rec => rec && rec.isDeleted !== true && getQuoteText(rec));
528        return;
529      }
530    }
531    quoteData = [];
532  } catch (e) {
533    console.warn('[Reader] 加载金句数据失败:', e);
534    quoteData = [];
535  }
536}
537
538function getQuoteText(rec) {
539  return rec.text || rec.originalText || rec.sentence || rec.content || '';
540}
541
542function normalizeQuoteText(t) {
543  return (t || '').replace(/\s+/g, ' ').trim().toLowerCase();
544}
545
546/** 获取当前书籍相关的金句(F 队列 Stage 1:改委托共享 ReaderComponent,legacy-hub 档逐项等价)
547 *  返回归一化 quoteRecord[](含 text/note/createdAt/chapterName/cfi/txtIndex/raw),下游 renderQuotePaneContent / applyQuoteHighlights 直接消费
548 */
549function getBookQuotes() {
550  if (!readerComponent) return [];
551  return readerComponent.getQuotes();
552}
553
554/**
555 * 应用金句高亮(F 队列 Stage 1:改委托共享 ReaderComponent.applyHighlights)
556 *
557 * F-⑫ 收口·高亮可见性(以扩展端 src/reader-ui.js:434-529 为基准复刻):
558 * - **realm**:注册必须落在目标 doc 自己的 realm —— 组件 setHighlight 用
559 *   `win = doc.defaultView` + `new win.Highlight(...)`(reader-component.js:300-310),
560 *   与扩展端 `doc.defaultView.CSS.highlights.set(..., new doc.defaultView.Highlight(...))` 一致。
561 *   ⚠️ 不可用宿主页 `CSS.highlights` 兜底:EPUB iframe 有独立 realm,注册到父窗口不会绘制。
562 *   故此处**不再**用父 realm 的 `typeof CSS`/`CSS.highlights` 做前置守卫(那会在父窗口不支持时
563 *   误杀 iframe,且守卫的是错的 realm);能力检测由组件按 doc 逐个进行。
564 * - **CSS 注入**:`::highlight()` 规则不跨 document 继承,必须逐 doc 注入。改为使用组件默认
565 *   `injectCss:true`(幂等,styleId=`hord-rd-quote-highlight-css`),TXT 主 doc 与 EPUB iframe doc
566 *   都会注入 —— 这修复了 Hub 主文档此前**从未有过** `::highlight(rd-quote-*)` 规则、
567 *   金句高亮注册了却"隐形"的问题(§7.5 有意差异,见提交说明)。
568 * - 组件 setHighlight 对空数组执行 delete → 修复原 applyQuoteHighlights 缺 else-delete 的
569 *   高亮残留 bug(§7.5 有意差异)。
570 */
571function applyQuoteHighlights(doc, rootEl) {
572  if (!readerComponent) return;
573  if (!doc || !doc.defaultView) return;
574  var hitMap = readerComponent.applyHighlights(doc, rootEl, {
575    acceptFilter: function(node) {
576      if (!node.parentElement) return true;
577      if (node.parentElement.closest('.reader-toolbar, .reader-sidebar, .reader-search-panel')) return false;
578      return true;
579    }
580  });
581  if (hitMap && hitMap.length > 0) quoteRangeCache.set(doc, hitMap);
582}
583
584/** 保存文本选择为金句收藏(F 队列 Stage 1:改委托共享 ReaderComponent.saveQuote)
585 *  校验/去重/写入/重载/Toast 全部经组件;chapterName 与 txtIndex 由 Hub 现场经 meta 透传。
586 *  ⚠️ 输入长度校验由组件 shouldTrackQuote 统一为 8–600,与原 Hub 5–500 略有差异(详见提交说明,待真机确认)。
587 */
588// TODO(F-⑫) 阅读器对齐欠账·金句写入入口:Hub 弹窗句子收藏已走此链
589// (reader-popup toggleFavorite → HordReaderSentenceApi.toggleQuote → toggleQuoteByText → saveAsQuote)。
590// 阅读器原生金句高亮/重开可见性对齐待后续统一(§5-F ⑫–⑮)。
591async function saveAsQuote(text, translation) {
592  if (!readerComponent) {
593    showToast('阅读器组件未就绪,收藏失败', 'error');
594    return;
595  }
596  hordProbe('[HORD-PROBE] saveAsQuote: text=', JSON.stringify(String(text || '').slice(0, 40)), 'hasTrans=', !!translation);
597  const record = { chapterName: getChapterName(), txtIndex: state.paragraphIndex };
598  if (translation) record.translation = translation;
599  await readerComponent.saveQuote(text, record);
600}
601
602/** 刷新所有高亮(词汇 + 金句) */
603function refreshHighlights() {
604  // TXT 模式
605  if (paragraphs.length > 0) {
606    const body = $root?.querySelector('#rdBody');
607    if (body) {
608      applyQuoteHighlights(document, body);
609    }
610  }
611  // EPUB 模式
612  if (epubLayout?.viewerEl) {
613    try {
614      const iframes = Array.from(epubLayout.viewerEl.querySelectorAll('iframe'));
615      for (const iframe of iframes) {
616        if (!iframe.contentDocument) continue;
617        applyQuoteHighlights(iframe.contentDocument, iframe.contentDocument.body);
618      }
619    } catch (_) {}
620  }
621}
622
623/** 删除金句(F 队列 Stage 1:改委托共享 ReaderComponent.deleteQuote) */
624async function deleteQuote(createdAt) {
625  if (!readerComponent) return;
626  hordProbe('[HORD-PROBE] deleteQuote(host): createdAt=', createdAt);
627  await readerComponent.deleteQuote(createdAt);
628}
629
630/** 渲染金句侧边栏内容 */
631function renderQuotePaneContent() {
632  // Step3:侧栏按本书过滤——只显示 bookId 精确匹配当前书的 quote;
633  // 无归属 / 其他书的 quote 归入折叠「全局」区,只显计数(不混入本书列表)。
634  const all = (readerComponent && readerComponent.getRawQuotes()) || [];
635  const bookId = (currentBook && currentBook.id) || '';
636  const isThisBook = function (q) { return !!(bookId && q && q.bookId && q.bookId === bookId); };
637  const quotes = all.filter(isThisBook).slice().sort(function (a, b) {
638    return (b.createdAt || 0) - (a.createdAt || 0);
639  });
640  const globalCount = all.length - quotes.length;
641  // 侧栏头部自诊徽章:本书条数 | 最新 createdAt(截图直接读,配 body[data-ver] 区分构建)
642  const count = quotes.length;
643  const latest = count ? (quotes[0].createdAt || 0) : 0;
644  const diag = `<div class="reader-quote-diag" data-count="${count}" data-latest="${latest}" style="font-size:12px;color:#7c3aed;background:rgba(167,139,250,0.12);border:1px solid rgba(167,139,250,0.4);border-radius:6px;padding:4px 8px;margin:0 0 8px;">💬 本书金句 ${count}
644 条 · 最新 ${latest ? new Date(latest).toLocaleString('zh-CN') : '—'}</div>`;
645  let html = diag;
646  if (count === 0) {
647    html += '<div class="reader-sidebar-empty">还没有标注与收藏。<br><small>在阅读中选中文本即可收藏</small></div>';
648  } else {
649    html += quotes.map(q => {
650      const text = getQuoteText(q);
651      const note = q.note || q.annotation || '';
652      const isAnnotation = !!note;
653      const time = q.createdAt ? new Date(q.createdAt).toLocaleDateString('zh-CN') : '';
654      return `
655        <div class="reader-quote-item" data-ts="${q.createdAt || ''}">
656          <div class="reader-quote-type">${isAnnotation ? '📝 标注' : '⭐ 收藏'}</div>
657          <div class="reader-quote-text">${HordCore.escapeHtml(text.length > 120 ? text.slice(0, 120) + '...' : text)}</div>
658          ${note ? `<div class="reader-quote-note">${HordCore.escapeHtml(note)}</div>` : ''}
659          <div class="reader-quote-meta">
660            <span>${time}${q.chapterName ? ' · ' + HordCore.escapeHtml(q.chapterName) : ''}</span>
661            <button class="reader-quote-del" data-ts="${q.createdAt}" title="删除">✕</button>
662          </div>
663        </div>
664      `;
665    }).join('');
666  }
667  // 全局折叠区:只显计数,不展开条目(Step3 要求)
668  if (globalCount > 0) {
669    html += `<details class="reader-quote-global" style="margin-top:10px;border-top:1px dashed rgba(167,139,250,0.35);padding-top:8px;"><summary style="cursor:pointer;font-size:12px;color:#7c3aed;">🌐 全局收藏 ${globalCount} 条(其他书 / 无归属)</summary></details>`;
670  }
671  return html;
672}
673
674function bindQuotePaneEvents() {
675  $root.querySelectorAll('.reader-quote-del').forEach(btn => {
676    btn.addEventListener('click', (e) => {
677      e.stopPropagation();
678      const ts = parseInt(btn.dataset.ts, 10);
679      if (ts) deleteQuote(ts);
680    });
681  });
682  // 金句点击导航
683  $root.querySelectorAll('.reader-quote-item').forEach(item => {
684    item.addEventListener('click', () => {
685      const ts = parseInt(item.dataset.ts, 10);
686      const q = quoteData.find(r => r.createdAt === ts);
687      if (!q) return;
688      // TXT 导航
689      if (paragraphs.length > 0 && q.txtIndex != null) {
690        state.paragraphIndex = q.txtIndex;
691        saveState(); saveProgress();
692        renderReading();
693      }
694      // EPUB 导航
695      if (epubLayout?.rendition && q.cfi) {
696        epubLayout.rendition.display(q.cfi);
697      }
698    });
699  });
700}
701
702function renderReaderSidebar(activeTab, bodyHtml) {
703  const tab = activeTab || 'toc';
704  return `
705    <div class="reader-sidebar ${sidebarOpen ? 'open' : ''}" id="rdSidebar">
706      <div class="reader-sidebar-tabs">
707        <button class="reader-sidebar-tab ${tab === 'toc' ? 'active' : ''}" data-tab="toc">📑 目录</button>
708        <button class="reader-sidebar-tab ${tab === 'bookmarks' ? 'active' : ''}" data-tab="bookmarks">🔖 书签</button>
709        <button class="reader-sidebar-tab ${tab === 'quotes' ? 'active' : ''}" data-tab="quotes">💬 标注与收藏</button>
710      </div>
711      <div class="reader-sidebar-body" id="rdSidebarBody">${bodyHtml || ''}</div>
712    </div>
713  `;
714}
715
716function renderReaderToolbar(mode, bookTitle) {
717  const isEpub = mode === 'epub';
718  const themeId = isEpub ? 'epubThemeCycle' : 'rdThemeCycle';
719  const fontMinusId = isEpub ? 'epubFontDec' : 'rdFontMinus';
720  const fontLabelId = isEpub ? 'epubFontLabel' : 'rdFontSize';
721  const fontPlusId = isEpub ? 'epubFontInc' : 'rdFontPlus';
722  const fullscreenId = isEpub ? 'epubFullscreen' : 'txtFullscreen';
723  const sidebarId = isEpub ? 'rdToggleSidebar' : 'rdTocToggle';
724  const backId = isEpub ? 'rdBackHome2' : 'rdBackHome';
725  const fontSize = isEpub ? state.epubFontSize : state.fontSize;
726
727  return `
728    <div class="reader-toolbar" id="rdToolbar">
729      <div class="reader-toolbar-left">
730        <div class="header-brand-wrap">
731          <img class="header-brand-logo" src="logo-page-64.png" alt="HORD Logo">
732          <div class="header-brand-copy">
733            <div class="brand-tag">HORD Reader</div>
734            <div class="brand-subline">Yesterday You Said Tomorrow</div>
735          </div>
736        </div>
737        <div class="header-divider"></div>
738        <div class="reader-status-group">
739          <div class="status-pill-item" title="当前位置">
740            <span class="status-emoji">📖</span>
741            <div class="chapter-name" id="rdChapterName">正在加载章节...</div>
742          </div>
743          <span class="status-pill-sep">|</span>
744          <div class="status-pill-item" title="阅读时长 · 累计时长">
745            <span class="status-emoji">⏱️</span>
746            <span id="rdReadingTime">00:00</span>
747            <span class="reading-time-sep" style="margin:0 5px;">·</span>
748            <span id="rdCumulativeTime" class="reading-time-cumulative">累计 0分</span>
749          </div>
750        </div>
751      </div>
752
753      <div class="reader-toolbar-center">
754        <div class="book-title" id="rdBookTitle">${HordCore.escapeHtml(bookTitle || '')}</div>
755      </div>
756
757      <div class="reader-toolbar-right">
758        <button class="btn toolbar-btn reader-theme-btn" id="${themeId}" title="循环主题">
759          <span class="theme-emoji">${HordCore.THEME_EMOJI[state.theme]}</span>
760          <span class="theme-label">主题·${HordCore.THEME_LABEL[state.theme]}</span>
761        </button>
762        <button class="btn toolbar-btn" id="rdSearchToggle" title="搜索 (Ctrl+F)">🔍</button>
763        <div class="font-controls font-pill">
764          <button class="btn toolbar-btn" id="${fontMinusId}">A−</button>
765          <span class="font-size-label" id="${fontLabelId}">${fontSize}</span>
766          <button class="btn toolbar-btn" id="${fontPlusId}">A+</button>
767        </div>
768        <button class="btn toolbar-btn" id="${fullscreenId}" title="全屏 (F)">⛶</button>
769        <button class="btn toolbar-btn ${settingsOpen ? 'active' : ''}" id="rdSettingsToggle" title="设置">⚙️</button>
770        <button class="btn toolbar-btn${isBookmarked() ? ' active' : ''}" id="rdBmToggle" title="书签" style="${isBookmarked() ? 'color:#ef4444;' : ''}">🔖</button>
771        <button class="btn toolbar-btn ${sidebarOpen ? 'active' : ''}" id="${sidebarId}" title="目录">☰</button>
772        <button class="btn toolbar-btn" id="${backId}" title="返回书架">🏠</button>
773      </div>
774    </div>
775  `;
776}
777
778// ================================================================
779// F 队列 Stage 1:Hub 版 ReaderDataAdapter + 组件实例(legacy-hub 档逐项等价)
780// 详见 docs/2026-08-11-reader-sharing-plan.md §3 / §7.5
781// 组件只做编排(校验/去重/重载/Toast/高亮注册),所有本地 I/O 经下列 adapter 方法。
782// ================================================================
783const hubQuoteAdapter = {
784  getBookId: function () { return (currentBook && currentBook.id) || ''; },
785  getBookTitle: function () { return (currentBook && (currentBook.title || currentBook.name)) || ''; },
786
787  // 读写同源(F-⑫ 续):读 Hub 资产记录(与 saveQuote/deleteQuote 经 mutateAsset 写入同源),
788  // 仅当该资产**无 quotes 数组**时才回退 legacy 多字段探测(VOCAB_KEY),兼容旧扩展格式记录。
789  // 关键:此前 getRawQuotes 取 [quotes, sentences, ...] 第一个非空即整段返回,若 legacy .sentences
790  // 有数据会**遮蔽** Hub 收藏进 .quotes 的测试金句;现改 .quotes 为主源,旧扩展金句仅作无 .quotes 时的回退。
791  getRawQuotes: async function () {
792    if (typeof globalThis.HordAssetStore?.loadAsset === 'function') {
793      const asset = await globalThis.HordAssetStore.loadAsset();
794      if (asset && typeof asset === 'object') {
795        // Hub 资产记录为主源:.quotes 即 saveQuote/deleteQuote 写入字段
796        if (Array.isArray(asset.quotes)) {
797          // 一次性去重(按 normText,保留最早 createdAt):仅在确有重复时才 mutateAsset 持久化,
798          // 去重后同文本只留一条且 createdAt 最小;后续读取无重复即不再写,故为「一次性」。
799          const seen = new Map();
800          let dupFound = false;
801          const uniq = [];
802          for (const q of asset.quotes) {
803            if (!q || q.isDeleted === true) continue;
804            const key = HordCore.ReaderComponent.normalizeQuoteText(getQuoteText(q));
805            if (!key) continue;
806            const prev = seen.get(key);
807            if (!prev) { seen.set(key, q); uniq.push(q); }
808            else if ((q.createdAt || 0) < (prev.createdAt || 0)) {
809              seen.set(key, q);                       // 保留更早创建的记录
810              const idx = uniq.indexOf(prev);
811              if (idx >
811= 0) uniq[idx] = q;
812              dupFound = true;
813            } else {
814              dupFound = true;
815            }
816          }
817          if (dupFound) {
818            try {
819              await globalThis.HordAssetStore.mutateAsset((ctx) => { ctx.asset.quotes = uniq; }, { syncDelay: 0 });
820            } catch (_) {}
821          }
822          return uniq.filter(rec => rec && rec.isDeleted !== true && getQuoteText(rec));
823        }
824        // 资产存在但无 quotes 字段 → 进入 legacy 回退
825      }
826    }
827    // legacy 回退(VOCAB_KEY):仅当资产层无 quotes 数组时触发,兼容旧扩展格式记录
828    if (typeof globalThis.HordIdbKv?.get === 'function') {
829      hordLegacy('[HORD-LEGACY] getRawQuotes: 资产无 quotes 数组,回退 VOCAB_KEY 多字段探测(不静默混入,已显式标注来源)');
830      const legacy = await globalThis.HordIdbKv.get(VOCAB_KEY);
831      if (legacy && typeof legacy === 'object') {
832        const sources = [
833          ['quotes', legacy.quotes],
834          ['sentences', legacy.sentences],
835          ['collectedSentences', legacy.collectedSentences],
836          ['quotesList', legacy.quotesList],
837          ['goldenSentences', legacy.goldenSentences],
838        ];
839        for (const [name, s] of sources) {
840          if (Array.isArray(s) && s.length) {
841            hordLegacy('[HORD-LEGACY] getRawQuotes 采用 legacy 字段:', name, '(len=', s.length, ')');
842            return s.filter(rec => rec && rec.isDeleted !== true && getQuoteText(rec));
843          }
844        }
845      }
846    }
847    return [];
848  },
849
850  getVocabSets: function () { return vocabSets; },
851
852  // 写入:record 已由组件构造(含 bookId/bookTitle/chapterName/txtIndex/createdAt/note/isDeleted)
853  saveQuote: async function (text, record) {
854    if (typeof globalThis.HordAssetStore?.mutateAsset !== 'function') {
855      console.error('[Reader] hubQuoteAdapter.saveQuote: HordAssetStore.mutateAsset 不可用,金句写入失败(根因:资产层未初始化/未加载)');
856      showToast('收藏失败:本地资产服务不可用', 'error');
857      return 'failed';
858    }
859    let duplicated = false;
860    let exists = false;
861    const normText = HordCore.ReaderComponent.normalizeQuoteText(text);
862    let beforeLen = 'n/a';
863    const mctx = await globalThis.HordAssetStore.mutateAsset((ctx) => {
864      const asset = ctx.asset;
865      beforeLen = Array.isArray(asset.quotes) ? asset.quotes.length : 'no-array';
866      if (!Array.isArray(asset.quotes)) asset.quotes = [];
867      exists = asset.quotes.some(q => HordCore.ReaderComponent.normalizeQuoteText(HordCore.ReaderComponent.getQuoteText(q)) === normText && q.isDeleted !== true);
868      if (exists) { duplicated = true; ctx.abort(); return; }
869      // 组件以 (text, meta) 形式传入:meta 为已构造的元数据记录,须补 text 字段落库
870      const finalRecord = (record && typeof record === 'object') ? Object.assign({}, record) : {};
871      finalRecord.text = text;
872      // H5 修复 A:确保写入记录带 id(组件 payload 现已补 id;此处兜底防裸记录无 id 被 mergeAsset 丢弃)
873      if (!finalRecord.id) finalRecord.id = String(finalRecord.createdAt || Date.now());
874      asset.quotes.push(finalRecord);
875      hordProbe('[HORD-PROBE] saveQuote: normText=', normText, 'beforeLen=', beforeLen, 'afterPushLen=', asset.quotes.length, 'lastCreatedAt=', finalRecord.createdAt);
876    }, { syncDelay: 2000 });
877    if (!mctx?.ok) {
878      const message = mctx?.error === 'SENTENCE_LIMIT_50'
879        ? '免费版最多收藏 50 条句子,升级 PRO 后可继续收藏。'
880        : '收藏失败,请稍后重试。';
881      showToast(message, 'error');
882      return 'failed';
883    }
884    // 探针:写后读回(确认 IDB 持久化 + loadAsset 同源读回)
885    let readbackLen = 'n/a', readbackLast = null;
886    try {
887      const rb = await globalThis.HordAssetStore.loadAsset();
888      if (rb && Array.isArray(rb.quotes)) {
889        readbackLen = rb.quotes.length;
890        const last = rb.quotes[rb.quotes.length - 1];
891        if (last) readbackLast = { text: String(last.text || '').slice(0, 40), createdAt: last.createdAt };
892      }
893    } catch (_) {}
894    hordProbe('[HORD-PROBE] saveQuote 返回: changed=', mctx && mctx.changed, 'duplicated=', duplicated, 'exists=', exists, 'readbackLen=', readbackLen, 'readbackLast=', readbackLast);
895    return duplicated ? 'duplicated' : 'saved';
896  },
897
898  // 删除:id 即 createdAt(与历史 deleteQuote 一致)
899  deleteQuote: async function (id) {
900    if (typeof globalThis.HordAssetStore?.mutateAsset !== 'function') return false;
901    let removed = false;
902    let beforeLen = 'n/a';
903    const mctx = await globalThis.HordAssetStore.mutateAsset((ctx) => {
904      const asset = ctx.asset;
905      const quotes = Array.isArray(asset.quotes) ? asset.quotes : [];
906      beforeLen = quotes.length;
907      // H5 修复 D:删除契约统一为 String(q.id ?? q.createdAt)(与 mergeAsset 兜底键一致),避免无 id 记录删不掉
908      const next = quotes.filter(q => String(q.id ?? q.createdAt) !== String(id));
909      if (next.length === quotes.length) { ctx.abort(); return; }
910      asset.quotes = next;
911      removed = true;
912    }, { syncDelay: 2000 });
913    // 探针:写后读回
914    let readbackLen = 'n/a';
915    try {
916      const rb = await globalThis.HordAssetStore.loadAsset();
917      if (rb && Array.isArray(rb.quotes)) readbackLen = rb.quotes.length;
918    } catch (_) {}
919    hordProbe('[HORD-PROBE] deleteQuote: id=', id, 'beforeLen=', beforeLen, 'removed=', removed, 'changed=', mctx && mctx.changed, 'readbackLen=', readbackLen);
920    return removed;
921  },
922
923  // 侧栏渲染(模板与事件绑定仍由宿主持有 —— 见 renderQuotePaneContent / bindQuotePaneEvents)
924  // 模板经 renderQuotePaneContent → getBookQuotes → readerComponent.getQuotes(),即组件 bookQuotes,
925  // 组件在 reload() 中先 recompute 再回调,故此处取到的恒为最新列表。
926  renderQuoteSidebar: function (list) {
927    if (sidebarTab !== 'quotes') return;
928    const body = $root && $root.querySelector('#rdSidebarBody');
929    if (body) { body.innerHTML = renderQuotePaneContent(); bindQuotePaneEvents(); }
930  },
931
932  // 写后回调:同步宿主镜像 → 刷新高亮 → 重渲侧栏
933  // ⚠️ 侧栏鲜度铁律:**必须先同步 quoteData 再重渲**。
934  // 组件 reload() 只更新组件内部 rawQuotes/bookQuotes,宿主的 quoteData 不会自动跟进;
935  // 而 quoteData 仍有 4 处直接消费者会因此读到旧数据:
936  //   ① bindQuotePaneEvents 金句点击导航(quoteData.find)—— 新收藏的条目点了不跳转
937  //   ② highlightText 段落内 .quote-hl 包裹(for…of quoteData)—— 新金句不生效/已删仍带壳
938  //   ③ 金句 hover 弹窗(quoteData.find by qid)
939  //   ④ getQuotesForPopup / toggleQuoteByText 的读回校验
940  // 同步后 save/delete 完成时,侧栏与上述四路立即含/除该条。
941  // 修复错位一拍:渲染前强制 readerComponent.loadQuotes() 拿最新(reload 那一刻的 bookQuotes
942  // 可能仍慢一拍),并 requestAnimationFrame 延时再刷一次高亮,规避 EPUB iframe 过渡态导致
943  // 高亮注册到尚不稳定的 doc、需等下次翻页才显的错位感。
944  onQuotesChanged: function () {
945    Promise.resolve(readerComponent ? readerComponent.loadQuotes() : null).then(function () {
946      quoteData = (readerComponent && readerComponent.getRawQuotes()) || [];
947      try { hordProbe('[HORD-PROBE] onQuotesChanged: bookQuotes=', (readerComponent && readerComponent.getQuotes() || []).length, 'quoteData=', quoteData.length); } catch (_) {}
948      refreshHighlights();
949      hubQuoteAdapter.renderQuoteSidebar();
950      if (typeof requestAnimationFrame === 'function') {
951        requestAnimationFrame(function () { try { refreshHighlights(); } catch (_) {} });
952      }
953    });
954  },
955
956  showToast: function (msg, type) { showToast(msg, type); }
957};
958
959const readerComponent = (typeof HordCore !== 'undefined' && HordCore.ReaderComponent)
960  ? HordCore.ReaderComponent.create(hubQuoteAdapter, {
961      tag: 'hub-reader',
962      mode: 'legacy-hub',
963      timeMode: 'legacy-hub',
964      limit: 220
965    })
966  : null;
967
968// ================================================================
969// 文本高亮
970// ================================================================
971
972const WORD_RE = /\b([a-zA-Z'-]+)\b/g;
973
974function highlightText(text) {
975  // 词汇高亮
976  let result = '';
977  if (vocabSet.size > 0) {
978    let lastIndex = 0;
979    WORD_RE.lastIndex = 0;
980    let m;
981    while ((m = WORD_RE.exec(text)) !== null) {
982      result += HordCore.escapeHtml(text.slice(lastIndex, m.index));
983      const word = m[1].toLowerCase();
984      if (vocabSet.has(word)) {
985        const meta = vocabMeta[word] || { status: 'yellow', meaning: '' };
986        result += `<span class="vocab-hl vocab-hl--${meta.status}" data-word="${escapeAttr(word)}" data-meaning="${escapeAttr(meta.meaning)}">${HordCore.escapeHtml(m[1])}</span>`;
987      } else {
988        result += HordCore.escapeHtml(m[1]);
989      }
990      lastIndex = m.index + m[0].length;
991    }
992    result += HordCore.escapeHtml(text.slice(lastIndex));
993  } else {
994    result = HordCore.escapeHtml(text);
995  }
996
997  // 金句书内高亮:P0-c 收敛为单一路径 = CSS Custom Highlight API(applyQuoteHighlights /
998  // refreshHighlights,由 onQuotesChanged 在写/删后即时触发),与扩展端 src/reader-ui.js:434-529
999  // 一致。此处不再用 .quote-hl 包裹:① 旧包裹无对应 CSS 规则(不可见),纯属死路径;
1000  // ② 与 CSS Highlight API 双源冲突;③ 取消收藏后段落不重渲也能即时消失(highlights.delete)。
1001  // 因此 highlightText 只负责词汇高亮,金句高亮完全交由 CSS Highlight API。对齐扩展的
1002  // 「选中文本 → 弹窗 toggle」交互;TXT 下的 .quote-hl hover 弹译随之失效(扩展端无此包裹)。
1003
1004  return result;
1005}
1006
1007function escapeAttr(s) {
1008  return (s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
1009}
1010/** 剥离 HTML 标签,用于 EPUB 导航条目可能包含内嵌标签的场景 */
1011function stripHtmlTags(s) {
1012  return (s || '').replace(/<[^>]*>/g, '');
1013}
1014
1015/**
1016 * CSS Custom Highlight API 词汇高亮(零 DOM 突变,适用于 EPUB iframe)
1017 * 移植自 src/reader-ui.js applyVocabHighlights()
1018 * @param {Document} doc - 目标文档(EPUB iframe.contentDocument)
1019 * @param {Element} [rootEl] - 扫描根节点,默认 doc.body
1020 */
1021function applyVocabHighlights(doc, rootEl) {
1022  var ranges = HordCore.buildVocabRanges(doc, vocabSets, null, {
1023    rootEl: rootEl || null,
1024    acceptFilter: function(node) {
1025      if (!node.parentElement) return true;
1026      if (node.parentElement.closest('.reader-sidebar, .reader-toolbar')) return false;
1027      return true;
1028    }
1029  });
1030  if (!doc || !doc.defaultView || !doc.defaultView.CSS || !doc.defaultView.CSS.highlights) return;
1031  try {
1032    if (ranges.red.length > 0) doc.defaultView.CSS.highlights.set('rd-vocab-red', new doc.defaultView.Highlight(...ranges.red)); else doc.defaultView.CSS.highlights.delete('rd-vocab-red');
1033    if (ranges.yellow.length > 0) doc.defaultView.CSS.highlights.set('rd-vocab-yellow', new doc.defaultView.Highlight(...ranges.yellow)); else doc.defaultView.CSS.highlights.delete('rd-vocab-yellow');
1034    if (ranges.green.length > 0) doc.defaultView.CSS.highlights.set('rd-vocab-green', new doc.defaultView.Highlight(...ranges.green)); else doc.defaultView.CSS.highlights.delete('rd-vocab-green');
1035    if (ranges.note.length > 0) doc.defaultView.CSS.highlights.set('rd-vocab-purple', new doc.defaultView.Highlight(...ranges.note)); else doc.defaultView.CSS.highlights.delete('rd-vocab-purple');
1036  } catch (e) {}
1037}
1038
1039/** ::highlight() CSS 规则文本(词汇高亮),注入到 EPUB iframe 中
1040 *
1041 * F-⑫ 收口:金句规则 `::highlight(rd-quote-favorite|rd-quote-annotation)` 已**移出本常量**,
1042 * 收归共享组件单源(src-core/reader-component.js 的 QUOTE_HIGHLIGHT_CSS,styleId
1043 * `hord-rd-quote-highlight-css`,逐 doc 幂等注入),与扩展端 src/reader-ui.js:446-4
104360 逐字节一致。
1044 * 原因:本常量只注入 EPUB iframe,TXT 主 doc 从来没有金句规则 → 金句高亮"隐形";
1045 * 且若两处同时定义,iframe 内会出现两份 `::highlight(rd-quote-*)`,靠层叠顺序决胜,不确定。
1046 * ⚠️ 词汇规则仍留在此处(Hub 词汇高亮尚未接入组件,属 Stage 2+ 范围,本轮不动)。
1047 */
1048const HIGHLIGHT_CSS = `
1049::highlight(rd-vocab-red) { background-color: rgba(239, 68, 68, 0.28); text-decoration: none; }
1050::highlight(rd-vocab-yellow) { background-color: rgba(245, 158, 11, 0.28); text-decoration: none; }
1051::highlight(rd-vocab-green) { background-color: rgba(34, 197, 94, 0.28); text-decoration: none; }
1052::highlight(rd-vocab-purple) { background-color: rgba(124, 58, 237, 0.28); text-decoration: none; }
1053`;
1054
1055// ================================================================
1056// 进度 & 状态
1057// ================================================================
1058
1059async function loadState(bookId) {
1060  // Phase F: 优先从 IDB bookshelfMeta 中读取 per-book state
1061  const shelf = bookshelf;
1062  const book = shelf.find(b => b.id === bookId);
1063  state = { ...DEFAULT_STATE, ...(book?.state || lsGet(LS_STATE(bookId), {})) };
1064  // 书签从 IDB readerBookmarks 读取
1065  const idbBm = await idbGetReaderField('readerBookmarks', {});
1066  bookmarks = (idbBm && idbBm[bookId]) ? idbBm[bookId] : lsGet(LS_BM(bookId), []);
1067}
1068
1069function saveState() {
1070  if (!currentBook) return;
1071  // localStorage 保留作为 fallback
1072  lsSet(LS_STATE(currentBook.id), state);
1073  // Phase F: 写入 bookshelfMeta 中的 per-book state
1074  currentBook.state = state;
1075  saveBookshelf();  // saveBookshelf 已处理 IDB 同步
1076}
1077
1078function saveProgress() {
1079  if (!currentBook) return;
1080  // B10: EPUB 不填 paragraphs,不能按 paragraphIndex 算进度(否则恒 0);
1081  // 改用 EPUB 阅读位置百分比;TXT 仍按 paragraphIndex/paragraphs.length。
1082  let prog;
1083  let epubLoc = null;
1084  if (currentBook.type === 'epub') {
1085    try { epubLoc = epubLayout?.rendition?.currentLocation()?.start; } catch (_) {}
1086    const pct = epubLoc?.percentage != null ? epubLoc.percentage : 0;
1087    prog = Math.round((pct || 0) * 100);
1088  } else {
1089    prog = paragraphs.length > 0
1090      ? Math.round((state.paragraphIndex / paragraphs.length) * 100)
1091      : 0;
1092  }
1093  currentBook.progress = Math.min(100, prog);
1094  if (sessionStart > 0) {
1095    currentBook.totalSeconds += Math.round((Date.now() - sessionStart) / 1000);
1096    sessionStart = Date.now();
1097  }
1098  currentBook.lastOpened = Date.now();
1099  // B4: 按书籍类型分流 + 合并写入,绝不清掉已有字段(修复 EPUB 退出被 {paragraphIndex:0} 覆盖导致进度丢失)
1100  const progKey = LS_PROG(currentBook.id);
1101  const prev = lsGet(progKey, {}) || {};
1102  if (currentBook.type === 'epub') {
1103    let cfi = prev.cfi, percentage = prev.percentage;
1104    if (epubLoc) {
1105      if (epubLoc.cfi) cfi = epubLoc.cfi;
1106      if (epubLoc.percentage != null) percentage = epubLoc.percentage;
1107    }
1108    lsSet(progKey, { ...prev, cfi, percentage, lastOpened: Date.now() });
1109  } else {
1110    lsSet(progKey, { ...prev, paragraphIndex: state.paragraphIndex });
1111  }
1112  saveBookshelf();  // saveBookshelf 已处理 IDB 同步
1113}
1114
1115// B4: 标签页直接关闭/导航时兜底保存进度(goBack 已 saveProgress,此处幂等合并无副作用)
1116window.addEventListener('pagehide', () => {
1117  if (currentBook) saveProgress();
1118});
1119
1120// ================================================================
1121// 书签
1122// ================================================================
1123
1124function toggleBookmark() {
1125  if (!currentBook) return;
1126  
1127  let cfi = null;
1128  let text = '';
1129  if (currentBook.type === 'epub' && epubLayout?.rendition) {
1130    cfi = epubLayout.rendition.currentLocation()?.start?.cfi;
1131    text = `位置: ${epubLayout.rendition.currentLocation()?.start?.displayed?.page || 1}`;
1132  } else {
1133    text = (paragraphs[state.paragraphIndex] || '').slice(0, 60);
1134  }
1135  
1136  const pos = bookmarks.findIndex(b => cfi ? b.cfi === cfi : b.index === state.paragraphIndex);
1137  if (pos >= 0) {
1138    bookmarks.splice(pos, 1);
1139    showToast('已移除书签');
1140  } else {
1141    const bmId = cfi ? `epub_${cfi}` : `txt_${state.paragraphIndex ?? 'x'}_${Date.now().toString(36)}`;
1142    bookmarks.push({
1143      id: bmId,
1144      index: state.paragraphIndex,
1145      cfi: cfi,
1146      text: text,
1147      time: Date.now(),
1148      note: ''
1149    });
1150    showToast('已添加书签');
1151  }
1152  persistBookmarks();
1153}
1154
1155/** 统一持久化书签:同时写 localStorage 与 IDB(云端同步通道)。
1156 *  toggleBookmark / 备注保存 / 删除共用,确保写入与读取通道一致(S0-12/B14)。 */
1157function persistBookmarks() {
1158  if (!currentBook) return;
1159  lsSet(LS_BM(currentBook.id), bookmarks);
1160  idbGetReaderField('readerBookmarks', {}).then(function(rb) {
1161    rb[currentBook.id] = bookmarks;
1162    idbSetReaderField('readerBookmarks', rb);
1163  });
1164}
1165
1166/** 书签匹配:优先按 id;旧数据无 id 时回退 index / cfi 匹配(向后兼容)。 */
1167function matchBookmark(b, id, idx, cfi) {
1168  if (b.id && id && b.id === id) return true;
1169  if (!b.id) {
1170    if (!isNaN(idx) && b.index === idx) return true;
1171    if (cfi && b.cfi && b.cfi === cfi) return true;
1172  }
1173  return false;
1174}
1175
1176function isBookmarked() {
1177  if (currentBook?.type === 'epub' && epubLayout?.rendition) {
1178    const cfi = epubLayout.rendition.currentLocation()?.start?.cfi;
1179    return bookmarks.some(b => b.cfi === cfi);
1180  }
1181  return bookmarks.some(b => b.index === state.paragraphIndex);
1182}
1183
1184// ================================================================
1185// Toast 帮助
1186// ================================================================
1187
1188// 对齐插件端阅读器:阅读器反馈统一用「阅读器内联 toast」(底部居中深色胶囊,#hord-reader-toast),
1189// 不再依赖全局右上角 HordHubToast(那是中枢其它页面用的老旧弹窗)。找不到元素时回退全局 toast。
1190let _hordReaderToastEl = null;
1191let _hordReaderToastTimer = null;
1192function showToast(msg, type = 'info') {
1193  try {
1194    let el = _hordReaderToastEl;
1195    if (!el || !document.body.contains(el)) {
1196      el = document.querySelector('#hord-reader-toast');
1197      if (!el) {
1198        el = document.createElement('div');
1199        el.id = 'hord-reader-toast';
1200        el.setAttribute('role', 'status');
1201        el.setAttribute('aria-live', 'polite');
1202        el.style.cssText = 'position:fixed;left:50%;bottom:20px;transform:translateX(-50%);z-index:2147483640;'
1203          + 'display:none;align-items:center;gap:6px;background:rgba(15,23,42,0.92);color:#fff;'
1204          + 'border-radius:999px;padding:9px 16px;font-size:12px;font-weight:700;line-height:1.35;'
1205          + 'box-shadow:0 8px 24px rgba(0,0,0,0.35);max-width:90vw;white-space:normal;text-align:center;'
1206          + 'letter-spacing:.2px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
1207        document.body.appendChild(el);
1208      }
1209      _hordReaderToastEl = el;
1210    }
1211    // 清理旧版本或外部注入的重复节点,保证 querySelector 命中当前可见节点。
1212    document.querySelectorAll('#hord-reader-toast').forEach((node) => {
1213      if (node !== el) node.remove();
1214    });
1215    let accent = '#fff';
1216    if (type === 'success') accent = '#34d399';
1217    else if (type === 'error') accent = '#f87171';
1218    else if (type === 'warning') accent = '#fbbf24';
1219    el.style.borderLeft = '3px solid ' + accent;
1220    el.textContent = msg;
1221    el.style.display = 'inline-flex';
1222    if (_hordReaderToastTimer) clearTimeout(_hordReaderToastTimer);
1223    _hordReaderToastTimer = setTimeout(() => {
1224      el.style.display = 'none';
1225      _hordReaderToastTimer = null;
1226    }, 2600);
1227    return;
1228  } catch (e) {
1229    // 异常兜底:退回全局 toast,保证反馈不丢失
1230  }
1231  if (typeof globalThis.HordHubToast?.show === 'function') {
1232    globalThis.HordHubToast.show(msg, type);
1233  }
1234}
1235
1236// ================================================================
1237// 样式
1238// ================================================================
1239
1240function ensureStyles() {
1241  if (document.getElementById(STYLE_ID)) return;
1242  const style = document.createElement('style');
1243  style.id = STYLE_ID;
1244  style.textContent = `
1245    /* ---- 书架视图 (Manager-style) ---- */
1246    .shelf-page { max-width: 100%; margin: 0 auto; padding: 0; }
1247
1248    /* Hero Banner */
1249    .shelf-brand-hero {
1250      display: flex; align-items: center; justify-content: space-between;
1251      gap: 20px;
1252      padding: 32px 28px; margin-top: 16px; margin-bottom: 16px;
1253      background: linear-gradient(135deg, rgba(124,58,237,0.06) 0%, rgba(20,184,166,0.04) 100%);
1254      border: 1px solid var(--hord-border, rgba(109,40,217,0.10));
1255      border-radius: 24px;
1256      overflow: hidden;
1257      min-height: 140px;
1258      box-sizing: border-box;
1259    }
1260    .hero-svg-art { flex-shrink: 0; opacity: 0.85; }
1261    .hero-brand-copy { display: flex; flex-direction: column; gap: 2px; }
1262    .hero-brand-title {
1263      font-size: 11px; font-weight: 900; text-transform: uppercase;
1264      letter-spacing: 0.08em; color: var(--hord-text-secondary, #94a3b8);
1265    }
1266    .hero-slogan {
1267      font-size: clamp(16px, 2.5vw, 26px); font-weight: 900;
1268      line-height: 1.2; color: var(--hord-text, #0f172a);
1269    }
1270    .hero-subline {
1271      font-size: 12px; color: var(--hord-text-secondary, #94a3b8); margin-top: 4px;
1272    }
1273    .hero-metric-divider { margin: 0 6px; opacity: 0.4; }
1274    .hero-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
1275    .hero-shortcut-btn {
1276      padding: 8px 16px; border: 1px solid var(--hord-border, rgba(109,40,217,0.10));
1277      border-radius: 10px; background: var(--hord-card-bg, #fff);
1278      color: var(--hord-text, #334155); font-size: 13px; font-weight: 700;
1279      cursor: pointer; transition: all 0.15s;
1280    }
1281    .hero-shortcut-btn:hover {
1282      border-color: rgba(124,58,237,0.3);
1283      background: rgba(124,58,237,0.06);
1284    }
1285    .hero-shortcut-settings-btn {
1286      width: 38px; height: 38px; padding: 0; border-radius: 50%;
1287      display: flex; align-items: center; justify-content: center; font-size: 16px;
1288    }
1289
1290    /* Library Control Bar */
1291    .shelf-topbar {
1292      position: sticky;
1293      top: 0;
1294      z-index: 10040;
1295      display: flex; flex-wrap: wrap; align-items: center;
1296      gap: 12px; padding: 12px 16px; margin-bottom: 16px;
1297      background: var(--hord-card-bg, #fff);
1298      border: 1px solid var(--hord-border, rgba(109,40,217,0.10));
1299      border-radius: 12px;
1300    }
1301    .shelf-toolbar-meta { display: flex; flex-direction: column; gap: 2px; }
1302    .shelf-toolbar-label {
1303      font-size: 12px; font-weight: 900; text-transform: uppercase;
1304      letter-spacing: 0.06em; color: var(--hord-text-secondary, #94a3b8);
1305    }
1306    .shelf-toolbar-summary { font-size: 13px; color: var(--hord-text-secondary, #94a3b8); }
1307    .shelf-filters { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
1308    .shelf-input, .shelf-select {
1309      padding: 6px 10px; border: 1px solid var(--hord-border, rgba(109,40,217,0.10));
1310      border-radius: 8px; background: var(--hord-bg-secondary, #f8fafc);
1311      color: var(--hord-text, #334155); font-size: 12px; font-weight: 600;
1312    }
1313    .shelf-input { min-width: 140px; }
1314    .shelf-input:focus, .shelf-select:focus {
1315      outline: none; border-color: rgba(124,58,237,0.4);
1316    }
1317    .shelf-actions { display: flex; gap: 6px; align-items: center; margin-left: auto; }
1318    .shelf-theme-btn {
1319      padding: 6px 12px; border: 1px solid var(--hord-border, rgba(109,40,217,0.10));
1320      border-radius: 8px; background: var(--hord-card-bg, #fff);
1321      color: var(--hord-text, #334155); font-size: 11px; font-weight: 700;
1322      cursor: pointer; transition: all 0.15s;
1323    }
1324    .shelf-theme-btn:hover { border-color: rgba(124,58,237,0.3); }
1325    .shelf-import-btn {
1326      padding: 6px 14px; background: var(--hord-primary, #7c3aed);
1327      color: #fff; border: none; border-radius: 8px;
1328      font-size: 12px; font-weight: 800; cursor: pointer;
1329      transition: all 0.15s;
1330    }
1331    .shelf-import-btn:hover { opacity: 0.9; }
1332    .shelf-view-toggle { display: flex; border: 1px solid var(--hord-border, rgba(109,40,217,0.10)); border-radius: 8px; overflow: hidden; }
1333    .shelf-view-toggle .btn {
1334      padding: 5px 10px; border: none; background: transparent;
1335      color: var(--hord-text-secondary, #94a3b8); font-size: 14px;
1336      cursor: pointer; transition: all 0.15s;
1337    }
1338    .shelf-view-toggle .btn.active {
1339      background: var(--hord-primary, #7c3aed); color: #fff;
1340    }
1341
1342    /* Currently Reading Banner */
1343    .shelf-continue {
1344      display: grid; grid-template-columns: auto 1fr auto;
1345      gap: 16px; align-items: center;
1346      padding: 14px 18px; margin-bottom: 16px;
1347      background: var(--hord-card-bg, #fff);
1348      border: 1px solid var(--hord-border, rgba(109,40,217,0.10));
1349      border-radius: 12px; position: relative; overflow: hidden;
1350    }
1351    .shelf-continue::before {
1352      content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
1353      background: linear-gradient(180deg, #7c3aed, #14b8a6);
1354    }
1355    .shelf-continue.hidden { display: none; }
1356    .continue-cover {
1357      width: 48px; height: 64px; border-radius: 6px; overflow: hidden;
1358      background: linear-gradient(145deg, #ddd1ff, #c7b7ff);
1359      display: flex; align-items: center; justify-content: center;
1360      font-size: 20px; flex-shrink: 0;
1361    }
1362    .continue-cover img { width: 100%; height: 100%; object-fit: cover; }
1363    .continue-title {
1364      font-size: 15px; font-weight: 900;
1365      color: var(--hord-text, #0f172a);
1366      margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1367    }
1368    .continue-meta {
1369      font-size: 12px; color: var(--hord-text-secondary, #94a3b8); margin-bottom: 6px;
1370    }
1371    .continue-progress-wrap { display: flex; align-items: center; gap: 8px; }
1372    .continue-progress {
1373      flex: 1; height: 6px; background: var(--hord-bg-secondary, rgba(109,40,217,0.06));
1374      border-radius: 99px; overflow: hidden;
1375    }
1376    .continue-progress > div {
1377      height: 100%; border-radius: 99px;
1378      background: linear-gradient(90deg, #7c3aed, #14b8a6);
1379      transition: width 0.3s;
1380    }
1381    .continue-pct {
1382      font-size: 12px; font-weight: 800;
1383      color: var(--hord-text, #334155); min-width: 36px; text-align: right;
1384    }
1385    .shelf-continue .btn.primary {
1386      padding: 8px 18px; font-size: 13px; font-weight: 800;
1387      white-space: nowrap; border: none; border-radius: 8px;
1388      background: var(--hord-primary, #7c3aed); color: #fff; cursor: pointer;
1389    }
1390
1391    /* Upload Zone */
1392    .shelf-upload-zone {
1393      border: 2px dashed var(--hord-border, rgba(109,40,217,0.15));
1394      border-radius: 12px; padding: 24px; text-align: center;
1395      cursor: pointer; transition: border-color 0.2s, background 0.2s;
1396      margin-bottom: 16px; color: var(--hord-text-secondary, #94a3b8);
1397    }
1398    .shelf-upload-zone.dragover {
1399      border-color: var(--hord-primary, #7c3aed);
1400      background: rgba(124,58,237,0.06);
1401    }
1402    .shelf-upload-zone input { display: none; }
1403
1404    /* Empty State */
1405    .shelf-empty-state {
1406      text-align: center; padding: 80px 20px;
1407      color: var(--hord-text-secondary, #94a3b8);
1408    }
1409    .shelf-empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; }
1410    .shelf-empty-state .empty-title { font-size: 18px; font-weight: 800; margin-bottom: 8px; }
1411    .shelf-empty-state .empty-desc { font-size: 13px; margin-bottom: 16px; }
1412
1413    /* Book Grid */
1414    .shelf-grid {
1415      display: grid;
1416      grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
1417      gap: 14px;
1418    }
1419    .shelf-grid.list-view {
1420      grid-template-columns: 1fr; gap: 8px;
1421    }
1422    .shelf-grid.compact {
1423      grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px;
1424    }
1425
1426    /* Book Card */
1427    .shelf-book {
1428      background: var(--hord-card-bg, #fff);
1429      border: 1px solid var(--hord-border, rgba(109,40,217,0.10));
1430      border-radius: 12px; overflow: hidden; cursor: pointer;
1431      position: relative; transition: all 0.2s;
1432    }
1433    .shelf-book:hover {
1434      transform: translateY(-4px);
1435      border-color: rgba(124,58,237,0.25);
1436      box-shadow: 0 8px 24px rgba(124,58,237,0.12);
1437    }
1438    .shelf-book:active { transform: translateY(-2px) scale(0.98); }
1439    .shelf-book-cover {
1440      width: 100%; aspect-ratio: 3/4; overflow: hidden;
1441      background: linear-gradient(145deg, #ddd1ff, #c7b7ff 62%, #a993ef);
1442      position: relative;
1443    }
1444    .shelf-book-cover::after {
1445      content: ''; position: absolute; bottom: 0; left: 0; right: 0;
1446      height: 30%; background: linear-gradient(transparent, rgba(0,0,0,0.15));
1447    }
1448    .shelf-book-cover img { width: 100%; height: 100%; object-fit: contain; background: rgba(255,255,255,0.62); object-position: center; }
1449    .shelf-book-info { padding: 9px 10px 10px; }
1450    .shelf-book-name {
1451      font-size: 13px; font-weight: 800; line-height: 1.35;
1452      color: var(--hord-text, #0f172a);
1453      display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
1454      overflow: hidden; min-height: 32px;
1455    }
1456    .shelf-book-meta {
1457      font-size: 11px; color: var(--hord-text-secondary, #94a3b8);
1458      display: flex; gap: 6px; margin-top: 4px;
1459    }
1460    .shelf-book-progress {
1461      height: 4px; margin-top: 6px;
1462      background: var(--hord-bg-secondary, rgba(109,40,217,0.06));
1463      border-radius: 99px; overflow: hidden;
1464    }
1465    .shelf-book-progress > div {
1466      height: 100%; border-radius: 99px;
1467      background: linear-gradient(90deg, #7c3aed, #14b8a6);
1468      transition: width 0.3s;
1469    }
1470    .shelf-book-foot {
1471      font-size: 10px; color: var(--hord-text-secondary, #94a3b8);
1472      display: flex; justify-content: space-between; margin-top: 4px;
1473    }
1474
1475    /* Book Card Actions (hover) */
1476    .shelf-book-actions {
1477      position: absolute; top: 8px; right: 8px;
1478      display: flex; gap: 4px; opacity: 0;
1479      transform: translateY(-4px); transition: all 0.2s; z-index: 2;
1480    }
1481    .shelf-book:hover .shelf-book-actions { opacity: 1; transform: translateY(0); }
1482    .shelf-open-btn {
1483      padding: 4px 10px; border: none; border-radius: 6px;
1484      background: rgba(124,58,237,0.88); color: #fff;
1485      font-size: 11px; font-weight: 800; cursor: pointer;
1486      backdrop-filter: blur(8px);
1487    }
1488    .shelf-open-btn:hover { background: rgba(124,58,237,1); }
1489    .shelf-menu-wrap { position: relative; }
1490    .shelf-menu-btn {
1491      width: 28px; height: 28px; border: none; border-radius: 6px;
1492      background: rgba(15,23,42,0.55); color: #fff;
1493      font-size: 16px; cursor: pointer; backdrop-filter: blur(8px);
1494      display: flex; align-items: center; justify-content: center;
1495    }
1496    .shelf-menu {
1497      display: none; position: absolute; top: 32px; right: 0;
1498      background: var(--hord-card-bg, #fff);
1499      border: 1px solid var(--hord-border, rgba(109,40,217,0.10));
1500      border-radius: 8px; box-shadow: 0 6px 20px rgba(0,0,0,0.12);
1501      min-width: 120px; z-index: 10; overflow: hidden;
1502    }
1503    .shelf-menu.show { display: flex; flex-direction: column; }
1504    .shelf-menu-item {
1505      padding: 8px 14px; border: none; background: transparent;
1506      color: var(--hord-text, #334155); font-size: 12px;
1507      cursor: pointer; text-align: left; transition: background 0.1s;
1508    }
1509    .shelf-menu-item:hover { background: var(--hord-bg-secondary, rgba(109,40,217,0.04)); }
1510    .shelf-menu-item.danger { color: #dc2626; }
1511
1512    /* Add Book Card */
1513    .shelf-add {
1514      border: 2px dashed var(--hord-border, rgba(109,40,217,0.15));
1515      border-radius: 12px; display: flex; flex-direction: column;
1516      align-items: center; justify-content: center; min-height: 240px;
1517      cursor: pointer; transition: all 0.2s; color: var(--hord-text-secondary, #94a3b8);
1518    }
1519    .shelf-add:hover {
1520      border-color: var(--hord-primary, #7c3aed);
1521      background: rgba(124,58,237,0.04);
1522    }
1523    .shelf-add-icon { font-size: 32px; margin-bottom: 8px; }
1524    .shelf-add-text { font-size: 13px; font-weight: 700; }
1525
1526    /* Fallback Cover */
1527    .fallback-cover {
1528      width: 100%; height: 100%; display: flex; flex-direction: column;
1529      justify-content: space-between; padding: 14px 12px;
1530      background: linear-gradient(145deg, var(--fallback-bg1, #ddd1ff), var(--fallback-bg2, #c7b7ff));
1531      color: var(--fallback-text, #4c1d95);
1532    }
1533    .fallback-spine {
1534      position: absolute; left: 0; top: 0; bottom: 0; width: 6px;
1535      background: linear-gradient(180deg, var(--accent, #7c3aed), rgba(0,0,0,0.15));
1536    }
1537    .fallback-brand-row { display: flex; justify-content: flex-end; }
1538    .fallback-brand {
1539      font-size: 10px; font-weight: 900; letter-spacing: 0.08em;
1540      text-transform: uppercase; opacity: 0.75;
1541    }
1542    .fallback-title-wrap { flex: 1; display: flex; align-items: center; padding: 8px 0; }
1543    .fallback-title { font-weight: 900; line-height: 1.2; word-break: break-word; }
1544    .fallback-title.title-short { font-size: 18px; }
1545    .fallback-title.title-medium { font-size: 15px; }
1546    .fallback-title.title-long { font-size: 13px; }
1547    .fallback-title.title-xlong { font-size: 11px; }
1548    .fallback-footer {
1549      display: flex; justify-content: space-between; align-items: center;
1550      font-size: 10px; opacity: 0.7; font-weight: 700;
1551    }
1552    .fallback-format {
1553      padding: 2px 6px; border-radius: 4px;
1554      background: rgba(0,0,0,0.08); font-weight: 800;
1555    }
1556
1557    /* Toast */
1558    .shelf-toast {
1559      position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
1560      padding: 10px 20px; border-radius: 10px;
1561      background: var(--hord-text, #0f172a); color: #fff;
1562      font-size: 13px; font-weight: 700; z-index: 1000;
1563      opacity: 0; transition: opacity 0.3s; pointer-events: none;
1564    }
1565    .shelf-toast.show { opacity: 1; }
1566
1567    /* List view overrides */
1568    .shelf-grid.list-view .shelf-book {
1569      display: grid; grid-template-columns: 56px 1fr auto;
1570      grid-template-areas: "cover info action";
1571      align-items: center;
1572    }
1573    .shelf-grid.list-view .shelf-book-cover {
1574      grid-area: cover; width: 56px; height: 74px; aspect-ratio: auto;
1575    }
1576    .shelf-grid.list-view .shelf-book-info { grid-area: info; }
1577    .shelf-grid.list-view .shelf-book-actions {
1578      grid-area: action; position: static; opacity: 1; transform: none;
1579    }
1580    .shelf-grid.list-view .shelf-add { min-height: 80px; flex-direction: row; gap: 12px; }
1581
1582    @media (max-width: 768px) {
1583      .shelf-brand-hero { grid-template-columns: 1fr; }
1584      .hero-actions { justify-content: flex-start; }
1585      .shelf-topbar { flex-direction: column; align-items: stretch; }
1586      .shelf-actions { margin-left: 0; }
1587      .shelf-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
1588    }
1589
1590    /* ---- 阅读视图 ---- */
1591    .reader-content { display: none; }
1592    .reader-content.active { display: flex; flex-direction: column; height: 100%; position: relative; }
1593
1594    /* ===== 阅读器滚动条主题化(对齐扩展端细款主题化滚动条) ===== */
1595    .reader-layout ::-webkit-scrollbar,
1596    .reader-page ::-webkit-scrollbar { width: 8px; height: 8px; }
1597    .reader-layout ::-webkit-scrollbar-track,
1598    .reader-page ::-webkit-scrollbar-track { background: transparent; }
1599    .reader-layout ::-webkit-scrollbar-thumb,
1600    .reader-page ::-webkit-scrollbar-thumb {
1601      background: color-mix(in srgb, var(--hord-brand, #7c3aed) 35%, var(--hord-border, rgba(109,40,217,0.12)));
1602      border-radius: 4px; border: 2px solid transparent; background-clip: padding-box;
1603    }
1604    .reader-layout ::-webkit-scrollbar-thumb:hover,
1605    .reader-page ::-webkit-scrollbar-thumb:hover {
1606      background: color-mix(in srgb, var(--hord-brand, #7c3aed) 55%, var(--hord-border, rgba(109,40,217,0.12)));
1607      background-clip: padding-box;
1608    }
1609    /* TXT 阅读器 / 侧栏 / EPUB 容器:更精致的细款 */
1610    .reader-content ::-webkit-scrollbar,
1611    .reader-sidebar ::-webkit-scrollbar,
1612    .epub-viewer-wrap ::-webkit-scrollbar { width: 6px; }
1613    /* 暗色 / OLED 变体(Hub 暗色桥接机制) */
1614    [data-theme="dark"] .reader-layout ::-webkit-scrollbar-thumb,
1615    body.vb-force-dark .reader-layout ::-webkit-scrollbar-thumb,
1616    [data-theme="dark"] .reader-page ::-webkit-scrollbar-thumb,
1617    body.vb-force-dark .reader-page ::-webkit-scrollbar-thumb {
1618      background: #555; border-radius: 3px; border: 2px solid transparent; background-clip: padding-box;
1619    }
1620    [data-theme="dark"] .reader-layout ::-webkit-scrollbar-track,
1621    body.vb-force-dark .reader-layout ::-webkit-scrollbar-track,
1622    [data-theme="dark"] .reader-page ::-webkit-scrollbar-track,
1623    body.vb-force-dark .reader-page ::-webkit-scrollbar-track { background: var(--hord-bg, #0f172a); }
1624    [data-theme="oled"] .reader-layout ::-webkit-scrollbar-thumb,
1625    body.vb-force-oled .reader-layout ::-webkit-scrollbar-thumb,
1626    [data-theme="oled"] .reader-page ::-webkit-scrollbar-thumb,
1627    body.vb-force-oled .reader-page ::-webkit-scrollbar-thumb {
1628      background: #333; border-radius: 3px; border: 2px solid transparent; background-clip: padding-box;
1629    }
1630    [data-theme="oled"] .reader-layout ::-webkit-scrollbar-track,
1631    body.vb-force-oled .reader-layout ::-webkit-scrollbar-track,
1632    [data-theme="oled"] .reader-page ::-webkit-scrollbar-track,
1633    body.vb-force-oled .reader-page ::-webkit-scrollbar-track { background: #000; }
1634
1635    .reader-toolbar {
1636      display: flex; align-items: center; justify-content: space-between;
1637      padding: 10px 16px;
1638      border-bottom: 1px solid var(--hord-border, rgba(15,23,42,0.10));
1639      background: var(--hord-card-bg, #ffffff);
1640      color: var(--hord-text, #0f172a);
1641      flex-shrink: 0;
1642      flex-wrap: nowrap;
1643      height: 52px;
1644      box-sizing: border-box;
1645    }
1646    .reader-toolbar-left {
1647      display: flex; align-items: center; gap: 8px;
1648      flex: 1; min-width: 0;
1649    }
1650    .reader-toolbar-center {
1651      display: flex; align-items: center; justify-content: center; gap: 12px;
1652      flex: 0 1 auto; min-width: 0;
1653    }
1654    .reader-toolbar-center:empty { display: none; }
1655    .reader-toolbar-right {
1656      display: flex; align-items: center; justify-content: flex-end; gap: 6px;
1657      flex: 1; min-width: 0;
1658    }
1659
1660    /* ---- 对齐插件端的阅
1660读器顶栏高阶样式 ---- */
1661    .header-brand-wrap {
1662      display: flex;
1663      align-items: center;
1664      gap: 10px;
1665      user-select: none;
1666    }
1667    .header-brand-logo {
1668      width: 32px;
1669      height: 32px;
1670      object-fit: contain;
1671    }
1672    .header-brand-copy {
1673      display: flex;
1674      flex-direction: column;
1675      line-height: 1.2;
1676    }
1677    .brand-tag {
1678      font-size: 13px;
1679      font-weight: 800;
1680      color: #7c3aed;
1681      text-transform: uppercase;
1682      letter-spacing: 0.5px;
1683    }
1684    .brand-subline {
1685      font-size: 12px;
1686      color: #64748b;
1687      font-weight: 600;
1688      white-space: nowrap;
1689    }
1690    .header-divider {
1691      width: 1px;
1692      height: 24px;
1693      background: var(--hord-border, rgba(15,23,42,0.15));
1694      margin: 0 8px;
1695      opacity: 0.6;
1696    }
1697    .reader-status-group {
1698      display: flex;
1699      align-items: center;
1700      gap: 0;
1701      background: rgba(124, 58, 237, 0.06);
1702      border: 1px solid rgba(124, 58, 237, 0.14);
1703      border-radius: 999px;
1704      padding: 2px 10px;
1705      height: 34px;
1706      box-sizing: border-box;
1707    }
1708    .status-pill-item {
1709      display: flex;
1710      align-items: center;
1711      gap: 6px;
1712      font-size: 12px;
1713      font-weight: 600;
1714      white-space: nowrap;
1715      max-width: 240px;
1716      color: var(--hord-text, #0f172a);
1717    }
1718    .status-pill-item .chapter-name {
1719      overflow: hidden;
1720      text-overflow: ellipsis;
1721      max-width: 150px;
1722    }
1723    .status-pill-sep {
1724      margin: 0 10px;
1725      color: rgba(124, 58, 237, 0.25);
1726      user-select: none;
1727      font-weight: 300;
1728    }
1729    .book-title {
1730      font-size: 14px;
1731      font-weight: 800;
1732      color: var(--hord-text, #0f172a);
1733      max-width: min(35vw, 550px);
1734      white-space: nowrap;
1735      overflow: hidden;
1736      text-overflow: ellipsis;
1737      text-align: center;
1738    }
1739    .font-pill {
1740      display: flex;
1741      align-items: center;
1742      background: rgba(124, 58, 237, 0.05);
1743      border: 1px solid var(--hord-border, rgba(15,23,42,0.15));
1744      border-radius: 999px;
1745      padding: 2px 4px;
1746      height: 32px;
1747      box-sizing: border-box;
1748    }
1749    .font-pill .btn {
1750      border: none !important;
1751      background: transparent !important;
1752      width: 28px;
1753      height: 28px;
1754      padding: 0 !important;
1755      display: flex;
1756      align-items: center;
1757      justify-content: center;
1758      font-weight: 700;
1759      cursor: pointer;
1760      color: var(--hord-text, #0f172a);
1761      transition: opacity 0.2s;
1762    }
1763    .font-pill .btn:hover {
1764      opacity: 0.7;
1765    }
1766    .font-pill .font-size-label {
1767      min-width: 24px;
1768      text-align: center;
1769      font-weight: 800;
1770      font-size: 12px;
1771      color: #7c3aed;
1772    }
1773    .reader-theme-btn {
1774      display: inline-flex;
1775      align-items: center;
1776      gap: 6px;
1777      padding: 4px 12px !important;
1778      border-radius: 999px !important;
1779      font-weight: 600;
1780    }
1781    .reader-toolbar-right .btn {
1782      background: none;
1783      border: 1px solid var(--hord-border, rgba(15,23,42,0.20));
1784      border-radius: 8px;
1785      width: 32px;
1786      height: 32px;
1787      padding: 0;
1788      cursor: pointer;
1789      font-size: 13px;
1790      display: flex;
1791      align-items: center;
1792      justify-content: center;
1793      color: var(--hord-text, #0f172a);
1794      transition: all 0.2s;
1795    }
1796    .reader-toolbar-right .btn:hover {
1797      background: var(--hord-bg-secondary, rgba(15,23,42,0.06));
1798    }
1799    .reader-toolbar-right .btn.active {
1800      background: #7c3aed;
1801      color: #ffffff;
1802      border-color: #7c3aed;
1803    }
1804    .reader-toolbar-right .reader-theme-btn {
1805      width: auto !important;
1806    }
1807
1808    /* 暗黑模式对齐 */
1809    [data-theme="dark"] .brand-tag,
1810    body.vb-force-dark .brand-tag {
1811      color: #c4b5fd;
1812    }
1813    [data-theme="dark"] .reader-status-group,
1814    body.vb-force-dark .reader-status-group {
1815      background: rgba(167, 139, 250, 0.08);
1816      border-color: rgba(167, 139, 250, 0.2);
1817    }
1818    [data-theme="dark"] .status-pill-sep,
1819    body.vb-force-dark .status-pill-sep {
1820      color: rgba(167, 139, 250, 0.3);
1821    }
1822    [data-theme="dark"] .font-pill,
1823    body.vb-force-dark .font-pill {
1824      background: rgba(167, 139, 250, 0.06);
1825      border-color: rgba(167, 139, 250, 0.2);
1826    }
1827    [data-theme="dark"] .font-pill .font-size-label,
1828    body.vb-force-dark .font-pill .font-size-label {
1829      color: #a78bfa;
1830    }
1831
1832    /* round5: 暗色主题下顶栏/书架文字对比度(修复"文字太淡") */
1833    [data-theme="dark"] .brand-subline,
1834    body.vb-force-dark .brand-subline { color: #cbd5e1 !important; }
1835    [data-theme="dark"] .shelf-toolbar-label,
1836    body.vb-force-dark .shelf-toolbar-label { color: #cbd5e1; }
1837    [data-theme="dark"] .shelf-toolbar-label,
1838    body.vb-force-dark .shelf-toolbar-label,
1839    [data-theme="dark"] .shelf-toolbar-summary,
1840    body.vb-force-dark .shelf-toolbar-summary,
1841    [data-theme="dark"] .shelf-book-meta,
1842    body.vb-force-dark .shelf-book-meta,
1843    [data-theme="dark"] .continue-meta,
1844    body.vb-force-dark .continue-meta { color: #cbd5e1 !important; }
1845    [data-theme="dark"] .shelf-book-name,
1846    body.vb-force-dark .shelf-book-name { color: #e2e8f0 !important; }
1847    [data-theme="dark"] .reader-toolbar .tb-label,
1848    body.vb-force-dark .reader-toolbar .tb-label,
1849    [data-theme="dark"] .reader-toolbar .tb-info,
1850    body.vb-force-dark .reader-toolbar .tb-info { color: #cbd5e1 !important; }
1851
1852    .reader-toolbar .tb-btn {
1853      background: none; border: 1px solid var(--hord-border, rgba(15,23,42,0.20));
1854      border-radius: 6px; padding: 4px 10px; cursor: pointer;
1855      font-size: 0.85rem;
1856      color: var(--hord-text, #0f172a);
1857    }
1858    .reader-toolbar .tb-label {
1859      color: var(--hord-text, #0f172a);
1860    }
1861    .reader-toolbar .tb-sep {
1862      color: var(--hord-border, rgba(15,23,42,0.20));
1863    }
1864    .reader-toolbar .tb-btn:hover { background: var(--hord-bg-secondary, rgba(15,23,42,0.06)); }
1865    .reader-toolbar .tb-btn.active {
1866      background: var(--hord-primary, #7c3aed);
1867      color: #fff; border-color: var(--hord-primary, #7c3aed);
1868    }
1869    .reader-toolbar .tb-sep {
1870      width: 1px; height: 20px; background: var(--hord-border, #ddd);
1871    }
1872    .reader-toolbar .tb-label {
1873      font-size: 0.8rem; color: var(--hord-text-secondary, #888);
1874    }
1875    .reader-toolbar .tb-title {
1876      font-weight: 800; font-size: 0.95rem;
1877      color: var(--hord-text, #0f172a);
1878      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1879      max-width: 260px;
1880    }
1881    .reader-toolbar .tb-spacer { flex: 1; }
1882    .reader-toolbar .tb-info {
1883      font-size: 0.8rem; font-weight: 700;
1884      color: var(--hord-text-secondary, #888);
1885    }
1886
1887    .reader-body {
1888      flex: 1; overflow-y: auto; padding: 24px 16px;
1889      max-width: var(--rd-content-width, 1800px); margin: 0 auto; width: 100%;
1890    }
1891    .reader-body img { max-width: 100% !important; height: auto !important; }
1892    .reader-paragraph {
1893      margin-bottom: var(--reader-para-spacing, 0.6em);
1894      text-indent: 2em;
1895      line-height: var(--reader-line-height, 1.8);
1896      font-size: var(--reader-font-size, 18px);
1897    }
1898    .reader-paragraph.current-para {
1899      background: var(--hord-primary-bg, rgba(79,140,255,0.06));
1900      border-radius: 4px;
1901      padding: 2px 4px;
1902    }
1903
1904    /* 词汇高亮 */
1905    .vocab-hl {
1906      cursor: pointer; border-radius: 2px; padding: 0 1px;
1907      position: relative;
1908    }
1909    .vocab-hl--red { background: rgba(231,76,60,0.18); color: #c0392b; }
1910    .vocab-hl--yellow { background: rgba(243,156,18,0.18); color: #b8860b; }
1911    .vocab-hl--green { background: rgba(39,174,96,0.18); color: #27ae60; }
1912    .vocab-hl--note { background: rgba(124,58,237,0.16); color: #7c3aed; }
1913
1914    .reader-footer {
1915      flex-shrink: 0; padding: 10px 16px;
1916      border-top: 1px solid var(--hord-border, #e0e0e0);
1917      display: flex; align-items: center; gap: 12px;
1918    }
1919    .reader-progress-bar {
1920      flex: 1; height: 4px; background: var(--hord-bg-secondary, #eee);
1921      border-radius: 2px; overflow: hidden;
1922    }
1923    .reader-progress-fill {
1924      height: 100%; background: var(--hord-primary, #7c3aed);
1925      transition: width 0.2s;
1926    }
1927    .reader-progress-text {
1928      font-size: 0.8rem; color: var(--hord-text-secondary, #888);
1929      min-width: 40px; text-align: right;
1930    }
1931
1932    /* 书签面板 */
1933    .reader-bm-panel {
1934      position: fixed; top: 0; right: 0; bottom: 0; width: 320px;
1935      background: var(--hord-card-bg, #fff);
1936      border-left: 1px solid var(--hord-border, #ddd);
1937      box-shadow: -4px 0 16px rgba(0,0,0,0.08);
1938      z-index: 1000; padding: 20px;
1939      overflow-y: auto;
1940      transform: translateX(100%);
1941      transition: transform 0.3s;
1942    }
1943    .reader-bm-panel.open { transform: translateX(0); }
1944    .reader-bm-panel h3 { margin-top: 0; }
1945    .reader-bm-item {
1946      padding: 10px 0; border-bottom: 1px solid var(--hord-border, #eee);
1947      cursor: pointer;
1948    }
1949    .reader-bm-item:hover { background: var(--hord-bg-secondary, #f9f9f9); }
1950    .reader-bm-text { font-size: 0.85rem; margin-bottom: 4px; }
1951    .reader-bm-time { font-size: 0.75rem; color: var(--hord-text-secondary, #999); }
1952    .reader-bm-note {
1953      width: 100%; box-sizing: border-box;
1954      margin-top: 6px;
1955      padding: 6px 8px;
1956      border: 1px solid var(--hord-border, #ddd);
1957      border-radius: 6px;
1958      background: var(--hord-card-bg, #fff);
1959      color: var(--hord-text, #333);
1960      font-size: 0.8rem;
1961      resize: vertical;
1962      font-family: inherit;
1963    }
1964    .reader-bm-delete {
1965      margin-top: 4px;
1966      padding: 2px 8px;
1967      border: 1px solid rgba(239,68,68,0.2);
1968      border-radius: 4px;
1969      background: none;
1970      color: #ef4444;
1971      font-size: 0.75rem;
1972      cursor: pointer;
1973    }
1974    .reader-bm-delete:hover { background: rgba(239,68,68,0.1); }
1975    .reader-bm-empty { color: var(--hord-text-secondary, #888); text-align: center; padding: 32px 0; }
1976
1977    /* 书签段落高亮 */
1978    .bookmarked-para {
1979      border-left: 3px solid #f59e0b;
1980      padding-left: 8px;
1981      background: rgba(245,158,11,0.06);
1982    }
1983
1984    /* 金句高亮标记 */
1985    .quote-hl {
1986      border-bottom: 2px dashed #f59e0b;
1987      cursor: pointer;
1988      transition: background 0.15s;
1989    }
1990    .quote-hl:hover { background: rgba(245,158,11,0.15); }
1991
1992    /* 金句 tooltip */
1993    .quote-tooltip {
1994      position: fixed;
1995      z-index: 2147483646;
1996      max-width: 320px;
1997      padding: 8px 12px;
1998      background: rgba(30,27,46,0.95);
1999      color: #e7eeff;
2000      border: 1px solid rgba(245,158,11,0.3);
2001      border-radius: 10px;
2002      font-size: 0.8rem;
2003      line-height: 1.4;
2004      box-shadow: 0 8px 24px rgba(0,0,0,0.3);
2005      pointer-events: none;
2006    }
2007    .quote-tooltip .qt-text { font-style: italic; margin-bottom: 4px; }
2008    .quote-tooltip .qt-meta { font-size: 0.7rem; color: #94a3b8; }
2009
2010    /* EPUB 占位 */
2011    .reader-epub-placeholder {
2012      text-align: center; padding: 60px 20px;
2013      color: var(--hord-text-secondary, #888);
2014    }
2015    .reader-epub-placeholder .icon { font-size: 3rem; margin-bottom: 16px; }
2016
2017    /* EPUB 阅读器视图 */
2018    .epub-viewer-wrap,
2019    .txt-viewer-wrap {
2020      flex: 1;
2021      display: flex;
2022      align-items: stretch;
2023      position: relative;
2024      overflow: hidden;
2025      min-height: 0;
2026    }
2027    .txt-viewer-wrap {
2028      width: 100%;
2029      background: inherit;
2030    }
2031    .epub-viewer {
2032      flex: 1;
2033      min-height: 0;
2034      overflow: hidden;
2035    }
2036    .epub-nav-btn {
2037      position: absolute;
2038      top: 50%;
2039      transform: translateY(-50%);
2040      z-index: 5;
2041      width: 44px;
2042      height: 80px;
2043      border: none;
2044      border-radius: 8px;
2045      background: rgba(0,0,0,0.08);
2046      color: var(--hord-text, #333);
2047      font-size: 1.5rem;
2048      cursor: pointer;
2049      display: flex;
2050      align-items: center;
2051      justify-content: center;
2052      transition: background 0.2s;
2053    }
2054    .epub-nav-btn:hover { background: rgba(0,0,0,0.15); }
2055    .epub-nav-btn:disabled,
2056    .epub-nav-btn[aria-busy="true"] { opacity: .45; cursor: wait; }
2057    .epub-nav-prev { left: 8px; }
2058    .epub-nav-next { right: 8px; }
2059    @media (max-width: 600px) {
2060      .epub-nav-btn { width: 32px; height: 56px; font-size: 1.2rem; }
2061    }
2062
2063    /* ---- 阅读布局(侧边栏 + 主内容) ---- */
2064    .reader-layout {
2065      display: flex;
2066      flex: 1 1 auto;
2067      width: 100%;
2068      min-width: 100%;
2069      max-width: 100%;
2070      height: 100%;
2071      margin-top: 0;
2072      position: relative;
2073      overflow: hidden;
2074    }
2075    .reader-page { margin-top: 0; }
2076
2077    /* 主内容区 */
2078    .reader-main {
2079      flex: 1 1 0%;
2080      width: 0;
2081      max-width: none;
2082      display: flex;
2083      flex-direction: column;
2084      min-width: 0;
2085      min-height: 0;
2086      overflow: hidden;
2087      position: relative;
2088    }
2089
2090    /* 侧边栏 */
2091    .reader-sidebar {
2092      width: 280px;
2093      min-width: 280px;
2094      background: var(--hord-card-bg, #fff);
2095      border-right: 1px solid var(--hord-border, #e0e0e0);
2096      display: flex;
2097      flex-direction: column;
2098      flex-shrink: 0;
2099      margin-left: -280px;
2100      transition: margin-left 0.3s ease;
2101      z-index: 10;
2102    }
2103    .reader-sidebar.open {
2104      margin-left: 0;
2105    }
2106    .reader-sidebar-tabs {
2107      display: flex;
2108      border-bottom: 1px solid var(--hord-border, #e0e0e0);
2109      flex-shrink: 0;
2110    }
2111    .reader-sidebar-tab {
2112      flex: 1;
2113      padding: 10px 8px;
2114      background: none;
2115      border: none;
2116      border-bottom: 2px solid transparent;
2117      font-size: 0.85rem;
2118      cursor: pointer;
2119      color: var(--hord-text-secondary, #888);
2120      transition: all 0.2s;
2121    }
2122    .reader-sidebar-tab:hover {
2123      color: var(--hord-text, #333);
2124      background: var(--hord-bg-secondary, #f9f9f9);
2125    }
2126    .reader-sidebar-tab.active {
2127      color: var(--hord-primary, #7c3aed);
2128      border-bottom-color: var(--hord-primary, #7c3aed);
2129      font-weight: 600;
2130    }
2131    .reader-sidebar-body {
2132      flex: 1;
2133      overflow-y: auto;
2134      padding: 8px 0;
2135    }
2136    .reader-sidebar-empty {
2137      text-align: center;
2138      padding: 32px 16px;
2139      color: var(--hord-text-secondary, #999);
2140      font-size: 0.85rem;
2141    }
2142    .reader-sidebar-action {
2143      width: calc(100% - 24px);
2144      margin: 0 12px 10px;
2145      padding: 8px 10px;
2146      border: 1px solid var(--hord-border, #e0e0e0);
2147      border-radius: 8px;
2148      background: var(--hord-card-bg, #fff);
2149      color: var(--hord-text, #333);
2150      font-size: 0.82rem;
2151      font-weight: 700;
2152      cursor: pointer;
2153    }
2154    .reader-sidebar-action:hover {
2155      border-color: var(--hord-primary, #7c3aed);
2156      color: var(--hord-primary, #7c3aed);
2157      background: var(--hord-primary-bg, rgba(79,140,255,0.06));
2158    }
2159
2160    /* TOC 项 */
2161    .reader-toc-item {
2162      display: flex;
2163      align-items: center;
2164      justify-content: space-between;
2165      padding: 8px 16px;
2166      cursor: pointer;
2167      font-size: 0.85rem;
2168      color: var(--hord-text, #444);
2169      border-left: 3px solid transparent;
2170      transition: all 0.15s;
2171    }
2172    .reader-toc-item:hover {
2173      background: var(--hord-bg-secondary, #f5f5f5);
2174    }
2175    .reader-toc-item.active {
2176      background: var(--hord-primary-bg, rgba(79,140,255,0.06));
2177      border-left-color: var(--hord-primary, #7c3aed);
2178      color: var(--hord-primary, #7c3aed);
2179      font-weight: 600;
2180    }
2181    .reader-toc-title {
2182      flex: 1;
2183      overflow: hidden;
2184      text-overflow: ellipsis;
2185      white-space: nowrap;
2186    }
2187    .reader-toc-page {
2188      font-size: 0.75rem;
2189      color: var(--hord-text-secondary, #aaa);
2190      margin-left: 8px;
2191      flex-shrink: 0;
2192    }
2193
2194    /* 设置面板 */
2195    @keyframes rd-popup-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
2196    .reader-settings-panel {
2197      display: none;
2198      position: absolute;
2199      top: 54px;
2200      right: 16px;
2201      width: 340px;
2202      max-height: calc(100vh - 80px);
2203      overflow-y: auto;
2204      background: var(--hord-card-bg, #ffffff);
2205      border: 1px solid var(--hord-border, rgba(15,23,42,0.15));
2206      border-radius: 14px;
2207      box-shadow: 0 12px 40px rgba(0,0,0,0.15);
2208      z-index: 999;
2209      padding: 16px;
2210      flex-shrink: 0;
2211      animation: rd-popup-in 0.18s ease;
2212    }
2213    .reader-settings-panel.open { display: block; }
2214    [data-theme="dark"] .reader-settings-panel,
2215    body.vb-force-dark .reader-settings-panel {
2216      background: var(--hord-card-bg, #0f172a);
2217      border-color: var(--hord-border, rgba(167,139,250,0.25));
2218    }
2219    [data-theme="oled"] .reader-settings-panel,
2220    body.vb-force-oled .reader-settings-panel {
2221      background: #000;
2222      border-color: rgba(167,139,250,0.3);
2223    }
2224    /* T3 ⚙️ 悬浮快捷键帮助 popup(对齐扩展 .shortcut-help-popup,默认隐藏) */
2225    .shortcut-help-popup {
2226      position: fixed;
2227      top: 54px;
2228      right: 16px;
2229      width: 320px;
2230      background: var(--hord-card-bg, #ffffff);
2231      border: 1px solid var(--hord-border, rgba(15,23,42,0.15));
2232      border-radius: 14px;
2233      box-shadow: 0 12px 40px rgba(0,0,0,0.15);
2234      z-index: 1000;
2235      display: none;
2236      animation: rd-popup-in 0.18s ease;
2237    }
2238    .shortcut-help-popup.show { display: block; }
2239    .shortcut-help-popup-inner { padding: 16px; }
2240    .shortcut-help-title { font-size: 14px; font-weight: 800; margin-bottom: 12px; color: var(--hord-text, #0f172a); }
2241    .shortcut-table { width: 100%; border-collapse: collapse; }
2242    .shortcut-table td { padding: 6px 0; font-size: 12px; border-bottom: 1px solid var(--hord-border, rgba(15,23,42,0.10)); }
2243    .shortcut-table tr:last-child td { border-bottom: none; }
2244    .shortcut-key {
2245      font-family: ui-monospace, monospace; font-weight: 700;
2246      color: var(--hord-brand, #7c3aed);
2247      background: color-mix(in srgb, var(--hord-brand, #7c3aed) 12%, transparent);
2248      padding: 2px 6px; border-radius: 4px; display: inline-block; min-width: 40px; text-align: center;
2249    }
2250    .shortcut-desc { color: var(--hord-text-secondary, #94a3b8); padding-left: 10px; }
2251    .shortcut-category {
2252      font-size: 11px; font-weight: 900; color: var(--hord-text-secondary, #94a3b8);
2253      text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 0 4px 0;
2254      border-top: 2px solid var(--hord-border, rgba(15,23,42,0.10)); margin-top: 8px;
2255    }
2256    .shortcut-category:first-child { border-top: none; margin-top: 0; }
2257    [data-theme="dark"] .shortcut-help-popup,
2258    body.vb-force-dark .shortcut-help-popup {
2259      background: var(--hord-card-bg, #0f172a);
2260      border-color: var(--hord-border, rgba(167,139,250,0.25));
2261    }
2262    [data-theme="oled"] .shortcut-help-popup,
2263    body.vb-force-oled .shortcut-help-popup {
2264      background: #000;
2265      border-color: rgba(167,139,250,0.3);
2266    }
2267    .reader-settings-row {
2268      display: flex;
2269      align-items: center;
2270      gap: 10px;
2271      margin-bottom: 12px;
2272      font-size: 0.85rem;
2273      color: var(--hord-text, #444);
2274    }
2275    .reader-settings-row:last-child { margin-bottom: 0; }
2276    .reader-settings-row label {
2277      min-width: 72px;
2278      font-weight: 600;
2279      color: var(--hord-text-secondary, #666);
2280      flex-shrink: 0;
2281    }
2282    .reader-settings-row input[type="range"] {
2283      flex: 1;
2284      accent-color: var(--hord-primary, #7c3aed);
2285      height: 6px;
2286      background: #e2e8f0;
2287      border-radius: 999px;
2288      outline: none;
2289    }
2290    .reader-settings-val {
2291      min-width: 45px;
2292      text-align: right;
2293      font-size: 0.8rem;
2294      font-weight: 700;
2295      color: var(--hord-text-secondary, #888);
2296    }
2297    .reader-settings-select {
2298      flex: 1;
2299      padding: 6px 10px;
2300      border: 1px solid var(--hord-border, #ddd);
2301      border-radius: 8px;
2302      background: var(--hord-card-bg, #fff);
2303      color: var(--hord-text, #333);
2304      font-size: 0.85rem;
2305    }
2306    /* 设置面板:主题色块 */
2307    .reader-swatch-row {
2308      display: flex; gap: 8px; flex-wrap: wrap;
2309      margin-bottom: 12px;
2310    }
2311    .reader-swatch {
2312      width: 28px; height: 28px; border-radius: 50%;
2313      cursor: pointer; transition: transform 0.15s;
2314      box-shadow: 0 1px 4px rgba(0,0,0,0.12);
2315    }
2316    .reader-swatch:hover { transform: scale(1.15); }
2317    .reader-swatch.active { box-shadow: 0 0 0 3px var(--hord-primary, #7c3aed); }
2318    /* 设置面板:分区标题 */
2319    .reader-settings-section h4 {
2320      margin: 0 0 8px;
2321      font-size: 0.8rem;
2322      font-weight: 700;
2323      color: var(--hord-text-secondary, #888);
2324      text-transform: uppercase;
2325      letter-spacing: 0.05em;
2326    }
2327    /* 设置面板:快捷键表格 */
2328    .reader-shortcut-table {
2329      width: 100%; border-collapse: collapse;
2330      font-size: 0.8rem;
2331    }
2332    .reader-shortcut-table td {
2333      padding: 4px 0;
2334      color: var(--hord-text, #333);
2335    }
2336    .reader-shortcut-table td:first-child {
2337      white-space: nowrap;
2338      padding-right: 12px;
2339    }
2340    .reader-shortcut-table kbd {
2341      display: inline-block;
2342      padding: 1px 5px;
2343      font-size: 0.75rem;
2344      border: 1px solid var(--hord-border, #ddd);
2345      border-radius: 4px;
2346      background: var(--hord-card-bg, #fff);
2347      color: var(--hord-text, #333);
2348      font-family: monospace;
2349      margin: 0 2px;
2350    }
2351    .reader-toolbar .theme-label {
2352      font-size: 0.75rem;
2353      font-weight: 500;
2354      color: var(--hord-text-secondary, #666);
2355    }
2356    /* 搜索面板 */
2357    .reader-search-panel {
2358      position: absolute;
2359      top: 54px;
2360      right: 16px;
2361      display: none;
2362      align-items: center;
2363      gap: 6px;
2364      padding: 12px;
2365      background: var(--hord-card-bg, #ffffff);
2366      border: 1px solid var(--hord-border, rgba(15,23,42,0.15));
2367      border-radius: 12px;
2368      box-shadow: 0 12px 40px rgba(0,0,0,0.15);
2369      z-index: 1000;
2370      flex-shrink: 0;
2371    }
2372    .reader-search-panel.open { display: flex; }
2373    .reader-search-panel input {
2374      flex: 1;
2375      max-width: 260px;
2376      padding: 6px 12px;
2377      border: 1px solid var(--hord-border, #ddd);
2378      border-radius: 6px;
2379      background: var(--hord-card-bg, #fff);
2380      color: var(--hord-text, #333);
2381      font-size: 0.85rem;
2382      outline: none;
2383    }
2384    .reader-search-panel input:focus {
2385      border-color: var(--hord-primary, #7c3aed);
2386    }
2387
2388    /* 金句侧边栏项 */
2389    .reader-quote-item {
2390      padding: 10px 12px;
2391      border-bottom: 1px solid var(--hord-border, #eee);
2392      cursor: pointer;
2393      transition: background 0.15s;
2394    }
2395    .reader-quote-item:hover {
2396      background: var(--hord-bg-hover, rgba(0,0,0,0.03));
2397    }
2398    .reader-quote-type {
2399      font-size: 0.7rem;
2400      color: var(--hord-text-secondary, #888);
2401      margin-bottom: 4px;
2402    }
2403    .reader-quote-text {
2404      font-size: 0.85rem;
2405      color: var(--hord-text, #333);
2406      line-height: 1.5;
2407      margin-bottom: 4px;
2408    }
2409    .reader-quote-note {
2410      font-size: 0.8rem;
2411      color: var(--hord-primary, #7c3aed);
2412      background: rgba(124, 58, 237, 0.06);
2413      padding: 4px 8px;
2414      border-radius: 4px;
2415      margin-bottom: 4px;
2416    }
2417    .reader-quote-meta {
2418      display: flex;
2419      justify-content: space-between;
2420      align-items: center;
2421      font-size: 0.7rem;
2422      color: var(--hord-text-secondary, #aaa);
2423    }
2424    .reader-quote-del {
2425      background: none;
2426      border: none;
2427      color: var(--hord-text-secondary, #999);
2428      cursor: pointer;
2429      font-size: 0.8rem;
2430      padding: 2px 6px;
2431      border-radius: 4px;
2432    }
2433    .reader-quote-del:hover {
2434      background: rgba(239, 68, 68, 0.1);
2435      color: #ef4444;
2436    }
2437
2438    /* 浮动收藏按钮 */
2439    .rd-quote-save-btn {
2440      padding: 6px 14px;
2441      background: linear-gradient(135deg, #f59e0b, #d97706);
2442      color: #fff;
2443      border: none;
2444      border-radius: 8px;
2445      font-size: 0.8rem;
2446      font-weight: 600;
2447      cursor: pointer;
2448      box-shadow: 0 2px 8px rgba(0,0,0,0.15);
2449      transition: transform 0.15s, box-shadow 0.15s;
2450      white-space: nowrap;
2451    }
2452    .rd-quote-save-btn:hover {
2453      transform: translateY(-1px);
2454      box-shadow: 0 4px 12px rgba(0,0,0,0.2);
2455    }
2456
2457    /* 标题标签(工具栏章节名) */
2458    .reader-title-label {
2459      max-width: 200px;
2460      overflow: hidden;
2461      text-overflow: ellipsis;
2462      white-space: nowrap;
2463      font-weight: 500;
2464    }
2465    .reader-time-label {
2466      font-size: 0.8rem;
2467      color: var(--hord-text-secondary, #888);
2468      white-space: nowrap;
2469      flex-shrink: 0;
2470      margin-left: auto;
2471    }
2472
2473    /* 增强词汇提示 */
2474    .vocab-tooltip {
2475      position: fixed; z-index: 9999;
2476      background: var(--hord-card-bg, #fff);
2477      border: 1px solid var(--hord-border, #ddd);
2478      border-radius: 10px; padding: 12px 16px;
2479      box-shadow: 0 6px 24px rgba(0,0,0,0.15);
2480      max-width: 340px; font-size: 0.9rem;
2481    }
2482    .vocab-tooltip .vt-word {
2483      font-weight: 700; font-size: 1.05rem;
2484      margin-bottom: 4px;
2485      display: flex; align-items: center; gap: 8px;
2486    }
2487    .vocab-tooltip .vt-meaning {
2488      color: var(--hord-text-secondary, #666);
2489      margin-bottom: 8px;
2490      line-height: 1.5;
2491    }
2492    .vocab-tooltip .vt-actions {
2493      display: flex; gap: 6px;
2494      border-top: 1px solid var(--hord-border, #eee);
2495      padding-top: 8px;
2496    }
2497    .vocab-tooltip .vt-btn {
2498      padding: 4px 10px;
2499      border: 1px solid var(--hord-border, #ddd);
2500      border-radius: 6px;
2501      background: var(--hord-card-bg, #fff);
2502      color: var(--hord-text, #444);
2503      font-size: 0.8rem;
2504      cursor: pointer;
2505      transition: all 0.15s;
2506    }
2507    .vocab-tooltip .vt-btn:hover {
2508      background: var(--hord-bg-secondary, #f5f5f5);
2509      border-color: var(--hord-primary, #7c3aed);
2510    }
2511    .vocab-tooltip .vt-btn.vt-btn--primary {
2512      background: var(--hord-primary, #7c3aed);
2513      color: #fff;
2514      border-color: var(--hord-primary, #7c3aed);
2515    }
2516    .vocab-tooltip .vt-btn.vt-btn--primary:hover {
2517      opacity: 0.9;
2518    }
2519
2520    @media (max-width: 768px) {
2521      .reader-sidebar { width: 260px; min-width: 260px; margin-left: -260px; }
2522      .reader-title-label { max-width: 120px; }
2523      /* 工具栏按钮折叠 */
2524      .reader-toolbar .tb-sep:nth-child(n+8),
2525      .reader-toolbar #rdFontMinus,
2526      .reader-toolbar #rdFontPlus,
2527      .reader-toolbar #rdFontSize,
2528      .reader-toolbar #rdSettingsToggle,
2529      .reader-toolbar .rd-search-restore { display: none; }
2530      .reader-toolbar { gap: 4px; padding: 8px 10px; }
2531      .reader-toolbar .tb-btn { padding: 3px 8px; font-size: 0.8rem; }
2532      .bookmarked-para { border-left-width: 2px; padding-left: 6px; }
2533    }
2534
2535    /* ═══ Dark Mode (对齐 Manager content.js 暗色色彩) ═══ */
2536    [data-theme="dark"] .reader-toolbar,
2537    body.vb-force-dark .reader-toolbar { background: #161830; border-bottom-color: rgba(167,139,250,.25); }
2538    [data-theme="dark"] .reader-toolbar .tb-btn,
2539    body.vb-force-dark .reader-toolbar .tb-btn {
2540      color: #e2e8f0; border-color: rgba(148,163,184,.3);
2541    }
2542    [data-theme="dark"] .reader-toolbar .tb-btn:hover,
2543    body.vb-force-dark .reader-toolbar .tb-btn:hover { background: rgba(167,139,250,.15); border-color: rgba(167,139,250,.4); }
2544    [data-theme="dark"] .reader-toolbar .tb-btn.active,
2545    body.vb-force-dark .reader-toolbar .tb-btn.active { background: #7c3aed; color: #fff; border-color: #7c3aed; }
2546    [data-theme="dark"] .reader-toolbar .tb-title,
2547    body.vb-force-dark .reader-toolbar .tb-title { color: #eef2ff; }
2548    [data-theme="dark"] .reader-toolbar .tb-label,
2549    [data-theme="dark"] .reader-toolbar .tb-info,
2550    body.vb-force-dark .reader-toolbar .tb-label,
2551    body.vb-force-dark .reader-toolbar .tb-info { color: rgba(226,232,240,.68); }
2552    [data-theme="dark"] .reader-toolbar .tb-sep,
2553    body.vb-force-dark .reader-toolbar .tb-sep { background: rgba(167,139,250,.25); }
2554    [data-theme="dark"] .vocab-hl--red,
2555    body.vb-force-dark .vocab-hl--red { background: rgba(248,113,113,.20); color: #fca5a5; }
2556    [data-theme="dark"] .vocab-hl--yellow,
2557    body.vb-force-dark .vocab-hl--yellow { background: rgba(251,191,36,.22); color: #fcd34d; }
2558    [data-theme="dark"] .vocab-hl--green,
2559    body.vb-force-dark .vocab-hl--green { background: rgba(74,222,128,.20); color: #86efac; }
2560    [data-theme="dark"] .vocab-hl--note,
2561    body.vb-force-dark .vocab-hl--note { background: rgba(167,139,250,.22); color: #c4b5fd; }
2562    [data-theme="dark"] .reader-footer,
2563    body.vb-force-dark .reader-footer { border-top-color: rgba(167,139,250,.25); }
2564    [data-theme="dark"] .reader-progress-bar,
2565    body.vb-force-dark .reader-progress-bar { background: rgba(167,139,250,.12); }
2566    [data-theme="dark"] .reader-progress-text,
2567    body.vb-force-dark .reader-progress-text { color: rgba(226,232,240,.68); }
2568    [data-theme="dark"] .reader-bm-panel,
2569    body.vb-force-dark .reader-bm-panel { background: #161830; border-left-color: rgba(167,139,250,.25); }
2570    [data-theme="dark"] .reader-bm-item,
2571    body.vb-force-dark .reader-bm-item { border-bottom-color: rgba(167,139,250,.15); }
2572    [data-theme="dark"] .reader-bm-empty,
2573    body.vb-force-dark .reader-bm-empty { color: rgba(226,232,240,.5); }
2574    [data-theme="dark"] .book-title-section,
2575    body.vb-force-dark .book-title-section {
2576      background: linear-gradient(180deg, rgba(79,70,229,.15), transparent 60%);
2577      border-bottom-color: rgba(167,139,250,.20);
2578    }
2579    /* 侧边栏(目录/书签/金句)——补全 --hord-* 令牌无深色桥接导致永远白底 */
2580    [data-theme="dark"] .reader-sidebar,
2581    body.vb-force-dark .reader-sidebar { background: #161830; border-right-color: rgba(167,139,250,.25); }
2582    [data-theme="dark"] .reader-sidebar-tabs,
2583    body.vb-force-dark .reader-sidebar-tabs { border-bottom-color: rgba(167,139,250,.25); }
2584    [data-theme="dark"] .reader-sidebar-tab,
2585    body.vb-force-dark .reader-sidebar-tab { color: rgba(226,232,240,.68); }
2586    [data-theme="dark"] .reader-sidebar-tab:hover,
2587    body.vb-force-dark .reader-sidebar-tab:hover { color: #e2e8f0; background: rgba(167,139,250,.12); }
2588    [data-theme="dark"] .reader-sidebar-tab.active,
2589    body.vb-force-dark .reader-sidebar-tab.active { color: #a78bfa; border-bottom-color: #a78bfa; }
2590    [data-theme="dark"] .reader-sidebar-body,
2591    body.vb-force-dark .reader-sidebar-body { color: #e2e8f0; }
2592    [data-theme="dark"] .reader-sidebar-empty,
2593    body.vb-force-dark .reader-sidebar-empty { color: rgba(226,232,240,.5); }
2594    /* 目录项 */
2595    [data-theme="dark"] .reader-toc-item,
2596    body.vb-force-dark .reader-toc-item { color: rgba(226,232,240,.8); }
2597    [data-theme="dark"] .reader-toc-item:hover,
2598    body.vb-force-dark .reader-toc-item:hover { background: rgba(167,139,250,.12); }
2599    [data-theme="dark"] .reader-toc-item.active,
2600    body.vb-force-dark .reader-toc-item.active { background: rgba(124,58,237,.18); border-left-color: #a78bfa; color: #a78bfa; }
2601    /* 金句项 */
2602    [data-theme="dark"] .reader-quote-item,
2603    body.vb-force-dark .reader-quote-item { border-bottom-color: rgba(167,139,250,.15); }
2604    [data-theme="dark"] .reader-quote-item:hover,
2605    body.vb-force-dark .reader-quote-item:hover { background: rgba(167,139,250,.10); }
2606    [data-theme="dark"] .reader-quote-type,
2607    body.vb-force-dark .reader-quote-type { color: rgba(226,232,240,.55); }
2608    [data-theme="dark"] .reader-quote-text,
2609    body.vb-force-dark .reader-quote-text { color: #e2e8f0; }
2610    [data-theme="dark"] .reader-quote-meta,
2611    body.vb-force-dark .reader-quote-meta { color: rgba(226,232,240,.5); }
2612    [data-theme="dark"] .reader-quote-del,
2613    body.vb-force-dark .reader-quote-del { color: rgba(226,232,240,.5); }
2614    /* 书签项(与 bm-panel 共用) */
2615    [data-theme="dark"] .reader-bm-item:hover,
2616    body.vb-force-dark .reader-bm-item:hover { background: rgba(167,139,250,.12); }
2617    [data-theme="dark"] .reader-bm-text,
2618    body.vb-force-dark .reader-bm-text { color: #e2e8f0; }
2619    [data-theme="dark"] .reader-bm-time,
2620    body.vb-force-dark .reader-bm-time { color: rgba(226,232,240,.5); }
2621    [data-theme="dark"] .epub-nav-btn,
2622    body.vb-force-dark .epub-nav-btn { background: rgba(167,139,250,.12); color: #cbd5e1; }
2623    [data-theme="dark"] .epub-nav-btn:hover,
2624    body.vb-force-dark .epub-nav-btn:hover { background: rgba(167,139,250,.22); }
2625    [data-theme="dark"] .vocab-tooltip,
2626    body.vb-force-dark .vocab-tooltip {
2627      background: #161830; border-color: rgba(167,139,250,.35);
2628      box-shadow: 0 22px 48px rgba(6,2,18,.62);
2629    }
2630    [data-theme="dark"] .vocab-tooltip .vt-word,
2631    body.vb-force-dark .vocab-tooltip .vt-word { color: #eef2ff; }
2632    [data-theme="dark"] .vocab-tooltip .vt-meaning,
2633    body.vb-force-dark .vocab-tooltip .vt-meaning { color: rgba(226,232,240,.68); }
2634    [data-theme="dark"] .vocab-tooltip .vt-btn,
2635    body.vb-force-dark .vocab-tooltip .vt-btn { color: #cbd5e1; border-color: rgba(148,163,184,.3); }
2636    [data-theme="dark"] .vocab-tooltip .vt-btn:hover,
2637    body.vb-force-dark .vocab-tooltip .vt-btn:hover { background: rgba(167,139,250,.15); border-color: rgba(167,139,250,.4); }
2638
2639    /* ── 阅读器主题深色(reader-theme-dark:阅读器自身主题切换时给 $root 加的 scoped 类,
2640        与 Hub 全局 [data-theme="dark"]/body.vb-force-dark 互补) ── */
2641    .reader-theme-dark .reader-sidebar { background: #161830; border-right-color: rgba(167,139,250,.25); }
2642    .reader-theme-dark .reader-sidebar-tabs { border-bottom-color: rgba(167,139,250,.25); }
2643    .reader-theme-dark .reader-sidebar-tab { color: rgba(226,232,240,.68); }
2644    .reader-theme-dark .reader-sidebar-tab:hover { color: #e2e8f0; background: rgba(167,139,250,.12); }
2645    .reader-theme-dark .reader-sidebar-tab.active { color: #a78bfa; border-bottom-color: #a78bfa; }
2646    .reader-theme-dark .reader-sidebar-body { color: #e2e8f0; }
2647    .reader-theme-dark .reader-sidebar-empty { color: rgba(226,232,240,.5); }
2648    .reader-theme-dark .reader-sidebar-action {
2649      background: #161830;
2650      color: #e2e8f0;
2651      border-color: rgba(167,139,250,.25);
2652    }
2653    .reader-theme-dark .reader-toc-item { color: rgba(226,232,240,.8); }
2654    .reader-theme-dark .reader-toc-item:hover { background: rgba(167,139,250,.12); }
2655    .reader-theme-dark .reader-toc-item.active { background: rgba(124,58,237,.18); border-left-color: #a78bfa; color: #a78bfa; }
2656    .reader-theme-dark .reader-quote-item { border-bottom-color: rgba(167,139,250,.15); }
2657    .reader-theme-dark .reader-quote-item:hover { background: rgba(167,139,250,.10); }
2658    .reader-theme-dark .reader-quote-type { color: rgba(226,232,240,.55); }
2659    .reader-theme-dark .reader-quote-text { color: #e2e8f0; }
2660    .reader-theme-dark .reader-quote-meta { color: rgba(226,232,240,.5); }
2661    .reader-theme-dark .reader-quote-del { color: rgba(226,232,240,.5); }
2662    .reader-theme-dark .reader-bm-panel { background: #161830; border-left-color: rgba(167,139,250,.25); }
2663    .reader-theme-dark .reader-bm-item { border-bottom-color: rgba(167,139,250,.15); }
2664    .reader-theme-dark .reader-bm-item:hover { background: rgba(167,139,250,.12); }
2665    .reader-theme-dark .reader-bm-empty { color: rgba(226,232,240,.5); }
2666    .reader-theme-dark .reader-bm-text { color: #e2e8f0; }
2667    .reader-theme-dark .reader-bm-time { color: rgba(226,232,240,.5); }
2668    .reader-theme-dark .reader-search-panel { background: #161830; border-color: rgba(167,139,250,.25); box-shadow: 0 12px 40px rgba(0,0,0,.35); }
2669    .reader-theme-dark .reader-search-panel input { background: rgba(15,23,42,.6); border-color: rgba(167,139,250,.25); color: #e2e8f0; }
2670    .reader-theme-dark .reader-search-panel input:focus { border-color: #a78bfa; }
2671
2672    /* ── 书架暗色 ── */
2673    [data-theme="dark"] .shelf-menu,
2674    body.vb-force-dark .shelf-menu { background: #161830; border-color: rgba(167,139,250,.25); }
2675    [data-theme="dark"] .shelf-menu-item.danger,
2676    body.vb-force-dark .shelf-menu-item.danger { color: #f87171; }
2677    [data-theme="dark"] .shelf-toast,
2678    body.vb-force-dark .shelf-toast { background: #1e1b2e; color: #e2e8f0; }
2679    [data-theme="dark"] .shelf-card,
2680    body.vb-force-dark .shelf-card { background: #161830; border-color: rgba(167,139,250,.18); }
2681    [data-theme="dark"] .shelf-card:hover,
2682    body.vb-force-dark .shelf-card:hover { border-color: rgba(167,139,250,.35); }
2683    /* 书架控制栏与单词本 Toolbar 共享同一暗色 surface,避免原生控件退化为黑灰块。 */
2684    [data-theme="dark"] .shelf-topbar,
2685    body.vb-force-dark .shelf-topbar {
2686      background: rgba(30, 23, 68, 0.96) !important;
2687      border-color: rgba(167, 139, 250, 0.28) !important;
2688      backdrop-filter: blur(12px) !important;
2689      -webkit-backdrop-filter: blur(12px) !important;
2690    }
2691    [data-theme="dark"] .shelf-input,
2692    [data-theme="dark"] .shelf-select,
2693    [data-theme="dark"] .shelf-theme-btn,
2694    [data-theme="dark"] .shelf-view-toggle,
2695    [data-theme="dark"] .shelf-view-toggle .btn,
2696    body.vb-force-dark .shelf-input,
2697    body.vb-force-dark .shelf-select,
2698    body.vb-force-dark .shelf-theme-btn,
2699    body.vb-force-dark .shelf-view-toggle,
2700    body.vb-force-dark .shelf-view-toggle .btn {
2701      background: rgba(10,16,36,.86) !important;
2702      border-color: rgba(125,138,207,.42) !important;
2703      color: #e7ecff !important;
2704    }
2705    [data-theme="dark"] .shelf-import-btn,
2706    body.vb-force-dark .shelf-import-btn {
2707      background: linear-gradient(135deg,#7c3aed,#8b5cf6);
2708      border: 1px solid #9f7aea;
2709      color:#fff;
2710    }
2711    [data-theme="dark"] .shelf-view-toggle .btn.active,
2712    body.vb-force-dark .shelf-view-toggle .btn.active {
2713      background:#7c3aed;
2714      border-color:#7c3aed;
2715      color:#fff;
2716    }
2717
2718    /* ---- AI 深度句意解析模态框样式 ---- */
2719    .hord-ai-modal-mask {
2720      position: fixed;
2721      top: 0; left: 0; right: 0; bottom: 0;
2722      background: rgba(15, 17, 32, 0.65);
2723      backdrop-filter: blur(12px);
2724      -webkit-backdrop-filter: blur(12px);
2725      z-index: 999999;
2726      display: none;
2727      align-items: center;
2728      justify-content: center;
2729      padding: 24px;
2730      box-sizing: border-box;
2731      animation: rd-fade-in 0.25s ease-out;
2732    }
2733    .hord-ai-modal-card {
2734      background: var(--bg-primary, #ffffff);
2735      color: var(--text-primary, #1e293b);
2736      width: 100%;
2737      max-width: 900px;
2738      max-height: 85vh;
2739      border-radius: 24px;
2740      box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
2741      border: 1px solid rgba(167, 139, 250, 0.2);
2742      display: flex;
2743      flex-direction: column;
2744      overflow: hidden;
2745      animation: rd-slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1);
2746    }
2747    .hord-ai-modal-header {
2748      padding: 18px 24px;
2749      display: flex;
2750      align-items: center;
2751      justify-content: space-between;
2752      border-bottom: 1px solid rgba(167, 139, 250, 0.15);
2753      background: linear-gradient(135deg, rgba(124, 58, 237, 0.04) 0%, rgba(20, 184, 166, 0.02) 100%);
2754    }
2755    .hord-ai-modal-title {
2756      font-size: 1.15rem;
2757      font-weight: 700;
2758      color: var(--vb-brand-2, #7c3aed);
2759      letter-spacing: 0.5px;
2760    }
2761    .hord-ai-modal-close {
2762      background: transparent;
2763      border: none;
2764      font-size: 1.35rem;
2765      color: var(--text-secondary, #64748b);
2766      cursor: pointer;
2767      padding: 4px 8px;
2768      border-radius: 8px;
2769      transition: all 0.2s;
2770    }
2771    .hord-ai-modal-close:hover {
2772      background: rgba(167, 139, 250, 0.1);
2773      color: var(--vb-brand-2, #7c3aed);
2774    }
2775    .hord-ai-modal-body {
2776      flex: 1;
2777      padding: 24px;
2778      overflow-y: auto;
2779      box-sizing: border-box;
2780      scrollbar-width: thin;
2781    }
2782    .hord-ai-modal-loading {
2783      display: flex;
2784      align-items: center;
2785      justify-content: center;
2786      min-height: 250px;
2787      font-size: 1.05rem;
2788      color: var(--text-secondary, #64748b);
2789      font-weight: 500;
2790      gap: 10px;
2791    }
2792    .hord-ai-modal-error {
2793      display: flex;
2794      align-items: center;
2795      justify-content: center;
2796      min-height: 200px;
2797      color: #ef4444;
2798      background: rgba(239, 68, 68, 0.05);
2799      border: 1px dashed rgba(239, 68, 68, 0.2);
2800      border-radius: 16px;
2801      padding: 20px;
2802      text-align: center;
2803      font-size: 0.95rem;
2804    }
2805    .analysis-result-wrap {
2806      display: flex;
2807      flex-direction: column;
2808      gap: 16px;
2809    }
2810    .analysis-card {
2811      background: rgba(167, 139, 250, 0.03);
2812      border: 1px solid rgba(167, 139, 250, 0.08);
2813      border-radius: 16px;
2814      padding: 18px 20px;
2815      box-sizing: border-box;
2816    }
2817    .analysis-section-title {
2818      font-size: 0.9rem;
2819      font-weight: 700;
2820      color: var(--vb-brand-2, #7c3aed);
2821      text-transform: uppercase;
2822      letter-spacing: 0.8px;
2823      margin-bottom: 10px;
2824      border-left: 3px solid var(--vb-brand-2, #7c3aed);
2825      padding-left: 8px;
2826    }
2827    .analysis-translation-text {
2828      font-size: 1.05rem;
2829      line-height: 1.65;
2830      font-weight: 500;
2831    }
2832    .analysis-skeleton-text, .analysis-grammar-text {
2833      font-size: 0.95rem;
2834      line-height: 1.6;
2835    }
2836    
2837    /* Chunks 样式 */
2838    .analysis-chunks-list {
2839      display: grid;
2840      grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
2841      gap: 12px;
2842      margin-top: 6px;
2843    }
2844    .analysis-chunk-card {
2845      background: rgba(20, 184, 166, 0.03);
2846      border: 1px solid rgba(20, 184, 166, 0.08);
2847      border-radius: 12px;
2848      padding: 12px 14px;
2849      display: flex;
2850      flex-direction: column;
2851      gap: 6px;
2852    }
2853    .chunk-badge {
2854      align-self: flex-start;
2855      background: rgba(20, 184, 166, 0.12);
2856      color: #0d9488;
2857      font-size: 0.72rem;
2858      font-weight: 700;
2859      padding: 2px 8px;
2860      border-radius: 6px;
2861    }
2862    .chunk-text {
2863      font-size: 0.88rem;
2864      font-weight: 600;
2865      line-height: 1.4;
2866    }
2867    .chunk-zh {
2868      font-size: 0.8rem;
2869      color: var(--text-secondary, #64748b);
2870    }
2871
2872    /* Phrases 样式 */
2873    .analysis-phrases-list {
2874      display: grid;
2875      grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
2876      gap: 12px;
2877      margin-top: 6px;
2878    }
2879    .analysis-phrase-card {
2880      background: rgba(245, 158, 11, 0.03);
2881      border: 1px solid rgba(245, 158, 11, 0.08);
2882      border-radius: 12px;
2883      padding: 12px 14px;
2884      display: flex;
2885      flex-direction: column;
2886      gap: 5px;
2887    }
2888    .phrase-head {
2889      display: flex;
2890      align-items: center;
2891      justify-content: space-between;
2892      gap: 6px;
2893    }
2894    .phrase-text {
2895      font-size: 0.92rem;
2896      font-weight: 700;
2897      color: #d97706;
2898    }
2899    .phrase-level {
2900      background: #fef3c7;
2901      color: #92400e;
2902      font-size: 0.68rem;
2903      font-weight: 800;
2904      padding: 1px 6px;
2905      border-radius: 4px;
2906    }
2907    .phrase-zh {
2908      font-size: 0.82rem;
2909      font-weight: 500;
2910    }
2911    .phrase-note {
2912      font-size: 0.76rem;
2913      color: var(--text-secondary, #64748b);
2914      border-top: 1px dashed rgba(245, 158, 11, 0.15);
2915      padding-top: 4px;
2916      margin-top: 2px;
2917    }
2918
2919    /* 暗黑模式适配 */
2920    [data-theme="dark"] .hord-ai-modal-card,
2921    body.vb-force-dark .hord-ai-modal-card {
2922      background: #111327;
2923      color: #e2e8f0;
2924      border-color: rgba(167, 139, 250, 0.15);
2925    }
2926    [data-theme="dark"] .analysis-card,
2927    body.vb-force-dark .analysis-card {
2928      background: rgba(167, 139, 250, 0.05);
2929    }
2930    [data-theme="dark"] .analysis-chunk-card,
2931    body.vb-force-dark .analysis-chunk-card {
2932      background: rgba(20, 184, 166, 0.05);
2933    }
2934    [data-theme="dark"] .analysis-phrase-card,
2935    body.vb-force-dark .analysis-phrase-card {
2936      background: rgba(245, 158, 11, 0.05);
2937    }
2938    [data-theme="dark"] .chunk-badge,
2939    body.vb-force-dark .chunk-badge {
2940      background: rgba(20, 184, 166, 0.18);
2941      color: #14b8a6;
2942    }
2943    [data-theme="dark"] .phrase-level,
2944    body.vb-force-dark .phrase-level {
2945      background: rgba(245, 158, 11, 0.25);
2946      color: #fbbf24;
2947    }
2948
2949    @keyframes rd-fade-in {
2950      from { opacity: 0; }
2951      to { opacity: 1; }
2952    }
2953    @keyframes rd-slide-up {
2954      from { transform: translateY(30px); opacity: 0; }
2955      to { transform: translateY(0); opacity: 1; }
2956    }
2957  `;
2958  document.head.appendChild(style);
2959}
2960
2961// ================================================================
2962// 书架 UI 帮助函数 (Manager-style)
2963// ================================================================
2964
2965const FALLBACK_PALETTES = [
2966  { accent: '#7c3aed', bg1: '#ddd1ff', bg2: '#c7b7ff', text: '#4c1d95' },  // hord-violet
2967  { accent: '#3b82f6', bg1: '#dbeafe', bg2: '#bfdbfe', text: '#1e3a5f' },  // quiet-blue
2968  { accent: '#22c55e', bg1: '#dcfce7', bg2: '#bbf7d0', text: '#166534' },  // sage-green
2969  { accent: '#f59e0b', bg1: '#fef3c7', bg2: '#fde68a', text: '#92400e' },  // warm-amber
2970  { accent: '#f43f5e', bg1: '#ffe4e6', bg2: '#fecdd3', text: '#9f1239' },  // rose-paper
2971  { accent: '#64748b', bg1: '#e2e8f0', bg2: '#cbd5e1', text: '#334155' },  // slate-classic
2972];
2973
2974function fnv1a(str) {
2975  let h = 0x811c9dc5;
2976  for (let i = 0; i < str.length; i++) {
2977    h ^= str.charCodeAt(i);
2978    h = Math.imul(h, 0x01000193);
2979  }
2980  return h >>> 0;
2981}
2982
2983
2984
2985
2986
2987function getFilteredBooks() {
2988  let filtered = bookshelf.filter(b => !b.isDeleted);
2989
2990  // Search
2991  if (shelfSearch) {
2992    const q = shelfSearch.toLowerCase();
2993    filtered = filtered.filter(b => (b.name || b.title || '').toLowerCase().includes(q));
2994  }
2995
2996  // Type filter
2997  if (shelfTypeFilter !== 'all') {
2998    filtered = filtered.filter(b => b.type === shelfTypeFilter);
2999  }
3000
3001  // Status filter
3002  if (shelfStatusFilter !== 'all') {
3003    filtered = filtered.filter(b => HordCore.classifyShelfStatus(b) === shelfStatusFilter);
3004  }
3005
3006  // Sort
3007  filtered.sort((a, b) => {
3008    switch (shelfSort) {
3009      case 'name': return (a.name || '').localeCompare(b.name || '', 'zh-Hans-CN');
3010      case 'size': return (b.size || 0) - (a.size || 0);
3011      case 'progress': return (b.progress || 0) - (a.progress || 0);
3012      case 'time': return (b.totalSeconds || 0) - (a.totalSeconds || 0);
3013      case 'recent':
3014      default: return (b.lastOpened || 0) - (a.lastOpened || 0);
3015    }
3016  });
3017
3018  return filtered;
3019}
3020
3021function renderFallbackCover(book) {
3022  const hash = fnv1a((book.id || '') + (book.name || book.title || ''));
3023  const pal = FALLBACK_PALETTES[hash % FALLBACK_PALETTES.length];
3024  const title = HordCore.formatBookDisplayName(book.name || book.title || '');
3025  const titleLen = title.length;
3026  const titleClass = titleLen <= 16 ? 'title-short' : titleLen <= 36 ? 'title-medium' : titleLen <= 64 ? 'title-long' : 'title-xlong';
3027
3028  return `<div class="fallback-cover" style="--accent:${pal.accent};--fallback-bg1:${pal.bg1};--fallback-bg2:${pal.bg2};--fallback-text:${pal.text};">
3029    <div class="fallback-spine"></div>
3030    <div class="fallback-brand-row"><span class="fallback-brand">HORD READER</span></div>
3031    <div class="fallback-title-wrap"><div class="fallback-title ${titleClass}">${HordCore.escapeHtml(title)}</div></div>
3032    <div class="fallback-footer">
3033      <span class="fallback-sub">English Companion</span>
3034      <span class="fallback-format">${(book.type || 'txt').toUpperCase()}</span>
3035    </div>
3036  </div>`;
3037}
3038
3039function renderContinueBar() {
3040  const reading = bookshelf
3041    .filter(b => !b.isDeleted && (b.progress || 0) > 0.1)
3042    .sort((a, b) => (b.lastOpened || 0) - (a.lastOpened || 0));
3043  if (reading.length === 0) return '<div class="shelf-continue hidden" id="shelfContinue"></div>';
3044
3045  const book = reading[0];
3046  const pct = Math.round(book.progress || 0);
3047  const sizeMB = ((book.size || 0) / 1024 / 1024).toFixed(1) + 'MB';
3048
3049  return `<div class="shelf-continue" id="shelfContinue" data-id="${book.id}">
3050    <div class="continue-cover" id="shelfContinueCover">
3051      ${book.cover ? `<img src="${book.cover}" alt="">` : '📖'}
3052    </div>
3053    <div>
3054      <div class="continue-title" id="shelfContinueTitle">${HordCore.escapeHtml(HordCore.formatBookDisplayName(book.name || book.title || ''))}</div>
3055      <div class="continue-meta" id="shelfContinueMeta">${book.type.toUpperCase()} · ${sizeMB} · 最近阅读 ${HordCore.formatRelativeTime(book.lastOpened)}</div>
3056      <div class="continue-progress-wrap">
3057        <div class="continue-progress"><div id="shelfContinueProgress" style="width:${pct}%"></div></div>
3058        <div class="continue-pct" id="shelfContinuePct">${pct}%</div>
3059      </div>
3060    </div>
3061    <button class="btn primary" id="shelfContinueBtn">继续阅读 ▶</button>
3062  </div>`;
3063}
3064
3065function updateShelfSummary() {
3066  const el = $root.querySelector('#shelfSummary');
3067  if (!el) return;
3068  const filtered = getFilteredBooks();
3069  const started = filtered.filter(b => (b.progress || 0) > 0.1).length;
3070  el.textContent = `${filtered.length}/${bookshelf.filter(b => !b.isDeleted).length} 本书 · 已开始 ${started} 本`;
3071}
3072
3073// ================================================================
3074// 渲染:书架
3075// ================================================================
3076
3077async function renderShelf(opts) {
3078  opts = opts || {};
3079  view = 'shelf';
3080  // 同源同序:addBook 已把新书 unshift 进内存 bookshelf 并 await 落盘;
3081  // 导入场景直接渲染该数组(skipReload),避免 loadBookshelf 重载时读到异步未刷新的 IDB 导致新书缺失/错位。
3082  if (!opts.skipReload) await loadBookshelf();
3083
3084  const filtered = getFilteredBooks();
3085  const allBooks = bookshelf.filter(b => !b.isDeleted);
3086  const totalBooks = allBooks.length;
3087  const avgProgress = totalBooks > 0
3088    ? Math.round(allBooks.reduce((s, b) => s + (b.progress || 0), 0) / totalBooks)
3089    : 0;
3090  const totalReadingSeconds = allBooks.reduce((s, b) => s + (b.totalSeconds || 0), 0);
3091
3092  console.log('[Reader] renderShelf v2.0 — Manager-style shelf',
3093    `books=${totalBooks} filtered=${filtered.length} totalReading=${totalReadingSeconds}s`);
3094
3095  const html = `
3096    <div class="shelf-page">
3097      <!-- Hero Banner -->
3098      <div class="shelf-brand-hero">
3099        <div class="hero-brand-copy">
3100          <div class="hero-brand-title">HORD ENGLISH READER</div>
3101          <div class="hero-slogan">Yesterday You Said Tomorrow</div>
3102          <div class="hero-subline">
3103            学习永远都不晚
3104              <span class="hero-metric-divider">·</span>
3105              <span id="heroMetricBooks">${totalBooks} 本书</span>
3106              <span class="hero-metric-divider">·</span>
3107              <span id="heroMetricProgress">平均进度 ${avgProgress}%</span>
3108              <span class="hero-metric-divider">·</span>
3109              <span id="heroMetricActive">累计阅读 ${HordCore.formatReadingTime(totalReadingSeconds, HordCore.READER_TIME_TEXT)}</span>
3110            </div>
3111          </div>
3112        <div class="hero-svg-art" aria-hidden="true">
3113          <svg viewBox="0 0 220 100" width="220" height="100" xmlns="http://www.w3.org/2000/svg">
3114            <defs><radialGradient id="hg-rd" cx="50%" cy="50%" r="60%"><stop offset="0%" stop-color="#7c3aed" stop-opacity="0.15"/><stop offset="100%" stop-color="#14b8a6" stop-opacity="0"/></radialGradient></defs>
3115            <rect width="220" height="100" fill="url(#hg-rd)" rx="12"/>
3116            <g transform="translate(40,12)">
3117              <g stroke="#7c3aed" stroke-width="1.5" fill="none" stroke-linecap="round">
3118                <rect x="20" y="10" width="40" height="55" rx="4" stroke-opacity="0.25" fill="rgba(124,58,237,0.05)" transform="rotate(-5 40 37)"/>
3119                <rect x="30" y="15" width="40" height="55" rx="4" stroke-opacity="0.3" fill="rgba(124,58,237,0.08)" transform="rotate(-2 50 42)"/>
3120                <rect x="40" y="18" width="40" height="55" rx="4" stroke-opacity="0.4" fill="rgba(124,58,237,0.1)"/>
3121                <line x1="48" y1="30" x2="72" y2="30" stroke-opacity="0.2"/>
3122                <line x1="48" y1="37" x2="70" y2="37" stroke-opacity="0.15"/>
3123                <line x1="48" y1="44" x2="68" y2="44" stroke-opacity="0.12"/>
3124                <line x1="48" y1="51" x2="72" y2="51" stroke-opacity="0.1"/>
3125                <line x1="48" y1="58" x2="65" y2="58" stroke-opacity="0.08"/>
3126              </g>
3127              <g transform="translate(100,20)" stroke="#14b8a6" stroke-width="1.5" fill="none" stroke-linecap="round">
3128                <path d="M5 0 L5 50" stroke-opacity="0.2"/>
3129                <circle cx="5" cy="10" r="3" fill="rgba(20,184,166,0.15)" stroke-opacity="0.3"/>
3130                <circle cx="5" cy="25" r="3" fill="rgba(20,184,166,0.2)" stroke-opacity="0.3"/>
3131                <circle cx="5" cy="40" r="3" fill="rgba(20,184,166,0.1)" stroke-opacity="0.3"/>
3132                <rect x="15" y="5" width="35" height="5" rx="2.5" fill="rgba(20,184,166,0.12)"/>
3133                <rect x="15" y="20" width="28" height="5" rx="2.5" fill="rgba(20,184,166,0.1)"/>
3134                <rect x="15" y="35" width="32" height="5" rx="2.5" fill="rgba(20,184,166,0.08)"/>
3135              </g>
3136            </g>
3137          </svg>
3138        </div>
3139      </div>
3140
3141      <!-- Library Control Bar -->
3142      <div class="shelf-topbar">
3143        <div class="shelf-toolbar-meta">
3144          <div class="shelf-toolbar-label">Library Control</div>
3145          <div class="shelf-toolbar-summary" id="shelfSummary"></div>
3146        </div>
3147        <div class="shelf-filters">
3148          <input class="shelf-input" id="shelfSearchInput" placeholder="搜索书名..." value="${escapeAttr(shelfSearch)}" />
3149          <select class="shelf-select" id="shelfSortSelect">
3150            <option value="recent"${shelfSort === 'recent' ? ' selected' : ''}>最近阅读</option>
3151            <option value="time"${shelfSort === 'time' ? ' selected' : ''}>阅读时长</option>
3152            <option value="progress"${shelfSort === 'progress' ? ' selected' : ''}>完成进度</option>
3153            <option value="name"${shelfSort === 'name' ? ' selected' : ''}>书籍名称</option>
3154            <option value="size"${shelfSort === 'size' ? ' selected' : ''}>文件大小</option>
3155          </select>
3156          <select class="shelf-select" id="shelfTypeFilter">
3157            <option value="all"${shelfTypeFilter === 'all' ? ' selected' : ''}>全部格式</option>
3158            <option value="epub"${shelfTypeFilter === 'epub' ? ' selected' : ''}>EPUB</option>
3159            <option value="txt"${shelfTypeFilter === 'txt' ? ' selected' : ''}>TXT</option>
3160          </select>
3161          <select class="shelf-select" id="shelfStatusFilter">
3162            <option value="all"${shelfStatusFilter === 'all' ? ' selected' : ''}>全部状态</option>
3163            <option value="reading"${shelfStatusFilter === 'reading' ? ' selected' : ''}>阅读中</option>
3164            <option value="unread"${shelfStatusFilter === 'unread' ? ' selected' : ''}>未开始</option>
3165            <option value="done"${shelfStatusFilter === 'done' ? ' selected' : ''}>已完成</option>
3166          </select>
3167        </div>
3168        <div class="shelf-actions">
3169          <button class="shelf-theme-btn" id="btnShelfThemeToggle">🎨 主题</button>
3170          <button class="shelf-import-btn" id="btnImportBook">📥 导入书籍</button>
3171          <div class="shelf-view-toggle">
3172            <button class="btn${shelfViewMode === 'grid' ? ' active' : ''}" id="btnGridView" data-view="grid">▦</button>
3173            <button class="btn${shelfViewMode === 'list' ? ' active' : ''}" id="btnListView" data-view="list">☰</button>
3174          </div>
3175        </div>
3176      </div>
3177
3178      <!-- Currently Reading -->
3179      ${renderContinueBar()}
3180
3181      <!-- Hidden file input (triggered by toolbar/empty buttons) -->
3182      <input type="file" id="rdFileInput" accept=".epub,.txt" multiple style="display:none" />
3183
3184      <!-- Empty / Grid -->
3185      ${filtered.length === 0 && totalBooks === 0
3186        ? `<div class="shelf-empty-state" id="shelfEmptyState">
3187            <div class="empty-icon">📖</div>
3188            <div class="empty-title">书架是空的</div>
3189            <div class="empty-desc">导入 TXT 或 EPUB 格式的英文书籍,开启沉浸式阅读之旅</div>
3190            <button class="shelf-import-btn" id="btnEmptyImport">📥 选择文件导入</button>
3191          </div>`
3192        : `<div class="shelf-grid${shelfViewMode === 'list' ? ' list-view' : ''}${shelfDensity === 'compact' ? ' compact' : ''}" id="shelfGrid">
3193            ${filtered.map(b => renderBookCard(b)).join('')}
3194            <div class="shelf-add" id="shelfAddBook">
3195              <div class="shelf-add-icon">+</div>
3196              <div class="shelf-add-text">添加书籍</div>
3197            </div>
3198          </div>`
3199      }
3200    </div>
3201  `;
3202
3203  $root.innerHTML = html;
3204
3205  // Update summary
3206  updateShelfSummary();
3207
3208  // Bind all events
3209  bindShelfEvents();
3210}
3211
3212function renderBookCard(book) {
3213  const pct = Math.round(book.progress || 0);
3214  const displayName = HordCore.formatBookDisplayName(book.name || book.title || '');
3215  const sizeMB = ((book.size || 0) / 1024 / 1024).toFixed(1) + 'MB';
3216  const timeLabel = HordCore.formatReadingTime(book.totalSeconds, HordCore.READER_TIME_TEXT);
3217
3218  return `
3219    <div class="shelf-book" data-id="${book.id}" tabindex="0">
3220      <div class="shelf-book-cover">
3221        ${book.cover
3222          ? `<img src="${book.cover}" alt="${escapeAttr(displayName)}" onerror="this.parentElement.innerHTML='<div class=\\'fallback-cover\\'>${HordCore.escapeHtml(displayName)}</div>'" />`
3223          : renderFallbackCover(book)
3224        }
3225      </div>
3226      <div class="shelf-book-actions">
3227        <button class="shelf-open-btn" data-act="open" title="继续阅读">继续阅读</button>
3228        <div class="shelf-menu-wrap">
3229          <button class="shelf-menu-btn" data-act="menu" title="更多操作">⋯</button>
3230          <div class="shelf-menu" data-menu>
3231            <button class="shelf-menu-item" data-act="open">继续阅读</button>
3232            <button class="shelf-menu-item danger" data-act="remove">移除书籍</button>
3233          </div>
3234        </div>
3235      </div>
3236      <div class="shelf-book-info">
3237        <div class="shelf-book-name" title="${escapeAttr(displayName)}">${HordCore.escapeHtml(displayName)}</div>
3238        <div class="shelf-book-meta"><span>${(book.type || 'txt').toUpperCase()}</span><span>${sizeMB}</span></div>
3239        <div class="shelf-book-progress"><div style="width:${pct}%"></div></div>
3240        <div class="shelf-book-foot"><span>${pct}% · 已读 ${timeLabel}</span><span>${HordCore.formatRelativeTime(book.lastOpened)}</span></div>
3241      </div>
3242    </div>
3243  `;
3244}
3245
3246function bindShelfEvents() {
3247  // Upload zone
3248  const zone = $root.querySelector('#rdUploadZone');
3249  const fileInput = $root.querySelector('#rdFileInput');
3250
3251  // fileInput change listener (works regardless of zone existence)
3252  if (fileInput) {
3253    fileInput.addEventListener('change', async (e) => {
3254      for (const file of e.target.files) await handleUpload(file);
3255    });
3256  }
3257
3258  // Add book card
3259  $root.querySelector('#shelfAddBook')?.addEventListener('click', () => fileInput?.click());
3260
3261  // Empty import button
3262  $root.querySelector('#btnEmptyImport')?.addEventListener('click', () => fileInput?.click());
3263
3264  // Import button
3265  $root.querySelector('#btnImportBook')?.addEventListener('click', () => fileInput?.click());
3266
3267  // Continue reading
3268  $root.querySelector('#shelfContinueBtn')?.addEventListener('click', () => {
3269    const cont = $root.querySelector('#shelfContinue');
3270    if (cont?.dataset.id) openBook(cont.dataset.id);
3271  });
3272
3273  // Search
3274  $root.querySelector('#shelfSearchInput')?.addEventListener('input', (e) => {
3275    shelfSearch = e.target.value;
3276    refreshShelfGrid();
3277  });
3278
3279  // Sort
3280  $root.querySelector('#shelfSortSelect')?.addEventListener('change', (e) => {
3281    shelfSort = e.target.value;
3282    refreshShelfGrid();
3283  });
3284
3285  // Type filter
3286  $root.querySelector('#shelfTypeFilter')?.addEventListener('change', (e) => {
3287    shelfTypeFilter = e.target.value;
3288    refreshShelfGrid();
3289  });
3290
3291  // Status filter
3292  $root.querySelector('#shelfStatusFilter')?.addEventListener('change', (e) => {
3293    shelfStatusFilter = e.target.value;
3294    refreshShelfGrid();
3295  });
3296
3297  // View mode toggle
3298  $root.querySelector('#btnGridView')?.addEventListener('click', () => {
3299    shelfViewMode = 'grid';
3300    refreshShelfGrid();
3301  });
3302  $root.querySelector('#btnListView')?.addEventListener('click', () => {
3303    shelfViewMode = 'list';
3304    refreshShelfGrid();
3305  });
3306
3307  // Theme toggle
3308  $root.querySelector('#btnShelfThemeToggle')?.addEventListener('click', () => {
3309    if (window.__hubThemeManager) {
3310      window.__hubThemeManager.toggle();
3311    } else if (globalThis.ThemeManager) {
3312      globalThis.ThemeManager.toggle();
3313    }
3314  });
3315
3316  // Shortcut buttons
3317  $root.querySelectorAll('[data-shortcut]').forEach(btn => {
3318    btn.addEventListener('click', () => {
3319      const target = btn.dataset.shortcut;
3320      // Try to navigate via Hub router
3321      if (typeof globalThis.HordHubRouter?.navigateTo === 'function') {
3322        globalThis.HordHubRouter.navigateTo(target);
3323      } else {
3324        showToast(`请从左侧导航栏切换到「${target === 'words' ? '单词本' : target === 'quotes' ? '金句库' : '复习'}」`);
3325      }
3326    });
3327  });
3328
3329  // Book card events (delegated)
3330  const grid = $root.querySelector('#shelfGrid');
3331  if (grid) {
3332    grid.addEventListener('click', (e) => {
3333      // Menu toggle
3334      const menuBtn = e.target.closest('[data-act="menu"]');
3335      if (menuBtn) {
3336        e.stopPropagation();
3337        const menu = menuBtn.closest('.shelf-menu-wrap')?.querySelector('[data-menu]');
3338        // Close all other menus first
3339        grid.querySelectorAll('.shelf-menu.show').forEach(m => { if (m !== menu) m.classList.remove('show'); });
3340        menu?.classList.toggle('show');
3341        return;
3342      }
3343
3344      // Open action
3345      const openBtn = e.target.closest('[data-act="open"]');
3346      if (openBtn) {
3347        const card = openBtn.closest('.shelf-book');
3348        if (card?.dataset.id) openBook(card.dataset.id);
3349        return;
3350      }
3351
3352      // Remove action
3353      const removeBtn = e.target.closest('[data-act="remove"]');
3354      if (removeBtn) {
3355        const card = removeBtn.closest('.shelf-book');
3356        if (card?.dataset.id && confirm('确定移除此书?')) {
3357          removeBook(card.dataset.id).then(() => renderShelf());
3358        }
3359        return;
3360      }
3361
3362      // Card click (open book)
3363      const card = e.target.closest('.shelf-book');
3364      if (card && !e.target.closest('.shelf-book-actions')) {
3365        if (card.dataset.id) openBook(card.dataset.id);
3366      }
3367    });
3368  }
3369
3370  // Close menus on outside click(命名函数,仅注册一次,避免重复累积 / once 失效)
3371  if (!closeShelfMenuOutside._registered) {
3372    document.addEventListener('click', closeShelfMenuOutside);
3373    closeShelfMenuOutside._registered = true;
3374  }
3375}
3376
3377// 点击书架区域外关闭所有书签/操作菜单(bindShelfEvents 每次重渲都调用,故只注册一次)
3378function closeShelfMenuOutside(e) {
3379  if (!e.target.closest('.shelf-menu-wrap')) {
3380    $root.querySelectorAll('.shelf-menu.show').forEach(m => m.classList.remove('show'));
3381  }
3382}
3383
3384function refreshShelfGrid() {
3385  const grid = $root.querySelector('#shelfGrid');
3386  if (!grid) {
3387    // If we're showing empty state, re-render the full shelf
3388    renderShelf();
3389    return;
3390  }
3391
3392  const filtered = getFilteredBooks();
3393  grid.className = `shelf-grid${shelfViewMode === 'list' ? ' list-view' : ''}${shelfDensity === 'compact' ? ' compact' : ''}`;
3394  grid.innerHTML = filtered.map(b => renderBookCard(b)).join('') + `
3395    <div class="shelf-add" id="shelfAddBook">
3396      <div class="shelf-add-icon">+</div>
3397      <div class="shelf-add-text">添加书籍</div>
3398    </div>
3399  `;
3400
3401  // Update summary
3402  updateShelfSummary();
3403
3404  // Update view toggle buttons
3405  $root.querySelector('#btnGridView')?.classList.toggle('active', shelfViewMode === 'grid');
3406  $root.querySelector('#btnListView')?.classList.toggle('active', shelfViewMode === 'list');
3407
3408  // Re-bind add book card
3409  const fileInput = $root.querySelector('#rdFileInput');
3410  $root.querySelector('#shelfAddBook')?.addEventListener('click', () => fileInput?.click());
3411
3412  // 卡片点击开书已由 #shelfGrid 的委托监听(closest('.shelf-book'))统一处理,
3413  // 此处不再逐卡直绑,避免一次点击触发两次 openBook(S0-10 / B13)。
3414}
3415
3416async function handleUpload(file) {
3417  const ext = file.name.toLowerCase();
3418  if (!ext.endsWith('.epub') && !ext.endsWith('.txt')) {
3419    showToast('仅支持 EPUB 和 TXT 格式', 'error');
3420    return;
3421  }
3422  showToast(`正在导入 ${file.name}...`);
3423  try {
3424    await addBook(file);
3425    showToast(`已导入 ${file.name}`);
3426    // 渲染当前内存书架(addBook å·² unshift 新书并 await 落ç›
3426˜ï¼‰ï¼Œä¸é‡è½½ï¼Œç¡®ä¿æ–°ä¹¦ç«‹å³å‡ºçŽ°åœ¨ä¹¦æž¶é¡¶éƒ¨
3427    renderShelf({ skipReload: true });
3428  } catch (e) {
3429    showToast('导入失败: ' + (e.message || ''), 'error');
3430  }
3431}
3432
3433// ================================================================
3434// 渲染:阅读视图
3435// ================================================================
3436
3437// ================================================================
3438// EPUB 支持
3439// ================================================================
3440
3441
3442function resizeImage(dataUrl, maxWidth, maxHeight) {
3443  return new Promise((resolve) => {
3444    const img = new Image();
3445    img.onload = () => {
3446      let w = img.width, h = img.height;
3447      if (w <= maxWidth && h <= maxHeight) { resolve(dataUrl); return; }
3448      const ratio = Math.min(maxWidth / w, maxHeight / h);
3449      w = Math.round(w * ratio); h = Math.round(h * ratio);
3450      const canvas = document.createElement('canvas');
3451      canvas.width = w; canvas.height = h;
3452      canvas.getContext('2d').drawImage(img, 0, 0, w, h);
3453      resolve(canvas.toDataURL('image/jpeg', 0.8));
3454    };
3455    img.onerror = () => resolve(dataUrl);
3456    img.src = dataUrl;
3457  });
3458}
3459
3460function normalizeZipPath(p) { return String(p || '').replace(/\\/g, '/').replace(/^\/+/, ''); }
3461
3462function resolveZipPath(base, href) {
3463  const raw = normalizeZipPath(String(href || '').split('#')[0]);
3464  if (!raw) return '';
3465  if (!base) return raw;
3466  const parts = normalizeZipPath(base).split('/'); parts.pop();
3467  for (const seg of raw.split('/')) {
3468    if (!seg || seg === '.') continue;
3469    if (seg === '..') parts.pop(); else parts.push(seg);
3470  }
3471  return parts.join('/');
3472}
3473
3474function getZipFile(zip, path) {
3475  const n = normalizeZipPath(path);
3476  if (!zip || !n) return null;
3477  const direct = zip.file(n);
3478  if (direct) return direct;
3479  const lower = n.toLowerCase();
3480  const match = Object.keys(zip.files || {}).find(k => normalizeZipPath(k).toLowerCase() === lower);
3481  return match ? zip.file(match) : null;
3482}
3483
3484HordCore.isImagePath = function(p) { return /\.(jpe?g|png|webp|gif)$/i.test(String(p || '')); }
3485
3486function getXmlElements(doc, name) {
3487  if (!doc) return [];
3488  const d = Array.from(doc.getElementsByTagName(name));
3489  return d.length ? d : Array.from(doc.getElementsByTagName('*')).filter(e => e.localName === name);
3490}
3491
3492/**
3493 * 从 EPUB ZIP 中提取封面图片,返回 data URL(缩略图 320×500)
3494 */
3495async function extractEpubCover(fileBlob) {
3496  try {
3497    if (!globalThis.JSZip || !fileBlob) return null;
3498    const zip = await JSZip.loadAsync(fileBlob);
3499    const parser = new DOMParser();
3500    const containerFile = getZipFile(zip, 'META-INF/container.xml');
3501    if (!containerFile) return null;
3502    const containerXml = await containerFile.async('string');
3503    const containerDoc = parser.parseFromString(containerXml, 'application/xml');
3504    const rootfile = getXmlElements(containerDoc, 'rootfile')[0];
3505    const opfPath = normalizeZipPath(rootfile ? rootfile.getAttribute('full-path') : '');
3506    if (!opfPath) return null;
3507    const opfFile = getZipFile(zip, opfPath);
3508    if (!opfFile) return null;
3509    const opfXml = await opfFile.async('string');
3510    const opfDoc = parser.parseFromString(opfXml, 'application/xml');
3511
3512    const manifestItems = getXmlElements(opfDoc, 'item').map(item => ({
3513      id: item.getAttribute('id') || '',
3514      href: item.getAttribute('href') || '',
3515      path: resolveZipPath(opfPath, item.getAttribute('href') || ''),
3516      mediaType: item.getAttribute('media-type') || '',
3517      properties: item.getAttribute('properties') || '',
3518    })).filter(i => i.path);
3519
3520    async function coverFromItem(item) {
3521      const file = getZipFile(zip, item.path);
3522      if (!file) return null;
3523      const mime = HordCore.getImageMime(item.path, item.mediaType);
3524      if (!mime) return null;
3525      const blob = new Blob([await file.async('blob')], { type: mime });
3526      const dataUrl = await HordCore.blobToDataUrl(blob);
3527      return resizeImage(dataUrl, 320, 500);
3528    }
3529
3530    // Strategy 1: <meta name="cover" content="id"/>
3531    const coverMeta = getXmlElements(opfDoc, 'meta').find(m =>
3532      String(m.getAttribute('name') || '').toLowerCase() === 'cover');
3533    const coverId = coverMeta ? coverMeta.getAttribute('content') : '';
3534    if (coverId) {
3535      const item = manifestItems.find(i => i.id === coverId);
3536      if (item && (HordCore.isImagePath(item.path) || /^image\//i.test(item.mediaType))) {
3537        const c = await coverFromItem(item);
3538        if (c) return c;
3539      }
3540    }
3541
3542    // Strategy 2: EPUB3 properties="cover-image"
3543    const coverProp = manifestItems.find(i =>
3544      /\bcover-image\b/i.test(i.properties) && (HordCore.isImagePath(i.path) || /^image\//i.test(i.mediaType)));
3545    if (coverProp) { const c = await coverFromItem(coverProp); if (c) return c; }
3546
3547    // Strategy 3: filename heuristic
3548    const images = manifestItems.filter(i => HordCore.isImagePath(i.path) || /^image\//i.test(i.mediaType));
3549    const fnCover = images.find(i => /(^|\/)(cover|front)[^/]*\.(jpe?g|png|webp|gif)$/i.test(i.path));
3550    if (fnCover) { const c = await coverFromItem(fnCover); if (c) return c; }
3551
3552    // Strategy 4: score by dimensions
3553    let best = null;
3554    for (const item of images.slice(0, 48)) {
3555      const file = getZipFile(zip, item.path);
3556      if (!file) continue;
3557      const mime = HordCore.getImageMime(item.path, item.mediaType);
3558      if (!mime) continue;
3559      const blob = await file.async('blob');
3560      const dims = await HordCore.getImageDims(new Blob([blob], { type: mime }));
3561      const s = HordCore.scoreCoverCandidate(item, dims);
3562      if (!best || s > best.score) best = { item, score: s };
3563    }
3564    if (best && best.score > -Infinity) return coverFromItem(best.item);
3565  } catch (e) {
3566    console.warn('[Reader] EPUB cover extraction failed:', e);
3567  }
3568  return null;
3569}
3570
3571/**
3572 * 应用 EPUB 主题到 rendition
3573 */
3574function applyEpubTheme(themeName) {
3575  if (!epubLayout?.rendition) return;
3576  const t = THEMES[themeName] || THEMES.light;
3577  const isDark = themeName === 'dark' || themeName === 'oled';
3578  // EPUB 阅读器同样给 $root 加 scoped dark 类,同步侧栏/面板主题
3579  if ($root) $root.classList.toggle('reader-theme-dark', isDark);
3580  epubLayout.rendition.themes.register('hord-theme', {
3581    'body': { 'background': t.bg + ' !important', 'color': t.fg + ' !important', 'padding': '20p
3581x !important', 'font-family': (state.fontFamily || 'inherit') + ' !important' },
3582    '*': { 'color': t.fg + ' !important' },
3583    'p': { 'margin-bottom': (state.paraSpacing || 0.6) + 'em !important', 'line-height': (state.lineHeight || 1.8) + ' !important' },
3584    'a': { 'color': (isDark ? '#c4b5fd' : '#7c3aed') + ' !important' },
3585  });
3586  epubLayout.rendition.themes.select('hord-theme');
3587  epubLayout.rendition.themes.fontSize(state.epubFontSize + 'px');
3588  // 更新工具栏背景色(对齐插件:header 随阅读主题变化)
3589  const toolbar = $root?.querySelector('.reader-toolbar');
3590  if (toolbar) {
3591    toolbar.style.background = isDark ? '#1a1830' : t.bg;
3592    toolbar.style.color = t.fg;
3593    toolbar.style.borderBottomColor = isDark ? 'rgba(167,139,250,.25)' : 'rgba(15,23,42,0.10)';
3594    // <button> 元素不会继承父级 color,需单独设置
3595    toolbar.querySelectorAll('button').forEach(btn => { btn.style.color = t.fg; });
3596  }
3597  // 更新主阅读区背景色
3598  const main = $root?.querySelector('.reader-main');
3599  if (main) { main.style.background = t.bg; main.style.color = t.fg; }
3600  // 同步 body 背景(消除顶部白色条)
3601  document.body.style.background = t.bg;
3602}
3603
3604/**
3605 * EPUB 全文搜索
3606 */
3607async function performEpubSearch(backward) {
3608  const input = $root?.querySelector('#rdSearchInput');
3609  const countEl = $root?.querySelector('#rdSearchCount');
3610  const query = input?.value?.trim();
3611  if (!query || !epubLayout?.rendition) return;
3612
3613  try {
3614    // 先尝试当前视图搜索
3615    const results = await epubLayout.rendition.search(query);
3616    if (results && results.length > 0) {
3617      const idx = backward ? results.length - 1 : 0;
3618      const r = results[idx];
3619      // 保存搜索前位置
3620      if (!searchOrigin) {
3621        const loc = epubLayout.rendition.currentLocation();
3622        searchOrigin = loc?.start?.cfi || null;
3623        // 显示"返回"按钮
3624        const restoreBtn = $root.querySelector('#rdSearchRestore');
3625        if (restoreBtn && searchOrigin) restoreBtn.style.display = '';
3626      }
3627      await epubLayout.rendition.display(r.cfi);
3628      if (countEl) countEl.textContent = `${idx + 1}/${results.length}`;
3629    } else {
3630      if (countEl) countEl.textContent = '无结果';
3631    }
3632  } catch (e) {
3633    // epub.js search 可能不支持,降级为简单文本搜索
3634    console.warn('[Reader] EPUB search error:', e);
3635    if (countEl) countEl.textContent = '搜索失败';
3636  }
3637}
3638
3639/**
3640 * 全屏切换
3641 */
3642function toggleFullscreen() {
3643  if (!document.fullscreenElement) {
3644    document.documentElement.requestFullscreen().then(() => {
3645      isFullscreen = true;
3646    }).catch(() => {});
3647  } else {
3648    document.exitFullscreen().then(() => {
3649      isFullscreen = false;
3650    }).catch(() => {});
3651  }
3652}
3653
3654// 监听全屏变化
3655document.addEventListener('fullscreenchange', () => {
3656  isFullscreen = !!document.fullscreenElement;
3657});
3658
3659/**
3660 * 渲染 EPUB 阅读视图
3661 */
3662async function renderEpubReading() {
3663  view = 'reading';
3664  const book = currentBook;
3665  const t = THEMES[state.theme] || THEMES.light;
3666  const isDark = state.theme === 'dark' || (state.theme === 'system' &&
3667    window.matchMedia('(prefers-color-scheme: dark)').matches);
3668
3669  $root.innerHTML = `
3670    <div class="reader-layout">
3671      ${renderReaderSidebar('toc', '<div class="toc-list" id="rdTocList"></div>')}
3672      <div class="reader-main reader-reading-surface">
3673        ${renderReaderToolbar('epub', book.title || book.name || '')}
3674        <!-- 搜索面板 -->
3675        <div class="reader-search-panel${searchOpen ? ' open' : ''}" id="rdSearchPanel">
3676          <input id="rdSearchInput" placeholder="全书查找 (Ctrl/⌘+F)" autocomplete="off">
3677          <button class="tb-btn" id="rdSearchPrev" title="上一个">↑</button>
3678          <button class="tb-btn" id="rdSearchNext" title="下一个">↓</button>
3679          <span class="tb-label" id="rdSearchCount">未查找</span>
3680          <button class="tb-btn" id="rdSearchClose">✕</button>
3681          <button class="tb-btn rd-search-restore" id="rdSearchRestore" title="返回搜索前位置" style="display:none">⏎ 返回</button>
3682        </div>
3683        <!-- 设置面板(浮动下拉,.open 类切换) -->
3684        <div class="reader-settings-panel${settingsOpen ? ' open' : ''}" id="rdSettingsPanel">${renderSettingsPanel()}</div>
3685        ${rdShortcutHelpPopupHtml()}
3686        <div class="epub-viewer-wrap" style="max-width:${state.contentMaxWidth || 1800}px;margin:0 auto;width:100%;">
3687          <button class="epub-nav-btn epub-nav-prev" id="epubPrev">‹</button>
3688          <div id="epubViewer" class="epub-viewer"></div>
3689          <button class="epub-nav-btn epub-nav-next" id="epubNext">›</button>
3690        </div>
3691      </div>
3692    </div>
3693  `;
3694
3695  // 绑定基础事件
3696  $root.querySelector('#rdBackHome2')?.addEventListener('click', goBack);
3697  $root.querySelector('#rdToggleSidebar').addEventListener('click', () => {
3698    sidebarOpen = !sidebarOpen;
3699    $root.querySelector('#rdSidebar').classList.toggle('open', sidebarOpen);
3700  });
3701
3702  // 章节名称点击弹出目录
3703  $root.querySelector('#rdChapterName')?.parentElement?.addEventListener('click', () => {
3704    sidebarOpen = !sidebarOpen;
3705    $root.querySelector('#rdSidebar').classList.toggle('open', sidebarOpen);
3706    const tab = $root.querySelector('#rdSidebar .reader-sidebar-tab[data-tab="toc"]');
3707    if (tab) tab.click();
3708  });
3709
3710  // 循环主题
3711  $root.querySelector('#epubThemeCycle')?.addEventListener('click', () => {
3712    cycleTheme();
3713  });
3714
3715  // 全屏切换
3716  $root.querySelector('#epubFullscreen')?.addEventListener('click', toggleFullscreen);
3717
3718  // 设置面板切换(对齐扩展 :3918-3922:⚙️ 仅打开语义,关闭仅靠外部点击守卫)
3719  $root.querySelector('#rdSettingsToggle')?.addEventListener('click', (e) => {
3720    e.stopPropagation();
3721    if (settingsOpen) return; // 已开则保持打开(扩展语义);关闭仅靠外部点击守卫
3722    settingsOpen = true;
3723    const panel = $root.querySelector('#rdSettingsPanel');
3724    panel?.classList.add('open');
3725    e.currentTarget.classList.add('active');
3726  });
3727  if (!globalThis.__hordRdSettingsCloseBound) {
3728    globalThis.__hordRdSettingsCloseBound = true;
3729    // R7 T0:由 click 改为 pointerdown + 捕获阶段——穿透正文区 click 链上的 stopPropagation
3730    // (TXT 正文点一下不关面板的根因),且 pointerdown 早于 selection/mouseup,划选起点即关。
3731    document.addEventListener('pointerdown', (e) => {
3732      if (!settingsOpen) return;
3733      const panel = document.querySelector('#rdSettingsPanel');
3734      const toggle = document.querySelector('#rdSettingsToggle');
3735      if (panel && !panel.contains(e.target) && !toggle?.contains(e.target)) {
3736        closeSettingsPanel();
3737      }
3738    }, true);
3739  }
3740
3741  // T3 ⚙️ 悬浮快捷键帮助 popup(对齐扩展 reader-ui.js:3929-3957)
3742  const rdShortcutPopup = $root.querySelector('#shortcutHelpPopup');
3743  const rdSettingsToggleBtn = $root.querySelector('#rdSettingsToggle');
3744  if (rdSettingsToggleBtn && rdShortcutPopup) {
3745    let shortcutHoverTimer = null;
3746    rdSettingsToggleBtn.addEventListener('mouseenter', () => {
3747      clearTimeout(shortcutHoverTimer);
3748      shortcutHoverTimer = setTimeout(() => {
3749        if (!settingsOpen) rdShortcutPopup.classList.add('show'); // 面板未开才显示
3750      }, 600);
3751    });
3752    rdSettingsToggleBtn.addEventListener('mouseleave', () => {
3753      clearTimeout(shortcutHoverTimer);
3754      setTimeout(() => {
3755        if (!rdShortcutPopup.matches(':hover')) rdShortcutPopup.classList.remove('show');
3756      }, 200);
3757    });
3758    rdShortcutPopup.addEventListener('mouseleave', () => {
3759      if (!rdSettingsToggleBtn.matches(':hover')) rdShortcutPopup.classList.remove('show');
3760    });
3761  }
3762
3763  // 绑定设置事件
3764  bindSettingsEvents();
3765
3766  // EPUB 侧边栏 tab 切换
3767  $root.querySelectorAll('#rdSidebar .reader-sidebar-tab').forEach(tab => {
3768    tab.addEventListener('click', () => {
3769      const target = tab.dataset.tab;
3770      $root.querySelectorAll('#rdSidebar .reader-sidebar-tab').forEach(t =>
3771        t.classList.toggle('active', t.dataset.tab === target));
3772      const body = $root.querySelector('#rdSidebarBody');
3773      if (!body) return;
3774
3775      if (target === 'quotes') {
3776        body.innerHTML = renderQuotePaneContent();
3777        bindQuotePaneEvents();
3778      } else if (target === 'bookmarks') {
3779        body.innerHTML = renderBookmarkContent();
3780        bindSidebarEvents();
3781      } else if (target === 'toc') {
3782        body.innerHTML = '<div class="toc-list" id="rdTocList"></div>';
3783        if (epubLayout?.book) {
3784          epubLayout.book.loaded.navigation.then(nav => {
3785            const toc = Array.isArray(nav?.toc) ? nav.toc : [];
3786            const tocList = body.querySelector('#rdTocList');
3787            if (tocList) {
3788              tocList.innerHTML = toc.map(ch =>
3789                `<button class="reader-toc-item" data-href="${escapeAttr(ch.href)}">${HordCore.escapeHtml(stripHtmlTags(ch.label || ''))}</button>`
3790              ).join('');
3791              tocList.addEventListener('click', (e) => {
3792                const btn = e.target.closest('.reader-toc-item');
3793                if (btn && btn.dataset.href) epubLayout.rendition.display(btn.dataset.href);
3794              });
3795            }
3796          }).catch(() => {});
3797        }
3798      }
3799    });
3800  });
3801
3802  // EPUB 书签按钮绑定
3803  $root.querySelector('#rdBmToggle')?.addEventListener('click', () => {
3804    toggleBookmark();
3805    updateBookmarkBtn();
3806    const activeTab = $root.querySelector('#rdSidebar .reader-sidebar-tab.active');
3807    if (activeTab && activeTab.dataset.tab === 'bookmarks') {
3808      const body = $root.querySelector('#rdSidebarBody');
3809      if (body) {
3810        body.innerHTML = renderBookmarkContent();
3811        bindSidebarEvents();
3812      }
3813    }
3814  });
3815
3816  // EPUB 字号控制
3817  $root.querySelector('#epubFontDec')?.addEventListener('click', () => {
3818    state.epubFontSize = Math.max(12, state.epubFontSize - 1);
3819    if (epubLayout?.rendition) epubLayout.rendition.themes.fontSize(state.epubFontSize + 'px');
3820    const lbl = $root.querySelector('#epubFontLabel');
3821    if (lbl) lbl.textContent = state.epubFontSize + 'px';
3822    saveState();
3823  });
3824  $root.querySelector('#epubFontInc')?.addEventListener('click', () => {
3825    state.epubFontSize = Math.min(32, state.epubFontSize + 1);
3826    if (epubLayout?.rendition) epubLayout.rendition.themes.fontSize(state.epubFontSize + 'px');
3827    const lbl = $root.querySelector('#epubFontLabel');
3828    if (lbl) lbl.textContent = state.epubFontSize + 'px';
3829    saveState();
3830  });
3831
3832  // EPUB 主题切换
3833  $root.querySelectorAll('[data-epub-theme]').forEach(btn => {
3834    btn.addEventListener('click', () => {
3835      const theme = btn.dataset.epubTheme;
3836      state.theme = theme;
3837      applyEpubTheme(theme);
3838      // 更新按钮激活状态
3839      $root.querySelectorAll('[data-epub-theme]').forEach(b =>
3840        b.classList.toggle('active', b.dataset.epubTheme === theme));
3841      saveState();
3842    });
3843  });
3844
3845  // 搜索面板
3846  $root.querySelector('#rdSearchToggle')?.addEventListener('click', () => {
3847    searchOpen = !searchOpen;
3848    const panel = $root.querySelector('#rdSearchPanel');
3849    if (panel) panel.classList.toggle('open', searchOpen);
3850    if (searchOpen) $root.querySelector('#rdSearchInput')?.focus();
3851  });
3852  $root.querySelector('#rdSearchClose')?.addEventListener('click', () => {
3853    searchOpen = false;
3854    const panel = $root.querySelector('#rdSearchPanel');
3855    if (panel) panel.classList.remove('open');
3856  });
3857  $root.querySelector('#rdSearchInput')?.addEventListener('keydown', (e) => {
3858    if (e.key === 'Enter') { e.preventDefault(); performEpubSearch(!e.shiftKey); }
3859    if (e.key === 'Escape') { searchOpen = false; $root.querySelector('#rdSearchPanel')?.classList.remove('open'); }
3860  });
3861  $root.querySelector('#rdSearchNext')?.addEventListener('click', () => performEpubSearch(false));
3862  $root.querySelector('#rdSearchPrev')?.addEventListener('click', () => performEpubSearch(true));
3863  $root.querySelector('#rdSearchRestore')?.addEventListener('click', () => {
3864    if (searchOrigin && epubLayout?.rendition) {
3865      epubLayout.rendition.display(searchOrigin);
3866      searchOrigin = null;
3867      $root.querySelector('#rdSearchRestore')?.style?.setProperty('display', 'none');
3868    }
3869    searchOpen = false;
3870    $root.querySelector('#rdSearchPanel')?.classList.remove('open');
3871  });
3872
3873  // 全屏
3874  $root.querySelector('#epubFullscreen')?.addEventListener('click', toggleFullscreen);
3875
3876  // AI 解析
3877  $root.querySelector('#epubAiAnalyze')?.addEventListener('click', startAiParagraphAnalysis);
3878
3879  // 加载 EPUB
3880  try {
3881    const buf = await idbGet(book.id);
3882    if (!buf) { showToast('找不到书籍文件,请重新导入', 'error'); return; }
3883
3884    const ePubFactory = globalThis.ePub || (globalThis.EPUBJS && globalThis.EPUBJS.ePub);
3885    if (typeof ePubFactory !== 'function') {
3886      showToast('EPUB 引擎未加载,请刷新页面重试', 'error');
3887      return;
3888    }
3889
3890    const blob = new Blob([buf], { type: 'application/epub+zip' });
3891    const epubBook = ePubFactory(blob);
3892    const viewerEl = $root.querySelector('#epubViewer');
3893
3894    // 方案 B:强制像素高度,防止 flex 百分比高度链坍缩为 0
3895    $root.style.overflow = 'hidden';
3896    const containerH = $root.clientHeight || window.innerHeight;
3897    if (containerH > 0) {
3898      viewerEl.style.height = containerH + 'px';
3899    }
3900
3901    const vw = window.innerWidth;
3902    const useContinuous = vw <= 900;
3903
3904    const rendition = epubBook.renderTo(viewerEl, {
3905      width: '100%', height: '100%',
3906      spread: vw >= 900 ? 'auto' : 'none',
3907      manager: useContinuous ? 'continuous' : 'default',
3908      flow: useContinuous ? 'scrolled-doc' : 'paginated',
3909    });
3910
3911    // 主题
3912    // T0 修补:仅在持久化值缺失时回退到通用 fontSize,避免每次开书无条件覆盖 S0-14 的持久化字号
3913    state.epubFontSize = state.epubFontSize || state.fontSize || 18;
3914
3915    epubLayout = { book: epubBook, rendition, viewerEl };
3916    applyEpubTheme(state.theme);
3917
3918    // EPUB iframe 弹窗外关闭注册(修:iframe 内 scroll/click 收不到主窗口监听器)
3919    // 用户要求:弹窗外任何鼠标操作都关闭弹窗。EPUB 正文在 iframe 内,须在其
3920    // contentDocument 上也挂关闭监听(registerExternalDoc 已对 scroll/wheel/touchstart 关弹窗)。
3921    function registerEpubIframeClose() {
3922      const RP = globalThis.HordReaderPopup;
3923      const viewer = epubLayout && epubLayout.viewerEl;
3924      if (!viewer) return;
3925      const iframes = viewer.querySelectorAll('iframe');
3926      for (let i = 0; i < iframes.length; i++) {
3927        const doc = iframes[i].contentDocument;
3928        if (!doc) continue;
3929        // 弹窗外关闭(popup 能力):仅 RP 可用时注册,与设置面板桥解耦(R8:不再整体 return)
3930        if (RP && typeof RP.registerExternalDoc === 'function') {
3931          RP.registerExternalDoc(doc);
3932        }
3933        // R7/R8 T0:EPUB iframe 内事件不冒泡到父 document,须在其 contentDocument 上也挂设置面板关闭监听。
3934        // R8 修复竞态:去重标记从 iframe 元素(__hordRdIframeSettingsBound)移到 document 上——
3935        // blob URL 加载会整体替换 iframe 的 document,新 doc 无标记,rendered/observer 触发时自然重绑,
3936        // 消除「元素标记残留挡住重绑」导致桥永久失效的问题。回调语义保持 R7 不变。
3937        if (!doc.__hordRdIframeSettingsBound) {
3938          doc.__hordRdIframeSettingsBound = true;
3939          doc.addEventListener('pointerdown', (ev) => {
3940            if (!settingsOpen) return;
3941            const panel = document.querySelector('#rdSettingsPanel');
3942            const toggle = document.querySelector('#rdSettingsToggle');
3943            if (panel && !panel.contains(ev.target) && !toggle?.contains(ev.target)) closeSettingsPanel();
3944          }, true);
3945        }
3946      }
3947    }
3948    registerEpubIframeClose();
3949    if (epubLayout.viewerEl && typeof MutationObserver !== 'undefined') {
3950      const _epubIframeObs = new MutationObserver(function () { registerEpubIframeClose(); });
3951      _epubIframeObs.observe(epubLayout.viewerEl, { childList: true, subtree: true });
3952      epubLayout._epubIframeObs = _epubIframeObs;
3952 // 持有引用,退出阅读时 disconnect(防 detached 节点泄漏)
3953    }
3954    if (rendition && typeof rendition.on === 'function') {
3955      rendition.on('rendered', registerEpubIframeClose);
3956    }
3957
3958    // epub.js 的 manager 只在 book.ready 且首次 display 完成后可导航。
3959    // 按钮禁用只覆盖鼠标点击,键盘仍需在同一守卫内安全 no-op。
3960    let epubNavigationReady = false;
3961    const epubPrevBtn = $root.querySelector('#epubPrev');
3962    const epubNextBtn = $root.querySelector('#epubNext');
3963    function setEpubNavigationState(ready) {
3964      epubNavigationReady = !!ready;
3965      const busy = !epubNavigationReady;
3966      [epubPrevBtn, epubNextBtn].forEach((btn) => {
3967        if (!btn) return;
3968        btn.disabled = busy;
3969        btn.setAttribute('aria-busy', String(busy));
3970        btn.setAttribute('aria-disabled', String(busy));
3971        btn.title = busy ? 'EPUB 正在加载,请稍候' : (btn === epubPrevBtn ? '上一页' : '下一页');
3972      });
3973    }
3974    function navigateEpub(direction) {
3975      if (!epubNavigationReady) {
3976        showToast('EPUB 正在加载,请稍候', 'info');
3977        return Promise.resolve(null);
3978      }
3979      const method = rendition && rendition[direction];
3980      if (typeof method !== 'function') {
3981        const error = new Error('EPUB 导航未就绪');
3982        console.error('[Reader/EPUB] navigation unavailable:', error);
3983        showToast(error.message, 'error');
3984        return Promise.resolve(null);
3985      }
3986      try {
3987        return Promise.resolve(method.call(rendition)).catch((error) => {
3988          console.error('[Reader/EPUB] navigation failed:', error);
3989          showToast('EPUB 翻页失败: ' + (error?.message || '未知错误'), 'error');
3990          return null;
3991        });
3992      } catch (error) {
3993        console.error('[Reader/EPUB] navigation failed:', error);
3994        showToast('EPUB 翻页失败: ' + (error?.message || '未知错误'), 'error');
3995        return Promise.resolve(null);
3996      }
3997    }
3998    setEpubNavigationState(false);
3999
4000    // 导航
4001    epubPrevBtn?.addEventListener('click', () => navigateEpub('prev'));
4002    epubNextBtn?.addEventListener('click', () => navigateEpub('next'));
4003
4004    // 键盘快捷键(EPUB 完整)
4005    const keyHandler = (e) => {
4006      if (e.target.matches('input, textarea, select') || e.isComposing) return;
4007      const mod = e.ctrlKey || e.metaKey;
4008      if (mod && e.key === 'f') {
4009        e.preventDefault();
4010        searchOpen = true;
4011        $root.querySelector('#rdSearchPanel')?.classList.add('open');
4012        $root.querySelector('#rdSearchInput')?.focus();
4013        return;
4014      }
4015      if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') navigateEpub('prev');
4016      else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') navigateEpub('next');
4017      else if (e.key === 'f' || e.key === 'F') toggleFullscreen();
4018      else if (e.key === 'm' || e.key === 'M' || e.key === 'e' || e.key === 'E') {
4019        sidebarOpen = !sidebarOpen;
4020        $root.querySelector('#rdSidebar')?.classList.toggle('open', sidebarOpen);
4021      }
4022      else if (e.key === 'q' || e.key === 'Q') {
4023        state.epubFontSize = Math.max(12, state.epubFontSize - 1);
4024        rendition.themes.fontSize(state.epubFontSize + 'px');
4025        const lbl = $root.querySelector('#epubFontLabel');
4026        if (lbl) lbl.textContent = state.epubFontSize + 'px';
4027        saveState();
4028      }
4029      else if (e.key === 'w' || e.key === 'W') {
4030        state.epubFontSize = Math.min(32, state.epubFontSize + 1);
4031        rendition.themes.fontSize(state.epubFontSize + 'px');
4032        const lbl = $root.querySelector('#epubFontLabel');
4033        if (lbl) lbl.textContent = state.epubFontSize + 'px';
4034        saveState();
4035      }
4036      else if (e.key === ' ' || e.key === 'Space' || e.key === 'Enter') {
4037        e.preventDefault();
4038        navigateEpub('next');
4039      }
4040      else if (e.key === 'Escape') {
4041        if (searchOpen) { searchOpen = false; $root.querySelector('#rdSearchPanel')?.classList.remove('open'); }
4042        else if (isFullscreen) toggleFullscreen();
4043        else goBack();
4044      }
4045    };
4046    document.addEventListener('keydown', keyHandler);
4047    epubLayout._keyHandler = keyHandler;
4048    
4049    // 启动阅读计时器
4050    startReadingTimer();
4051
4052    // 进度追踪
4053    rendition.on('relocated', (location) => {
4054      const pct = location.start?.percentage;
4055      const cfi = location.start?.cfi;
4056      
4057      // 动态提取当前 EPUB 章节名称并同步更新到顶栏
4058      try {
4059        const spine = epubBook.spine.get(location.start.cfi);
4060        if (spine) {
4061          const navItem = epubBook.navigation.get(spine.href);
4062          const chTitle = navItem ? (navItem.label || '').trim() : '';
4063          const bookTitle = book.title || book.name || '未知书籍';
4064          const titleEl = $root.querySelector('#rdChapterName');
4065          if (titleEl) {
4066            titleEl.textContent = chTitle ? `${bookTitle} · ${chTitle}` : bookTitle;
4067          }
4068        }
4069      } catch (err) {
4070        console.warn('[EPUB] Failed to update chapter name:', err);
4071      }
4072
4073      if (pct != null) {
4074        const pctText = (pct * 100).toFixed(1) + '%';
4075        const infoEl = $root.querySelector('#rdPageInfo');
4076        if (infoEl) infoEl.textContent = pctText;
4077
4078        // 保存到书架
4079        if (book) {
4080          book.progress = Math.round(pct * 100);
4081          book.lastOpened = Date.now();
4082          saveBookshelf();
4083          lsSet(LS_PROG(book.id), { cfi, percentage: pct });
4084        }
4085      }
4086    });
4087
4088    // 词汇高亮:每次翻页渲染后注入 CSS Highlight API 高亮
4089    rendition.on('rendered', () => {
4090      requestAnimationFrame(() => {
4091        try {
4092          const iframes = Array.from(viewerEl.querySelectorAll('iframe'));
4093          for (const iframe of iframes) {
4094            if (!iframe.contentDocument) continue;
4095            // 注入 ::highlight() CSS 规则
4096            let s = iframe.contentDocument.getElementById('hord-highlight-css');
4097            if (!s) {
4098              s = iframe.contentDocument.createElement('style');
4099              s.id = 'hord-highlight-css';
4100              s.textContent = HIGHLIGHT_CSS;
4101              iframe.contentDocument.head.appendChild(s);
4102            }
4103            // 应用词汇高亮
4104            applyVocabHighlights(iframe.contentDocument, iframe.contentDocument.body);
4105            // 应用金句高亮
4106            applyQuoteHighlights(iframe.contentDocument, iframe.contentDocument.body);
4107
4108            // 绑定 iframe 内文本选择查词
4109            bindEpubSelectionBridge(iframe);
4110          }
4111        } catch (e) { /* iframe 跨域时静默跳过 */ }
4112      });
4113    });
4114
4115    await epubBook.ready;
4116
4117    // 生成目录
4118    try {
4119      const nav = await epubBook.loaded.navigation;
4120      const toc = Array.isArray(nav?.toc) ? nav.toc : [];
4121      const tocList = $root.querySelector('#rdTocList');
4122      if (tocList) {
4123        tocList.innerHTML = toc.map(ch =>
4124          `<button class="reader-toc-item" data-href="${escapeAttr(ch.href)}">${HordCore.escapeHtml(stripHtmlTags(ch.label || ''))}</button>`
4125        ).join('');
4126        tocList.addEventListener('click', (e) => {
4127          const btn = e.target.closest('.reader-toc-item');
4128          if (btn && btn.dataset.href) rendition.display(btn.dataset.href);
4129        });
4130      }
4131    } catch (_) {}
4132
4133    // 显示到上次位置
4134    const savedProg = lsGet(LS_PROG(book.id), {});
4135    try {
4136      if (savedProg.cfi) await rendition.display(savedProg.cfi);
4137      else await rendition.display();
4138    } catch (_) {
4139      await rendition.display();
4140    }
4141    setEpubNavigationState(true);
4142
4143    // 生成 location(用于精确百分比)
4144    const spineCount = epubBook?.spine?.items?.length || 0;
4145    if (spineCount <= 500) {
4146      setTimeout(async () => {
4147        try { await epubBook.locations.generate(1500); } catch (_) {}
4148      }, 1000);
4149    }
4150  } catch (e) {
4151    console.error('[Reader] EPUB load error:', e);
4152    showToast('EPUB 加载失败: ' + (e.message || ''), 'error');
4153    // 恢复书架视图,避免卡在空的阅读器界面
4154    _exitReaderModeOnError();
4155  }
4156}
4157
4158async function openBook(id) {
4159  // 先检查书籍是否存在,再进入全屏阅读模式
4160  const book = findBook(id);
4161  if (!book) {
4162    showToast('找不到该书籍,请刷新书架重试', 'error');
4163    return;
4164  }
4165
4166  // Hub SPA 模式:通知宿主进入全屏阅读模式
4167  if (!location.pathname.includes('reader-window')) {
4168    window.dispatchEvent(new CustomEvent('hord:reader-enter', { detail: { bookId: id } }));
4169  }
4170
4171  currentBook = book;
4172  lsSet(LS_ACTIVE_BOOK, id);
4173  sessionStart = Date.now();
4174
4175  // 初始化查词弹窗(词汇状态变化时刷新高亮)
4176  initReaderPopup(async (word, status) => {
4177    if (window.getSelection) {
4178      window.getSelection().removeAllRanges();
4179    }
4180    await loadVocabData();
4181    await loadQuoteData();
4182    if (book.type === 'epub') {
4183      // EPUB: 重新注入高亮
4184      try {
4185        const iframes = Array.from($root.querySelector('#epubViewer')?.querySelectorAll('iframe') || []);
4186        for (const iframe of iframes) {
4187          if (iframe.contentDocument) applyVocabHighlights(iframe.contentDocument, iframe.contentDocument.body);
4188        }
4189      } catch (_) {}
4190    } else {
4191      // TXT: 重新渲染段落
4192      renderParagraphs();
4193    }
4194  }, applyOptimisticReaderWordStatus);
4195
4196  // 加载阅读状态
4197  loadState(id);
4198  const savedProg = lsGet(LS_PROG(id), {});
4199  if (savedProg.paragraphIndex != null) {
4200    state.paragraphIndex = savedProg.paragraphIndex;
4201  }
4202
4203  // EPUB 阅读
4204  if (book.type === 'epub') {
4205    await loadVocabData();
4206    await loadQuoteData();
4207    await renderEpubReading();
4208    return;
4209  }
4210
4211  // 加载 TXT 内容
4212  try {
4213    const buf = await idbGet(id);
4214    if (!buf) {
4215      showToast('找不到书籍文件,请重新导入', 'error');
4216      _exitReaderModeOnError();
4217      return;
4218    }
4219    paragraphs = await readTxtBook(buf);
4220    if (paragraphs.length === 0) {
4221      showToast('文件内容为空', 'error');
4222      _exitReaderModeOnError();
4223      return;
4224    }
4225
4226    // 检测章节
4227    chapters = detectChapters(paragraphs);
4228    sidebarOpen = false;
4229    sidebarTab = 'toc';
4230    settingsOpen = false;
4231
4232    // 确保 paragraphIndex 有效
4233    if (state.paragraphIndex >= paragraphs.length) {
4234      state.paragraphIndex = 0;
4235    }
4236
4237    // 加载词汇数据
4238    await loadVocabData();
4239    await loadQuoteData();
4240
4241    renderReading();
4242  } catch (e) {
4243    showToast('加载书籍失败: ' + (e.message || ''), 'error');
4244    _exitReaderModeOnError();
4245  }
4246}
4247
4248// 暴露给 reader-window.html 引导脚本
4249globalThis.__hordReaderOpenBook = openBook;
4250
4251function renderEpubPlaceholder() {
4252  view = 'reading';
4253  $root.innerHTML = `
4254    <div class="reader-page">
4255      <div class="reader-toolbar">
4256        <button class="tb-btn" id="rdBack">&#8592; 返回书架</button>
4257      </div>
4258      <div class="reader-epub-placeholder">
4259        <div class="icon">&#128218;</div>
4260        <h3>EPUB 支持即将推出</h3>
4261        <p>完整 EPUB 阅读功能正在开发中,<br>目前请先使用 TXT 格式阅读。</p>
4262      </div>
4263    </div>
4264  `;
4265  $root.querySelector('#rdBack').addEventListener('click', goBack);
4266}
4267
4268function renderReading() {
4269  view = 'reading';
4270  const t = THEMES[state.theme] || THEMES.light;
4271
4272  $root.innerHTML = `
4273    <div class="reader-layout">
4274      <!-- TOC 侧边栏 -->
4275      ${renderReaderSidebar(sidebarTab, sidebarTab === 'toc' ? renderTocContent() : sidebarTab === 'quotes' ? renderQuotePaneContent() : renderBookmarkContent())}
4276
4277      <!-- 主阅读区 -->
4278      <div class="reader-main reader-content reader-reading-surface active" style="
4279        --reader-font-size: ${state.fontSize}px;
4280        --reader-line-height: ${state.lineHeight};
4281        --reader-para-spacing: ${state.paraSpacing || 0.6}em;
4282        --rd-content-width: ${state.contentMaxWidth || 1800}px;
4283        --reader-margin: ${margin}px;
4284        --reader-font-family: ${state.fontFamily};
4285        background: ${t.bg}; color: ${t.fg};
4286      ">
4287        ${renderReaderToolbar('txt', currentBook?.title || currentBook?.name || '')}
4288
4289        <!-- 搜索面板 -->
4290        <div class="reader-search-panel${searchOpen ? ' open' : ''}" id="rdSearchPanel">
4291          <input id="rdSearchInput" placeholder="全书查找 (Ctrl/⌘+F)" autocomplete="off">
4292          <button class="tb-btn" id="rdSearchPrev" title="上一个">↑</button>
4293          <button class="tb-btn" id="rdSearchNext" title="下一个">↓</button>
4294          <span class="tb-label" id="rdSearchCount">未查找</span>
4295          <button class="tb-btn" id="rdSearchClose">✕</button>
4296          <button class="tb-btn rd-search-restore" id="rdSearchRestore" title="返回搜索前位置" style="display:none">⏎ 返回</button>
4297        </div>
4298
4299        <!-- 设置面板(浮动下拉,.open 类切换) -->
4300        <div class="reader-settings-panel${settingsOpen ? ' open' : ''}" id="rdSettingsPanel">${renderSettingsPanel()}</div>
4301        ${rdShortcutHelpPopupHtml()}
4302
4303        <div class="txt-viewer-wrap">
4304          <div class="reader-body" id="rdBody" style="max-width:${state.contentMaxWidth || 1800}px;padding-left: ${margin}px; padding-right: ${margin}px; font-family: ${state.fontFamily};"></div>
4305        </div>
4306
4307        <div class="reader-footer">
4308          <div class="reader-progress-bar">
4309            <div class="reader-progress-fill" id="rdProgFill"></div>
4310          </div>
4311          <span class="reader-progress-text" id="rdProgText">0%</span>
4312        </div>
4313      </div>
4314    </div>
4315  `;
4316
4317  // S0-11/B12:上面重设 $root.innerHTML 会重建 #rdBody(全新元素、无监听),
4318  // 但 _selectionBound 仍是上一本书留下的 true,会导致 bindTextSelection 提前返回、
4319  // 第二本书起划词查词失效。此处重置旗标,确保新 #rdBody 被重新绑定。
4320  _selectionBound = false;
4321  renderParagraphs();
4322  updateProgress();
4323  bindReadingEvents();
4324}
4325
4326/** 获取当前章节名 */
4327function getChapterName() {
4328  const bookTitle = currentBook?.title || currentBook?.name || '未知书籍';
4329  if (!chapters.length) return bookTitle;
4330  let current = chapters[0];
4331  for (const ch of chapters) {
4332    if (ch.paragraphIndex <= state.paragraphIndex) current = ch;
4333    else break;
4334  }
4335  const chTitle = (current.title || '').trim();
4336  if (chTitle && chTitle.toLowerCase() !== bookTitle.toLowerCase()) {
4337    return `${bookTitle} · ${chTitle}`;
4338  }
4339  return bookTitle;
4340}
4341
4342/** 渲染 TOC 内容 */
4343function renderTocContent() {
4344  if (!chapters.length) {
4345    return '<div class="reader-sidebar-empty">未检测到章节结构</div>';
4346  }
4347  return chapters.map((ch, i) => {
4348    const isActive = ch.paragraphIndex <= state.paragraphIndex &&
4349      (i === chapters.length - 1 || chapters[i + 1].paragraphIndex > state.paragraphIndex);
4350    return `<div class="reader-toc-item ${isActive ? 'active' : ''}" data-idx="${ch.paragraphIndex}">
4351      <span class="reader-toc-title">${HordCore.escapeHtml(ch.title)}</span>
4352      <span class="reader-toc-page">§${ch.paragraphIndex + 1}</span>
4353    </div>`;
4354  }).join('');
4355}
4356
4357/** 渲染书签内容 */
4358function renderBookmarkContent() {
4359  if (bookmarks.length === 0) {
4360    return '<div class="reader-sidebar-empty">暂无书签</div>';
4361  }
4362  return bookmarks.map(bm => {
4363    const bmId = bm.id || (bm.cfi ? `epub_${bm.cfi}` : `txt_${bm.index ?? 'x'}`);
4364    const locLabel = (typeof bm.index === 'number')
4365      ? `第 ${bm.index + 1} 段`
4366      : (bm.cfi ? 'EPUB 位置' : '位置');
4367    return `
4368    <div class="reader-bm-item" data-id="${bmId}" data-idx="${bm.index ?? ''}" data-cfi="${bm.cfi || ''}">
4369      <div class="reader-bm-text">${HordCore.escapeHtml(bm.text)}...</div>
4370      <div class="reader-bm-time">${locLabel} · ${new Date(bm.time).toLocaleDateString('zh-CN')}</div>
4371      <textarea class="reader-bm-note" data-bm-id="${bmId}" data-bm-idx="${bm.index ?? ''}" data-bm-cfi="${bm.cfi || ''}" placeholder="添加备注..." rows="2">${HordCore.escapeHtml(bm.note || '')}</textarea>
4372      <button class="reader-bm-delete" data-bm-id="${bmId}" data-bm-idx="${bm.index ?? ''}" data-bm-cfi="${bm.cfi || ''}">删除</button>
4373    </div>`;
4374  }).join('');
4375}
4376
4377/** 渲染设置面板 */
4378// T3 ⚙️ 悬浮快捷键帮助 popup 内容(逐字对齐扩展 src/reader.html:2802-2855 的 shortcutHelpPopup)
4379function rdShortcutHelpPopupHtml() {
4380  return `
4381    <div class="shortcut-help-popup" id="shortcutHelpPopup">
4382      <div class="shortcut-help-popup-inner">
4383        <div class="shortcut-help-title">📚 阅读快捷键</div>
4384        <div class="shortcut-category">翻页导航</div>
4385        <table class="shortcut-table">
4386          <tr><td><span class="shortcut-key">空格</span></td><td class="shortcut-desc">下一页</td></tr>
4387          <tr><td><span class="shortcut-key">Enter</span></td><td class="shortcut-desc">下一页</td></tr>
4388          <tr><td><span class="shortcut-key">← →</span></td><td class="shortcut-desc">上一页 / 下一页</td></tr>
4389          <tr><td><span class="shortcut-key">点击空白</span></td><td class="shortcut-desc">左侧上一页,右侧下一页</td></tr>
4390        </table>
4391        <div class="shortcut-category">功能操作</div>
4392        <table class="shortcut-table">
4393          <tr><td><span class="shortcut-key">ESC</span></td><td class="shortcut-desc">退出阅读,返回主页</td></tr>
4394          <tr><td><span class="shortcut-key">F</span></td><td class="shortcut-desc">全屏模式</td></tr>
4395          <tr><td><span class="shortcut-key">M / E</span></td><td class="shortcut-desc">打开/关闭目录</td></tr>
4396          <tr><td><span class="shortcut-key">Q</span></td><td class="shortcut-desc">缩小字体</td></tr>
4397          <tr><td><span class="shortcut-key">W</span></td><td class="shortcut-desc">放大字体</td></tr>
4398        </table>
4399        <div class="shortcut-category">其他</div>
4400        <table class="shortcut-table">
4401          <tr><td><span class="shortcut-key">Ctrl+F</span></td><td class="shortcut-desc">全文搜索</td></tr>
4402        </table>
4403      </div>
4404    </div>`;
4405}
4406
4407function renderSettingsPanel() {
4408  const isEpub = currentBook?.type === 'epub';
4409
4410  const themeSwatches = HordCore.THEMES_ORDER.map(k => {
4411    const t = THEMES[k];
4412    return `<span class="reader-swatch${state.theme === k ? ' active' : ''}" data-set-theme="${k}" style="background:${t.bg};border:2px solid ${state.theme===k?t.fg:'transparent'}" title="${HordCore.THEME_LABEL[k]}"></span>`;
4413  }).join('');
4414
4415  // P0-7/A8:与扩展 src/reader.html 字体下拉逐字一致(含 Georgia 完整 value 串)
4416  const fontOptions = [
4417    { value: 'inherit', label: '默认字体' },
4418    { value: "Georgia, 'Times New Roman', serif", label: '经典衬线 (Georgia)' },
4419    { value: "'Literata', Georgia, serif", label: 'Literata · 阅读衬线' },
4420    { value: "'Lora', Georgia, serif", label: 'Lora · 文学衬线' },
4421    { value: "system-ui, -apple-system, 'Segoe UI', sans-serif", label: '系统无衬线 (Sans)' },
4422    { value: "'Inter', system-ui, sans-serif", label: 'Inter · 现代无衬线' },
4423    { value: "'JetBrains Mono', 'Fira Code', 'Courier New', monospace", label: '等宽字体 (Mono)' },
4424  ].map(opt =>
4425    `<option value="${opt.value}"${state.fontFamily === opt.value ? ' selected' : ''}>${opt.label}</option>`
4426  ).join('');
4427
4428  return `
4429      <div class="reader-settings-section">
4430        <h4>排版</h4>
4431        <div class="reader-settings-row">
4432          <label>排版方式</label>
4433          <select id="rdSpread" class="reader-settings-select" ${!isEpub ? 'disabled title="TXT模式仅支持单页滚动"' : ''}>
4434            <option value="smart" ${state.spread === 'smart' ? 'selected' : ''}>智能 (宽屏自动双页)</option>
4435            <option value="none" ${state.spread === 'none' ? 'selected' : ''}>强制单页</option>
4436            <option value="auto" ${state.spread === 'auto' ? 'selected' : ''}>强制双页</option>
4437          </select>
4438        </div>
4439        <div class="reader-settings-row">
4440          <label>内容宽度</label>
4441          <input type="range" min="760" max="4200" step="20" value="${state.contentMaxWidth}" id="rdWidthSlider">
4442          <span class="reader-settings-val" id="rdWidthVal">${state.contentMaxWidth}px</span>
4443        </div>
4444        ${''/* B1:EPUB 双页激进调节滑条无效(epub.js spread() 忽略第二参数),待 L1 布局引擎上收后恢复真实现;此处不再渲染 */}
4445        <div class="reader-settings-row">
4446          <label>字体</label>
4447          <select id="rdFontFamily" class="reader-settings-select">
4448            ${fontOptions}
4449          </select>
4450        </div>
4451        <div class="reader-settings-row">
4452          <label>行距</label>
4453          <input type="range" min="1.4" max="2.8" step="0.1" value="${state.lineHeight}" id="rdLineSlider">
4454          <span class="reader-settings-val" id="rdLineVal">${state.lineHeight}</span>
4455        </div>
4456        <div class="reader-settings-row">
4457          <label>段落间距</label>
4458          <input type="range" min="0" max="2.0" step="0.1" value="${state.paraSpacing || 0.6}" id="rdParaSpacingSlider">
4459          <span class="reader-settings-val" id="rdParaSpacingVal">${state.paraSpacing || 0.6}em</span>
4460        </div>
4461        <div class="reader-settings-row">
4462          <label>左右边距</label>
4463          <input type="range" min="0" max="100" step="10" value="${margin}" id="rdMarginSlider">
4464          <span class="reader-settings-val" id="rdMarginVal">${margin}px</span>
4465        </div>
4466        <div class="reader-settings-row">
4467          <label>字号大小</label>
4468          <input type="range" min="12" max="32" step="1" value="${state.fontSize}" id="rdFontSlider">
4469          <span class="reader-settings-val" id="rdFontVal">${state.fontSize}px</span>
4470        </div>
4471      </div>
4472      <div class="reader-settings-section" style="border-top:1px solid var(--hord-border, rgba(15,23,42,0.10));margin-top:12px;padding-top:12px;">
4473        <h4>阅读主题</h4>
4474        <div class="reader-swatch-row" style="margin-top:6px;">${themeSwatches}</div>
4475      </div>
4476      <div class="reader-settings-section" style="border-top:1px solid var(--hord-border, rgba(15,23,42,0.10));margin-top:12px;padding-top:12px;">
4477        <h4>快捷键</h4>
4478        <table class="reader-shortcut-table">
4479          <tr><td><kbd>空格 / Enter</kbd></td><td>下一页</td></tr>
4480          <tr><td><kbd>← / →</kbd></td><td>上/下一页</td></tr>
4481          <tr><td><kbd>F</kbd></td><td>全屏</td></tr>
4482          <tr><td><kbd>M / E</kbd></td><td>打开/关闭目录</td></tr>
4483          <tr><td><kbd>Q / W</kbd></td><td>缩放字体</td></tr>
4484          <tr><td><kbd>Ctrl+F</kbd></td><td>全文搜索</td></tr>
4485          <tr><td><kbd>Esc</kbd></td><td>返回书架</td></tr>
4486        </table>
4487      </div>
4488  `;
4489}
4490
4491function renderParagraphs() {
4492  const body = $root.querySelector('#rdBody');
4493  if (!body) return;
4494
4495  // 虚拟渲染:只渲染当前段落附近的段落(前后各 50 段)
4496  const WINDOW = 50;
4497  const start = Math.max(0, state.paragraphIndex - WINDOW);
4498  const end = Math.min(paragraphs.length, state.paragraphIndex + WINDOW + 1);
4499
4500  let html = '';
4501  // 上方占位
4502  if (start > 0) {
4503    html += `<div style="height:${start * estimateParaHeight()}px"></div>`;
4504  }
4505
4506  for (let i = start; i < end; i++) {
4507    const isCurrent = i === state.paragraphIndex;
4508    const isBm = bookmarks.some(b => b.index === i);
4509    html += `<div class="reader-paragraph${isCurrent ? ' current-para' : ''}${isBm ? ' bookmarked-para' : ''}" data-idx="${i}">${highlightText(paragraphs[i])}</div>`;
4510  }
4511
4512  // 下方占位
4513  if (end < paragraphs.length) {
4514    html += `<div style="height:${(paragraphs.length - end) * estimateParaHeight()}px"></div>`;
4515  }
4516
4517  body.innerHTML = html;
4518
4519  // 滚动到当前段落
4520  const currentEl = body.querySelector('.current-para');
4521  if (currentEl) {
4522    currentEl.scrollIntoView({ block: 'center', behavior: 'instant' });
4523  }
4524
4525  // 绑定高亮词 hover(对齐插件 mouseenter + 90ms 防抖)
4526  // 触摸设备回退到 click
4527  const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
4528  let _hoverTimer = null;
4529  const triggerVocabPopup = (e) => {
4530    // Pin 机制:弹窗打开且鼠标在弹窗内时,不触发新弹窗
4531    if (isPopupPinned && isPopupPinned()) return;
4532    const el = e.currentTarget;
4533    if (!el) return;
4534    // B3: 在事件触发瞬间立即捕获 rect(而非 90ms 后才取),避免 TXT 虚拟滚动回收元素后
4535    //      getBoundingClientRect 归零 → 弹窗掉到 (12,12) 角落
4536    const rect = el.getBoundingClientRect();
4537    const word = el.dataset.word;
4538    const meaning = el.dataset.meaning || '';
4539    clearTimeout(_hoverTimer);
4540    _hoverTimer = setTimeout(() => {
4541      // B3 修正:仅校验元素仍在 DOM(虚拟滚动可能在 90ms 内回收元素),
4542      // 不再做零 rect 守卫——inline span 含文字时 rect 不会全零,但对刚 layout / 重排的
4543      // 边界情况零 rect 误杀会导致「点击单词无弹窗」(回归)。即使 rect 退化也至少
4544      // 调用 showWordPopup,让 positionPopup 自行处理(远比静默吞点击友好)。
4545      if (!el.isConnected) return;
4546      // 二次保险:仅当 rect 完全无效(NaN/负值)才
4546跳过
4547      if (!rect || isNaN(rect.top) || isNaN(rect.left)) return;
4548      showWordPopup(word, rect, { meaning, source: currentBook?.title || '' });
4549    }, 90);
4550  };
4551  body.querySelectorAll('.vocab-hl').forEach(el => {
4552    if (isTouchDevice) {
4553      el.addEventListener('click', triggerVocabPopup);
4554    } else {
4555      el.addEventListener('mouseenter', triggerVocabPopup);
4556      el.addEventListener('mouseleave', () => { clearTimeout(_hoverTimer); });
4557      // C1: 桌面端点词查词(对齐扩展端点击任意词查词)
4558      el.addEventListener('click', () => {
4559        // 同步阶段立即捕获 rect,避免虚拟滚动回收后取不到
4560        const rect = el.getBoundingClientRect();
4561        const word = el.dataset.word;
4562        if (word) showWordPopup(word, rect, { meaning: el.dataset.meaning || '', source: currentBook?.title || '' });
4563      });
4564    }
4565  });
4566
4567  // 绑定文本选择查词(mouseup)
4568  bindTextSelection();
4569
4570  // 绑定段落点击:生词交给 .vocab-hl 的 click;非生词尝试点词查词;其余回落段落切换
4571  body.querySelectorAll('.reader-paragraph').forEach(el => {
4572    el.addEventListener('click', (e) => {
4573      if (e.target.closest('.vocab-hl')) return;
4574      // 选中文本时不干扰句子选择流程
4575      const sel = window.getSelection && window.getSelection();
4576      if (sel && sel.toString().trim()) return;
4577      // C1+: 从点击坐标提取英文单词并查词(对齐扩展端"点任意词查词")
4578      // 边缘兜底:若 caret 位置未命中单词(如点空格/标点),在 offset ±8 内搜索最近字母单词
4579      let range;
4580      try {
4581        if (document.caretRangeFromPoint) range = document.caretRangeFromPoint(e.clientX, e.clientY);
4582        else if (document.caretPositionFromPoint) {
4583          const pos = document.caretPositionFromPoint(e.clientX, e.clientY);
4584          if (pos) { range = document.createRange(); range.setStart(pos.offsetNode, pos.offset); range.setEnd(pos.offsetNode, pos.offset); }
4585        }
4586      } catch (_) { range = null; }
4587      if (range && range.startContainer && range.startContainer.nodeType === Node.TEXT_NODE) {
4588        const text = range.startContainer.textContent || '';
4589        const baseOffset = range.startOffset;
4590        let wm = HordCore.extractWordAtPosition(text, baseOffset);
4591        // 边缘兜底:±8 范围扫描
4592        if (!wm) {
4593          for (let delta = 1; delta <= 8 && !wm; delta++) {
4594            wm = HordCore.extractWordAtPosition(text, baseOffset + delta)
4595              || HordCore.extractWordAtPosition(text, baseOffset - delta);
4596          }
4597        }
4598        if (wm) {
4599          let rect = range.getBoundingClientRect();
4600          if (!rect || (rect.width === 0 && rect.height === 0)) {
4601            rect = { left: e.clientX, top: e.clientY, width: wm.word.length * 8, height: 18, right: e.clientX + wm.word.length * 8, bottom: e.clientY + 18 };
4602          }
4603          showWordPopup(wm.word, rect, { meaning: '', source: currentBook?.title || '' });
4604          return;
4605        }
4606      }
4607      // 未命中单词 → 原有段落切换逻辑
4608      const idx = parseInt(el.dataset.idx, 10);
4609      if (!isNaN(idx)) {
4610        state.paragraphIndex = idx;
4611        saveState();
4612        saveProgress();
4613        renderParagraphs();
4614        updateProgress();
4615        updateBookmarkBtn();
4616        updateChapterName();
4617      }
4618    });
4619  });
4620
4621  // 应用金句高亮(CSS Custom Highlight API)
4622  applyQuoteHighlights(document, body);
4623}
4624
4625function estimateParaHeight() {
4626  // 粗略估算每段高度(px)
4627  return state.fontSize * state.lineHeight * 1.5 + 12;
4628}
4629
4630function updateProgress() {
4631  // Stage A 等价接线:进度公式上收核心引擎(Hub 语义,零行为变化)。
4632  const prog = HordCore.TxtEngine.computeTxtProgress(state.paragraphIndex, paragraphs.length).percentage;
4633  const fill = $root.querySelector('#rdProgFill');
4634  const text = $root.querySelector('#rdProgText');
4635  if (fill) fill.style.width = prog + '%';
4636  if (text) text.textContent = prog + '%';
4637}
4638
4639function updateBookmarkBtn() {
4640  const btn = $root.querySelector('#rdBmToggle');
4641  if (btn) {
4642    const active = isBookmarked();
4643    btn.classList.toggle('active', active);
4644    btn.style.color = active ? '#ef4444' : '';
4645  }
4646}
4647
4648function updateChapterName() {
4649  const el = $root.querySelector('#rdChapterName');
4650  if (el) el.textContent = getChapterName();
4651}
4652
4653// ================================================================
4654// 阅读事件绑定
4655// ================================================================
4656
4657function bindReadingEvents() {
4658  // 返回
4659  $root.querySelector('#rdBackHome')?.addEventListener('click', goBack);
4660
4661  // AI 解析
4662  $root.querySelector('#rdAiAnalyze')?.addEventListener('click', startAiParagraphAnalysis);
4663
4664  // TOC 侧边栏切换
4665  $root.querySelector('#rdTocToggle')?.addEventListener('click', () => {
4666    sidebarOpen = !sidebarOpen;
4667    const sidebar = $root.querySelector('#rdSidebar');
4668    const btn = $root.querySelector('#rdTocToggle');
4669    if (sidebar) sidebar.classList.toggle('open', sidebarOpen);
4670    if (btn) btn.classList.toggle('active', sidebarOpen);
4671  });
4672
4673  // 侧边栏 tab 切换
4674  $root.querySelectorAll('.reader-sidebar-tab').forEach(tab => {
4675    tab.addEventListener('click', () => {
4676      sidebarTab = tab.dataset.tab;
4677      $root.querySelectorAll('.reader-sidebar-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === sidebarTab));
4678      const body = $root.querySelector('#rdSidebarBody');
4679      if (body) {
4680        body.innerHTML = sidebarTab === 'toc' ? renderTocContent()
4681          : sidebarTab === 'quotes' ? renderQuotePaneContent()
4682          : renderBookmarkContent();
4683      }
4684      bindSidebarEvents();
4685      if (sidebarTab === 'quotes') bindQuotePaneEvents();
4686    });
4687  });
4688
4689  bindSidebarEvents();
4690
4691  // 字号
4692  $root.querySelector('#rdFontMinus')?.addEventListener('click', () => {
4693    state.fontSize = Math.max(12, state.fontSize - 1);
4694    applyReadingStyle();
4695  });
4696  $root.querySelector('#rdFontPlus')?.addEventListener('click', () => {
4697    state.fontSize = Math.min(32, state.fontSize + 1);
4698    applyReadingStyle();
4699  });
4700
4701  // 主题
4702  $root.querySelectorAll('[data-theme]').forEach(btn => {
4703    btn.addEventListener('click', () => {
4704      state.theme = btn.dataset.theme;
4705      applyReadingStyle();
4706    });
4707  });
4708
4709  // 章节名称点击弹出目录
4710  $root.querySelector('#rdChapterName')?.parentElement?.addEventListener('click', () => {
4711    sidebarOpen = !sidebarOpen;
4712    const sidebar = $root.querySelector('#rdSidebar');
4713    const btn = $root.querySelector('#rdTocToggle');
4714    if (sidebar) sidebar.classList.toggle('open', sidebarOpen);
4715    if (btn) btn.classList.toggle('active', sidebarOpen);
4716    const tab = $root.querySelector('.reader-sidebar-tab[data-tab="toc"]');
4717    if (tab) tab.click();
4718  });
4719
4720  // 主题循环按钮(对齐插件单按钮设计)
4721  $root.querySelector('#rdThemeCycle')?.addEventListener('click', () => {
4722    cycleTheme();
4723  });
4724
4725  // 设置面板切换(对齐扩展 :3918-3922:⚙️ 仅打开语义,关闭仅靠外部点击守卫)
4726  $root.querySelector('#rdSettingsToggle')?.addEventListener('click', (e) => {
4727    e.stopPropagation();
4728    if (settingsOpen) return; // 已开则保持打开(扩展语义);关闭仅靠外部点击守卫
4729    settingsOpen = true;
4730    const panel = $root.querySelector('#rdSettingsPanel');
4731    panel?.classList.add('open');
4732    e.currentTarget.classList.add('active');
4733  });
4734  if (!globalThis.__hordRdSettingsCloseBound) {
4735    globalThis.__hordRdSettingsCloseBound = true;
4736    // R7 T0:由 click 改为 pointerdown + 捕获阶段——穿透正文区 click 链上的 stopPropagation
4737    // (TXT 正文点一下不关面板的根因),且 pointerdown 早于 selection/mouseup,划选起点即关。
4738    document.addEventListener('pointerdown', (e) => {
4739      if (!settingsOpen) return;
4740      const panel = document.querySelector('#rdSettingsPanel');
4741      const toggle = document.querySelector('#rdSettingsToggle');
4742      if (panel && !panel.contains(e.target) && !toggle?.contains(e.target)) {
4743        closeSettingsPanel();
4744      }
4745    }, true);
4746  }
4747
4748  // T3 ⚙️ 悬浮快捷键帮助 popup(对齐扩展 reader-ui.js:3929-3957)
4749  const rdShortcutPopup = $root.querySelector('#shortcutHelpPopup');
4750  const rdSettingsToggleBtn = $root.querySelector('#rdSettingsToggle');
4751  if (rdSettingsToggleBtn && rdShortcutPopup) {
4752    let shortcutHoverTimer = null;
4753    rdSettingsToggleBtn.addEventListener('mouseenter', () => {
4754      clearTimeout(shortcutHoverTimer);
4755      shortcutHoverTimer = setTimeout(() => {
4756        if (!settingsOpen) rdShortcutPopup.classList.add('show'); // 面板未开才显示
4757      }, 600);
4758    });
4759    rdSettingsToggleBtn.addEventListener('mouseleave', () => {
4760      clearTimeout(shortcutHoverTimer);
4761      setTimeout(() => {
4762        if (!rdShortcutPopup.matches(':hover')) rdShortcutPopup.classList.remove('show');
4763      }, 200);
4764    });
4765    rdShortcutPopup.addEventListener('mouseleave', () => {
4766      if (!rdSettingsToggleBtn.matches(':hover')) rdShortcutPopup.classList.remove('show');
4767    });
4768  }
4769
4770  // 设置面板控件
4771  bindSettingsEvents();
4772
4773  // 书签切换
4774  $root.querySelector('#rdBmToggle')?.addEventListener('click', () => {
4775    toggleBookmark();
4776    updateBookmarkBtn();
4777    // 更新侧边栏书签(如果当前在书签 tab)
4778    if (sidebarTab === 'bookmarks') {
4779      const body = $root.querySelector('#rdSidebarBody');
4780      if (body) body.innerHTML = renderBookmarkContent();
4781      bindSidebarEvents();
4782    }
4783  });
4784
4785  // 搜索面板
4786  $root.querySelector('#rdSearchToggle')?.addEventListener('click', () => {
4787    searchOpen = !searchOpen;
4788    const panel = $root.querySelector('#rdSearchPanel');
4789    if (panel) panel.classList.toggle('open', searchOpen);
4790    if (searchOpen) $root.querySelector('#rdSearchInput')?.focus();
4791  });
4792  $root.querySelector('#rdSearchClose')?.addEventListener('click', () => {
4793    searchOpen = false;
4794    const panel = $root.querySelector('#rdSearchPanel');
4795    if (panel) panel.classList.remove('open');
4796  });
4797  $root.querySelector('#rdSearchInput')?.addEventListener('keydown', (e) => {
4798    if (e.key === 'Enter') { e.preventDefault(); performTxtSearch(!e.shiftKey); }
4799    if (e.key === 'Escape') { searchOpen = false; $root.querySelector('#rdSearchPanel')?.classList.remove('open'); }
4800  });
4801  $root.querySelector('#rdSearchNext')?.addEventListener('click', () => performTxtSearch(false));
4802  $root.querySelector('#rdSearchPrev')?.addEventListener('click', () => performTxtSearch(true));
4803  $root.querySelector('#rdSearchRestore')?.addEventListener('click', () => {
4804    if (searchOrigin != null) {
4805      state.paragraphIndex = searchOrigin;
4806      searchOrigin = null;
4807      $root.querySelector('#rdSearchRestore')?.style?.setProperty('display', 'none');
4808      renderParagraphs();
4809      updateProgress();
4810      updateBookmarkBtn();
4811    }
4812    searchOpen = false;
4813    $root.querySelector('#rdSearchPanel')?.classList.remove('open');
4814  });
4815
4816  // 全屏
4817  $root.querySelector('#txtFullscreen')?.addEventListener('click', toggleFullscreen);
4818
4819  // 键盘导航
4820  document.addEventListener('keydown', handleKeyNav);
4821
4822  // 启动阅读计时器
4823  startReadingTimer();
4824
4825  // 滚动加载(虚拟滚动)
4826  const body = $root.querySelector('#rdBody');
4827  if (body) {
4828    body.addEventListener('scroll', () => {
4829      const { scrollTop, scrollHeight, clientHeight } = body;
4830      if (scrollTop < clientHeight * 0.2 || scrollTop > scrollHeight - clientHeight * 1.2) {
4831        renderParagraphs();
4832      }
4833    });
4834  }
4835
4836  // 金句 hover 检测(全局 mousemove, 200ms 防抖)
4837  document.addEventListener('mousemove', handleQuoteHover, { passive: true });
4838}
4839
4840/** 金句 hover 检测(对齐插件:hover → 句子翻译弹窗) */
4841let _quoteHoverTimer = null;
4842let _quoteTooltip = null;
4843let _lastQuoteHoverText = '';
4844
4845function handleQuoteHover(e) {
4846  const el = e.target.closest('.quote-hl');
4847  if (!el) {
4848    clearTimeout(_quoteHoverTimer);
4849    hideQuoteTooltip();
4850    return;
4851  }
4852  // Pin 机制检测
4853  if (isPopupPinned && isPopupPinned()) return;
4854  clearTimeout(_quoteHoverTimer);
4855  _quoteHoverTimer = setTimeout(() => {
4856    const qid = el.dataset.qid;
4857    const quote = quoteData.find(q => (q.id || `q_${q.time}`) === qid);
4858    if (!quote) return;
4859    const qText = getQuoteText(quote);
4860    if (!qText || qText.length < 5) return;
4861    // 700ms 同金句去重
4862    if (qText === _lastQuoteHoverText) return;
4863    _lastQuoteHoverText = qText;
4864    // 关闭已有弹窗,显示翻译弹窗
4865    closeAllReaderPopups();
4866    const rect = el.getBoundingClientRect();
4867    showSentenceTranslation(qText, rect, { trigger: 'hover' });
4868  }, 200);
4869}
4870
4871function showQuoteTooltip(quote, x, y) {
4872  hideQuoteTooltip();
4873  _quoteTooltip = document.createElement('div');
4874  _quoteTooltip.className = 'quote-tooltip';
4875  const text = (quote.quote || quote.text || '').slice(0, 120);
4876  const date = quote.time ? new Date(quote.time).toLocaleDateString('zh-CN') : '';
4877  _quoteTooltip.innerHTML = `<div class="qt-text">💬 ${HordCore.escapeHtml(text)}${text.length >= 120 ? '...' : ''}</div>`
4878    + (quote.translation ? `<div class="qt-text" style="color:#c4b5fd">${HordCore.escapeHtml(quote.translation)}</div>` : '')
4879    + (date ? `<div class="qt-meta">📅 ${date}</div>` : '');
4880  _quoteTooltip.style.left = Math.min(x + 12, window.innerWidth - 332) + 'px';
4881  _quoteTooltip.style.top = (y + 16) + 'px';
4882  document.body.appendChild(_quoteTooltip);
4883}
4884
4885function hideQuoteTooltip() {
4886  if (_quoteTooltip) { _quoteTooltip.remove(); _quoteTooltip = null; }
4887}
4888
4889/** 绑定侧边栏内部事件(TOC 点击跳转、书签点击跳转) */
4890function bindSidebarEvents() {
4891  // TOC 项点击
4892  $root.querySelectorAll('.reader-toc-item').forEach(item => {
4893    item.addEventListener('click', () => {
4894      const idx = parseInt(item.dataset.idx, 10);
4895      if (!isNaN(idx)) {
4896        state.paragraphIndex = idx;
4897        saveState(); saveProgress();
4898        renderReading();
4899      }
4900    });
4901  });
4902
4903  // 书签项点击(点击文本区域跳转,textarea/button 不触发)
4904  $root.querySelectorAll('.reader-bm-item').forEach(item => {
4905    item.addEventListener('click', (e) => {
4906      if (e.target.closest('textarea, button')) return;
4907      const idx = parseInt(item.dataset.idx, 10);
4908      const cfi = item.dataset.cfi;
4909      
4910      if (currentBook?.type === 'epub' && epubLayout?.rendition && cfi) {
4911        epubLayout.rendition.display(cfi);
4912      } else if (!isNaN(idx)) {
4913        state.paragraphIndex = idx;
4914        saveState(); saveProgress();
4915        renderReading();
4916      }
4917    });
4918  });
4919
4920  // 书签备注保存
4921  $root.querySelectorAll('.reader-bm-note').forEach(ta => {
4922    ta.addEventListener('change', () => {
4923      const id = ta.dataset.bmId;
4924      const idx = parseInt(ta.dataset.bmIdx, 10);
4925      const cfi = ta.dataset.bmCfi;
4926      const bm = bookmarks.find(b => matchBookmark(b, id, idx, cfi));
4927      if (bm) { bm.note = ta.value.trim(); persistBookmarks(); }
4928    });
4929  });
4930
4931  // 书签删除
4932  $root.querySelectorAll('.reader-bm-delete').forEach(btn => {
4933    btn.addEventListener('click', (e) => {
4934      e.stopPropagation();
4935      const id = btn.dataset.bmId;
4936      const idx = parseInt(btn.dataset.bmIdx, 10);
4937      const cfi = btn.dataset.bmCfi;
4938      bookmarks = bookmarks.filter(b => !matchBookmark(b, id, idx, cfi));
4939      persistBookmarks();
4940      const body = $root.querySelector('#rdSidebarBody');
4941      if (body) body.innerHTML = renderBookmarkContent();
4942      bindSidebarEvents();
4943    });
4944  });
4945}
4946
4947/** 绑定设置面板控件事件 */
4948function bindSettingsEvents() {
4949  const spreadSelect = $root.querySelector('#rdSpread');
4950  if (spreadSelect) {
4951    spreadSelect.addEventListener('change', (e) => {
4952      state.spread = e.target.value;
4953      if (epubLayout?.rendition) {
4954        try {
4955          const currentLoc = epubLayout.rendition.currentLocation();
4956          const cfi = currentLoc?.start?.cfi;
4957          epubLayout.rendition.spread(state.spread);
4958          if (cfi) epubLayout.rendition.display(cfi);
4959        } catch (_) {}
4960      }
4961      saveState();
4962    });
4963  }
4964
4965  const spreadAggrSlider = $root.querySelector('#rdSpreadAggression');
4966  if (spreadAggrSlider) {
4967    spreadAggrSlider.addEventListener('input', (e) => {
4968      state.spreadAggression = parseInt(e.target.value, 10);
4969      const val = $root.querySelector('#rdSpreadAggressionVal');
4970      if (val) val.textContent = state.spreadAggression + '%';
4971      // 应用到 EPUB rendition(影响双页/单页切换的激进程度)
4972      if (epubLayout?.rendition) {
4973        try {
4974          epubLayout.rendition.spread(state.spread, state.spreadAggression);
4975          if (typeof epubLayout.rendition.resize === 'function') epubLayout.rendition.resize();
4976        } catch (_) {}
4977      }
4978      saveState();
4979    });
4980  }
4981
4982  const fontSlider = $root.querySelector('#rdFontSlider');
4983  if (fontSlider) {
4984    fontSlider.addEventListener('input', (e) => {
4985      state.fontSize = parseInt(e.target.value, 10);
4986      const val = $root.querySelector('#rdFontVal');
4987      if (val) val.textContent = state.fontSize + 'px';
4988      
4989      // 同步更新顶栏字号提示
4990      const topVal = $root.querySelector('#rdFontSize');
4991      if (topVal) topVal.textContent = state.fontSize + 'px';
4992      
4993      // 同步更新 EPUB 端的字号变量和顶栏字号提示
4994      state.epubFontSize = state.fontSize;
4995      const epubTopVal = $root.querySelector('#epubFontLabel');
4996      if (epubTopVal) epubTopVal.textContent = state.epubFontSize + 'px';
4997      if (epubLayout?.rendition) epubLayout.rendition.themes.fontSize(state.epubFontSize + 'px');
4998
4999      applyReadingStyle();
5000      saveState();
5001    });
5002  }
5003
5004  const lineSlider = $root.querySelector('#rdLineSlider');
5005  if (lineSlider) {
5006    lineSlider.addEventListener('input', (e) => {
5007      state.lineHeight = parseFloat(e.target.value);
5008      const val = $root.querySelector('#rdLineVal');
5009      if (val) val.textContent = state.lineHeight;
5010      const content = $root.querySelector('.reader-content');
5011      if (content) content.style.setProperty('--reader-line-height', state.lineHeight);
5012      if (epubLayout?.rendition) applyEpubTheme(state.theme);
5013      saveState();
5014    });
5015  }
5016
5017  const marginSlider = $root.querySelector('#rdMarginSlider');
5018  if (marginSlider) {
5019    marginSlider.addEventListener('input', (e) => {
5020      margin = parseInt(e.target.value, 10);
5021      const val = $root.querySelector('#rdMarginVal');
5022      if (val) val.textContent = margin + 'px';
5023      const body = $root.querySelector('#rdBody');
5024      if (body) { body.style.paddingLeft = margin + 'px'; body.style.paddingRight = margin + 'px'; }
5025      
5026      // EPUB 左右内边距
5027      const epubViewer = $root.querySelector('#epubViewer');
5028      if (epubViewer) {
5029        epubViewer.style.paddingLeft = margin + 'px';
5030        epubViewer.style.paddingRight = margin + 'px';
5031      }
5032      saveState();
5033    });
5034  }
5035
5036  const widthSlider = $root.querySelector('#rdWidthSlider');
5037  if (widthSlider) {
5038    widthSlider.addEventListener('input', (e) => {
5039      state.contentMaxWidth = parseInt(e.target.value, 10);
5040      const val = $root.querySelector('#rdWidthVal');
5041      if (val) val.textContent = state.contentMaxWidth + 'px';
5042      // 设置 CSS 变量(对齐插件:--rd-content-width 统一控制)
5043      const content = $root.querySelector('.reader-content');
5044      if (content) content.style.setProperty('--rd-content-width', state.contentMaxWidth + 'px');
5045      const body = $root.querySelector('#rdBody');
5046      if (body) body.style.maxWidth = state.contentMaxWidth + 'px';
5047      
5048      // EPUB 最大内容宽度(同时更新 viewer 和 wrap 容器)
5049      const epubViewer = $root.querySelector('#epubViewer');
5050      if (epubViewer) epubViewer.style.maxWidth = state.contentMaxWidth + 'px';
5051      const epubWrap = $root.querySelector('.epub-viewer-wrap');
5052      if (epubWrap) epubWrap.style.maxWidth = state.contentMaxWidth + 'px';
5053      // 触发 EPUB 重排(否则内容宽度调整不生效)
5054      if (epubLayout?.rendition && typeof epubLayout.rendition.resize === 'function') {
5055        epubLayout.rendition.resize();
5056      }
5057      saveState();
5058    });
5059  }
5060
5061  const fontSelect = $root.querySelector('#rdFontFamily');
5062  if (fontSelect) {
5063    fontSelect.addEventListener('change', (e) => {
5064      state.fontFamily = e.target.value;
5065      const body = $root.querySelector('#rdBody');
5066      if (body) body.style.fontFamily = state.fontFamily;
5067      // T6 (B2):EPUB 字体族穿透 —— 通知 iframe 内 rendition 重新应用主题(含 font-family 注入)
5068      if (epubLayout?.rendition) applyEpubTheme(state.theme);
5069      saveState();
5070    });
5071  }
5072
5073  // 主题色块点击
5074  $root.querySelectorAll('.reader-swatch[data-set-theme]').forEach(swatch => {
5075    swatch.addEventListener('click', () => {
5076      state.theme = swatch.dataset.setTheme;
5077      saveState();
5078      
5079      if (epubLayout?.rendition) {
5080        applyEpubTheme(state.theme);
5081        const cycleBtn = $root.querySelector('#epubThemeCycle');
5082        if (cycleBtn) {
5083          cycleBtn.innerHTML = `<span class="theme-emoji">${HordCore.THEME_EMOJI[state.theme]}</span><span class="theme-label">主题·${HordCore.THEME_LABEL[state.theme]}</span>`;
5084        }
5085      } else {
5086        applyReadingStyle();
5087        const cycleBtn = $root.querySelector('#rdThemeCycle');
5088        if (cycleBtn) {
5089          cycleBtn.innerHTML = `<span class="theme-emoji">${HordCore.THEME_EMOJI[state.theme]}</span><span class="theme-label">主题·${HordCore.THEME_LABEL[state.theme]}</span>`;
5090        }
5091      }
5092
5093      // 更新 swatches active class
5094      $root.querySelectorAll('.reader-swatch[data-set-theme]').forEach(sw => {
5095        const isCurrent = sw.dataset.setTheme === state.theme;
5096        sw.classList.toggle('active', isCurrent);
5097        sw.style.borderColor = isCurrent ? THEMES[state.theme].fg : 'transparent';
5098      });
5099    });
5100  });
5101  const paraSlider = $root.querySelector('#rdParaSpacingSlider');
5102  if (paraSlider) {
5103    paraSlider.addEventListener('input', (e) => {
5104      state.paraSpacing = parseFloat(e.target.value);
5105      const val = $root.querySelector('#rdParaSpacingVal');
5106      if (val) val.textContent = state.paraSpacing + 'em';
5107      const content = $root.querySelector('.reader-content');
5108      if (content) content.style.setProperty('--reader-para-spacing', state.paraSpacing + 'em');
5109      if (epubLayout?.rendition) applyEpubTheme(state.theme);
5110      saveState();
5111    });
5112  }
5113}
5114
5115function applyReadingStyle() {
5116  const content = $root.querySelector('.reader-content');
5117  if (!content) return;
5118  const t = THEMES[state.theme] || THEMES.light;
5119  const isDark = state.theme === 'dark' || state.theme === 'oled';
5120  // 侧边栏/面板依赖 CSS 选择器命中深色规则,给 $root 加 scoped dark 类
5121  if ($root) $root.classList.toggle('reader-theme-dark', isDark);
5122  content.style.setProperty('--reader-font-size', state.fontSize + 'px');
5123  content.style.setProperty('--reader-line-height', state.lineHeight);
5124  content.style.setProperty('--reader-para-spacing', (state.paraSpacing || 0.6) + 'em');
5125  content.style.setProperty('--rd-content-width', (state.contentMaxWidth || 1800) + 'px');
5126  content.style.background = t.bg;
5127  content.style.color = t.fg;
5128
5129  // 工具栏也同步主题(对齐插件:header 背景随主题变化)
5130  const toolbar = $root.querySelector('.reader-toolbar');
5131  if (toolbar) {
5132    toolbar.style.background = isDark ? '#1a1830' : t.bg;
5133    toolbar.style.color = t.fg;
5134    toolbar.style.borderBottomColor = isDark ? 'rgba(167,139,250,.25)' : 'rgba(15,23,42,0.10)';
5135    // <button> 元素不会继承父级 color,需单独设置
5136    toolbar.querySelectorAll('button').forEach(btn => { btn.style.color = t.fg; });
5137  }
5138  // 阅读器主容器背景
5139  const main = $root.querySelector('.reader-main');
5140  if (main) { main.style.background = t.bg; main.style.color = t.fg; }
5141  // 同步 body 背景(消除顶部白色条)
5142  document.body.style.background = t.bg;
5143
5144  // 更新标签
5145  const fsLabel = $root.querySelector('#rdFontSize');
5146  if (fsLabel) fsLabel.textContent = state.fontSize + 'px';
5147
5148  // 更新章节名
5149  const chapterLabel = $root.querySelector('#rdChapterName');
5150  if (chapterLabel) chapterLabel.textContent = getChapterName();
5151
5152  // 主题按钮状态
5153  $root.querySelectorAll('[data-theme]').forEach(btn => {
5154    btn.classList.toggle('active', btn.dataset.theme === state.theme);
5155  });
5156
5157  saveState();
5158}
5159
5160function cycleTheme() {
5161  const currentIndex = Math.max(0, HordCore.THEMES_ORDER.indexOf(state.theme));
5162  const nextIndex = (currentIndex + 1) % HordCore.THEMES_ORDER.length;
5163  state.theme = HordCore.THEMES_ORDER[nextIndex];
5164  saveState();
5165  
5166  if (view === 'reading') {
5167    if (epubLayout?.rendition) {
5168      applyEpubTheme(state.theme);
5169      const cycleBtn = $root.querySelector('#epubThemeCycle');
5170      if (cycleBtn) {
5171        cycleBtn.innerHTML = `<span class="theme-emoji">${HordCore.THEME_EMOJI[state.theme]}</span><span class="theme-label">主题·${HordCore.THEME_LABEL[state.theme]}</span>`;
5172      }
5173    } else {
5174      applyReadingStyle();
5175      const cycleBtn = $root.querySelector('#rdThemeCycle');
5176      if (cycleBtn) {
5177        cycleBtn.innerHTML = `<span class="theme-emoji">${HordCore.THEME_EMOJI[state.theme]}</span><span class="theme-label">主题·${HordCore.THEME_LABEL[state.theme]}</span>`;
5178      }
5179    }
5180    // 同步刷新设置面板里的 swatches 激活状态
5181    const swatches = $root.querySelectorAll('.reader-swatch[data-set-theme]');
5182    swatches.forEach(sw => {
5183      const isCurrent = sw.dataset.setTheme === state.theme;
5184      sw.classList.toggle('active', isCurrent);
5185      sw.style.borderColor = isCurrent ? THEMES[state.theme].fg : 'transparent';
5186    });
5187  }
5188}
5189
5190/**
5191 * TXT 全文搜索(基于段落文本匹配)
5192 */
5193function performTxtSearch(backward) {
5194  const input = $root?.querySelector('#rdSearchInput');
5195  const countEl = $root?.querySelector('#rdSearchCount');
5196  const query = input?.value?.trim().toLowerCase();
5197  if (!query || !paragraphs.length) return;
5198
5199  // 收集所有匹配位置(Stage A 等价接线:匹配内核上收核心引擎,DOM/滚动逻辑不变)
5200  const matches = HordCore.TxtEngine.matchTxtQuery(paragraphs, query);
5201
5202  if (matches.length === 0) {
5203    if (countEl) countEl.textContent = '无结果';
5204    return;
5205  }
5206
5207  // 找到当前段落之后的下一个匹配
5208  const currentIdx = state.paragraphIndex;
5209  let nextMatch;
5210  if (backward) {
5211    nextMatch = matches.filter(i => i < currentIdx).pop();
5212    if (nextMatch == null) nextMatch = matches[matches.length - 1]; // wrap
5213  } else {
5214    nextMatch = matches.find(i => i > currentIdx);
5215    if (nextMatch == null) nextMatch = matches[0]; // wrap
5216  }
5217
5218  // 保存搜索前位置
5219  if (searchOrigin == null) {
5220    searchOrigin = currentIdx;
5221    const restoreBtn = $root.querySelector('#rdSearchRestore');
5222    if (restoreBtn) restoreBtn.style.display = '';
5223  }
5224
5225  state.paragraphIndex = nextMatch;
5226  saveState(); saveProgress();
5227  renderParagraphs(); updateProgress(); updateBookmarkBtn(); updateChapterName();
5228
5229  // 高亮搜索词(滚动到位置后添加)
5230  requestAnimationFrame(() => {
5231    const body = $root?.querySelector('#rdBody');
5232    if (!body) return;
5233    body.querySelectorAll('.rd-search-hl').forEach(el => {
5234      const parent = el.parentNode;
5235      parent.replaceChild(document.createTextNode(el.textContent), el);
5236      parent.normalize();
5237    });
5238    // 在当前可见段落中高亮搜索词
5239    body.querySelectorAll('.reader-paragraph').forEach(p => {
5240      const text = p.textContent.toLowerCase();
5241      if (!text.includes(query)) return;
5242      const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
5243      const ranges = [];
5244      let node;
5245      while (node = walker.nextNode()) {
5246        const idx = node.textContent.toLowerCase().indexOf(query);
5247        if (idx >= 0) {
5248          const range = document.createRange();
5249          range.setStart(node, idx);
5250          range.setEnd(node, idx + query.length);
5251          ranges.push(range);
5252        }
5253      }
5254      for (const range of ranges) {
5255        const mark = document.createElement('mark');
5256        mark.className = 'rd-search-hl';
5257        mark.style.cssText = 'background:#fbbf24;color:#1e293b;border-radius:2px;padding:0 1px;';
5258        try { range.surroundContents(mark); } catch (_) {}
5259      }
5260    });
5261  });
5262
5263  const matchIdx = matches.indexOf(nextMatch) + 1;
5264  if (countEl) countEl.textContent = `${matchIdx}/${matches.length}`;
5265}
5266
5267function handleKeyNav(e) {
5268  if (view !== 'reading' || !paragraphs.length) return;
5269  if (e.target.matches('input, textarea, select') || e.isComposing) return;
5270
5271  const mod = e.ctrlKey || e.metaKey;
5272  if (mod && e.key === 'f') {
5273    e.preventDefault();
5274    searchOpen = true;
5275    $root.querySelector('#rdSearchPanel')?.classList.add('open');
5276    $root.querySelector('#rdSearchInput')?.focus();
5277    return;
5278  }
5279
5280  if (e.key === 'ArrowDown' || e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') {
5281    e.preventDefault();
5282    state.paragraphIndex = Math.min(paragraphs.length - 1, state.paragraphIndex + 1);
5283    saveState(); saveProgress();
5284    renderParagraphs(); updateProgress(); updateBookmarkBtn(); updateChapterName();
5285  } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
5286    e.preventDefault();
5287    state.paragraphIndex = Math.max(0, state.paragraphIndex - 1);
5288    saveState(); saveProgress();
5289    renderParagraphs(); updateProgress(); updateBookmarkBtn(); updateChapterName();
5290  } else if (e.key === 'f' || e.key === 'F') {
5291    toggleFullscreen();
5292  } else if (e.key === 'm' || e.key === 'M' || e.key === 'e' || e.key === 'E') {
5293    // m / e → 侧边栏 / 目录切换(对齐插件)
5294    sidebarOpen = !sidebarOpen;
5295    $root.querySelector('#rdSidebar')?.classList.toggle('open', sidebarOpen);
5296  } else if (e.key === 'q' || e.key === 'Q') {
5297    // 缩小字体(对齐插件)
5298    e.preventDefault();
5299    state.fontSize = Math.max(12, state.fontSize - 1);
5300    saveState();
5301    applyReadingStyle();
5302  } else if (e.key === 'w' || e.key === 'W') {
5303    // 放大字体(对齐插件)
5304    e.preventDefault();
5305    state.fontSize = Math.min(32, state.fontSize + 1);
5306    saveState();
5307    applyReadingStyle();
5308  } else if (e.key === 'Escape') {
5309    if (searchOpen) { searchOpen = false; $root.querySelector('#rdSearchPanel')?.classList.remove('open'); }
5310    else if (isFullscreen) toggleFullscreen();
5311    else goBack();
5312  }
5313}
5314
5315// ================================================================
5316// 词汇提示
5317// ================================================================
5318
5319let tooltipEl = null;
5320
5321/** TTS 朗读 */
5322function readerSpeak(word) {
5323  if (!word || !globalThis.speechSynthesis) return;
5324  globalThis.speechSynthesis.cancel();
5325  const u = new SpeechSynthesisUtterance(word);
5326  u.lang = 'en-US';
5327  u.rate = 0.85;
5328  globalThis.speechSynthesis.speak(u);
5329}
5330
5331// 注:历史版本此处有一个零调用的加词函数,已于 #314 删除(死代码,且缺 phonetics 字段)。
5332// 真实加词路径 = reader-popup.js setWordStatus(),已正确写入 phonetics:{us,uk}(云同步契约)。
5333
5334function removeTooltip() {
5335  if (tooltipEl) {
5336    tooltipEl.remove();
5337    tooltipEl = null;
5338  }
5339}
5340
5341// ================================================================
5342// 文本选择查词(TXT + EPUB)
5343// ================================================================
5344
5345/** 在文本中指定位置提取单词(用于 EPUB hover 检测) */
5346
5347/** 获取指定坐标的文本节点(用于 EPUB quote hover 检测) */
5348function getTextNodeAtPoint(doc, x, y) {
5349  try {
5350    let range;
5351    if (doc.caretRangeFromPoint) {
5352      range = doc.caretRangeFromPoint(x, y);
5353    } else if (doc.caretPositionFromPoint) {
5354      const pos = doc.caretPositionFromPoint(x, y);
5355      if (pos) {
5356        range = doc.createRange();
5357        range.setStart(pos.offsetNode, pos.offset);
5358        range.setEnd(pos.offsetNode, pos.offset);
5359      }
5360    }
5361    if (range && range.startContainer && range.startContainer.nodeType === Node.TEXT_NODE) {
5362      return range.startContainer;
5363    }
5364  } catch (_) {}
5365  return null;
5366}
5367
5368const LEMMA_IRREGULARS = {
5369  'was': 'be', 'were': 'be', 'been': 'be', 'am': 'be', 'is': 'be', 'are': 'be',
5370  'went': 'go', 'gone': 'go', 'goes': 'go',
5371  'had': 'have', 'has': 'have', 'having': 'have',
5372  'did': 'do', 'does': 'do', 'done': 'do',
5373  'said': 'say', 'says': 'say',
5374  'made': 'make', 'makes': 'make',
5375  'took': 'take', 'taken': 'take', 'takes': 'take',
5376  'came': 'come', 'comes': 'come',
5377  'saw': 'see', 'seen': 'see', 'sees': 'see',
5378  'got': 'get', 'gotten': 'get', 'gets': 'get',
5379  'gave': 'give', 'given': 'give', 'gives': 'give',
5380  'knew': 'know', 'known': 'know', 'knows': 'know',
5381  'thought': 'think', 'thinks': 'think',
5382  'told': 'tell', 'tells': 'tell',
5383  'found': 'find', 'finds': 'find',
5384  'ran': 'run', 'runs': 'run',
5385  'wrote': 'write', 'written': 'write', 'writes': 'write',
5386  'men': 'man', 'women': 'woman', 'children': 'child',
5387  'mice': 'mouse', 'feet': 'foot', 'teeth': 'tooth',
5388  'better': 'good', 'best': 'good', 'worse': 'bad', 'worst': 'bad',
5389};
5390
5391function getLemma(word) {
5392  const w = word.toLowerCase().trim();
5393  if (LEMMA_IRREGULARS[w]) return LEMMA_IRREGULARS[w];
5394  // -ies -> -y
5395  if (w.endsWith('ies') && w.length > 4) return w.slice(0, -3) + 'y';
5396  // -es -> base
5397  if (w.endsWith('es') && w.length > 3) {
5398    const base = w.slice(0, -2);
5399    if (base.endsWith('s') || base.endsWith('x') || base.endsWith('z') || base.endsWith('ch') || base.endsWith('sh')) return base;
5400  }
5401  // -ed -> base
5402  if (w.endsWith('ed') && w.length > 4) {
5403    if (w.endsWith('ied')) return w.slice(0, -3) + 'y';
5404    const base1 = w.slice(0, -2);
5405    if (vocabSet.has(base1)) return base1;
5406    const base2 = w.slice(0, -1);
5407    if (vocabSet.has(base2)) return base2;
5408    const base3 = w.slice(0, -3);
5409    if (base3.length >= 2 && vocabSet.has(base3)) return base3;
5410  }
5411  // -ing -> base
5412  if (w.endsWith('ing') && w.length > 5) {
5413    const base1 = w.slice(0, -3);
5414    if (vocabSet.has(base1)) return base1;
5415    const base2 = w.slice(0, -3) + 'e';
5416    if (vocabSet.has(base2)) return base2;
5417    const base3 = w.slice(0, -4);
5418    if (base3.length >= 2 && vocabSet.has(base3)) return base3;
5419  }
5420  // -s -> base
5421  if (w.endsWith('s') && !w.endsWith('ss') && w.length > 3) return w.slice(0, -1);
5422  return w;
5423}
5424
5425/** TXT 文本选择处理 */
5426function handleTextSelection(e) {
5427  if (!currentBook) return;
5428  if (settingsOpen) closeSettingsPanel(); // R7 T0:划选即关设置面板(直接解决「划选时面板仍展开」)
5429  const sel = window.getSelection();
5430  const text = (sel.toString() || '').trim();
5431  if (!text || text.length < 2 || text.length > 100) return;
5432
5433  // 检查是否纯英文单词(允许 ' 和 -)
5434  if (!/^[a-zA-Z][a-zA-Z'-]*$/.test(text)) return;
5435
5436  const lemma = getLemma(text);
5437  const range = sel.rangeCount > 0 ? sel.getRangeAt(0) : null;
5438  if (!range) return;
5439  const rect = range.getBoundingClientRect();
5440
5441  // 查找该词的释义
5442  const meta = vocabMeta[lemma] || vocabMeta[text.toLowerCase()];
5443  const meaning = meta?.meaning || '';
5444
5445  // 记录弹窗时间/词,用于 dblclick 防抖(避免 mouseup + dblclick 重复弹)
5446  _lastWordPopupTs = Date.now();
5447  _lastWordPopupText = lemma.toLowerCase();
5448
5449  showWordPopup(lemma, rect, {
5450    meaning,
5451    context: getContextSentence(sel),
5452    source: currentBook?.title || '',
5453  });
5454}
5455
5456/** 防抖 state:记录最近一次单词弹窗的时间/词,供 dblclick handler 判重 */
5457let _lastWordPopupTs = 0;
5458let _lastWordPopupText = '';
5459
5460/** 普通阅读器(#rdBody)双击单词 — 兑现 02907b6 commit「对齐插件 dblclick+划选」承诺。
5461 *  EPUB 路径 L5483 已落实 dblclick;本路径 L4951 之前仅挂 mouseup 缺 dblclick,
5461回归修复。
5462 *  不依赖浏览器自动选词(避免严格门禁拦带尾部标点选区),用 caretRangeFromPoint + extractWordAtPosition 兜底。
5463 *  防抖:mouseup 已先弹(_lastWordPopupTs 已被 handleTextSelection 设置)→ 350ms 内同词跳过。 */
5464function handleWordDblClick(e) {
5465  if (!currentBook) return;
5466  // 防抖:mouseup 已先弹同一词 → 跳过(避免 dblclick 在 mouseup 之后 fire 重复弹)
5467  const sel = window.getSelection();
5468  const curText = (sel?.toString() || '').trim();
5469  if (curText && Date.now() - _lastWordPopupTs < 350 && _lastWordPopupText === curText.toLowerCase()) return;
5470
5471  // 用 caretRangeFromPoint 拿词(不依赖浏览器自动选词,对齐 EPUB 路径 L5486-5505)
5472  let range;
5473  try {
5474    if (document.caretRangeFromPoint) range = document.caretRangeFromPoint(e.clientX, e.clientY);
5475    else if (document.caretPositionFromPoint) {
5476      const pos = document.caretPositionFromPoint(e.clientX, e.clientY);
5477      if (pos) {
5478        range = document.createRange();
5479        range.setStart(pos.offsetNode, pos.offset);
5480        range.setEnd(pos.offsetNode, pos.offset);
5481      }
5482    }
5483  } catch (_) { range = null; }
5484  if (!range || !range.startContainer || range.startContainer.nodeType !== Node.TEXT_NODE) return;
5485  const wm = HordCore.extractWordAtPosition(range.startContainer.textContent || '', range.startOffset);
5486  if (!wm) return;
5487
5488  // 估算 wordRect(与 EPUB 路径 L5497-5504 一致公式)
5489  const rect = {
5490    left: e.clientX - 20,
5491    top: e.clientY - 8,
5492    width: wm.word.length * 10,
5493    height: 20,
5494    right: e.clientX + wm.word.length * 10,
5495    bottom: e.clientY + 12
5496  };
5497  const lemma = getLemma(wm.word);
5498  const meta = vocabMeta[lemma] || vocabMeta[wm.word.toLowerCase()];
5499  _lastWordPopupTs = Date.now();
5500  _lastWordPopupText = lemma.toLowerCase();
5501  showWordPopup(lemma, rect, {
5502    meaning: meta?.meaning || '',
5503    context: '',
5504    source: currentBook?.title || '',
5505  });
5506}
5507
5508function getContextSentence(sel) {
5509  try {
5510    const node = sel.anchorNode;
5511    if (!node) return '';
5512    const text = node.textContent || '';
5513    const offset = sel.anchorOffset || 0;
5514    // 找到句子边界
5515    const before = text.slice(0, offset);
5516    const after = text.slice(offset);
5517    const startIdx = Math.max(before.lastIndexOf('.'), before.lastIndexOf('!'), before.lastIndexOf('?'), before.lastIndexOf('\n')) + 1;
5518    let endIdx = after.search(/[.!?\n]/);
5519    if (endIdx < 0) endIdx = after.length;
5520    return (before.slice(startIdx) + after.slice(0, endIdx + 1)).trim();
5521  } catch (_) { return ''; }
5522}
5523
5524/** 绑定文本选择事件(TXT 阅读模式) */
5525let _selectionBound = false;
5526function bindTextSelection() {
5527  if (_selectionBound) return;
5528  const body = $root?.querySelector('#rdBody');
5529  if (!body) return;
5530  body.addEventListener('mouseup', handleTextSelection);
5531  body.addEventListener('mouseup', handleSentenceSelection);
5532  body.addEventListener('dblclick', handleWordDblClick);
5533  _selectionBound = true;
5534}
5535
5536/** 多词/句子选择 → 显示翻译弹窗 */
5537function handleSentenceSelection(e) {
5538  const sel = window.getSelection();
5539  const text = (sel.toString() || '').trim();
5540  // #314-T2: 与扩展对齐——≥2 词即触发句子翻译弹窗(原门槛 ≥3 词导致 2 词选区死区)
5541  if (!text || text.length < 3 || text.length > 500 || !text.includes(' ')) return;
5542  const wordCount = text.split(/\s+/).length;
5543  if (wordCount < 2) return;
5544
5545  const range = sel.rangeCount > 0 ? sel.getRangeAt(0) : null;
5546  if (!range) return;
5547  const rect = range.getBoundingClientRect();
5548
5549  // 直接弹出翻译(对齐插件)
5550  showSentenceTranslation(text, rect);
5551}
5552
5553/** 统一关闭所有阅读器弹窗的入口
5554 *  G7:句子弹窗已并入共享组件(与查词弹窗同一实例 / 同一根节点),
5555 *  closeReaderPopup() 即可关闭两者,原 _sentenceTranslationPopup 独立句柄随旧实现一并删除。 */
5556function closeAllReaderPopups() {
5557  closeReaderPopup();
5558}
5559
5560// #314-T3: 内置 key 碎片与拼装函数已下沉 src-core/hord-ai-client.js(HordCore.assembleBuiltinKey)。
5561// 翻译冷却窗口仍为翻译瀑布流专用,留在本文件。
5562const BUILTIN_COOLDOWN_MS = 60 * 1000;
5563let zhipuBuiltinCooldownUntil = 0;
5564let tokenhubBuiltinCooldownUntil = 0;
5565
5566// G7:原 PROVIDER_LABELS(Hub 独有一套 slot 中文名)已删除 —— slot 标题统一取
5567// HordCore.getTranslateSlotLabel(src-core/hord-provider-catalog.js 单源,与扩展
5568// content.js TRANSLATOR_NAME_MAP 逐字同源),禁止两端各硬编码一套。
5569
5570function getHubTranslateProviders() {
5571  let keys = {};
5572  let order = [];
5573  try {
5574    const raw = localStorage.getItem('hub_api_keys_v1');
5575    if (raw) {
5576      const data = JSON.parse(raw);
5577      keys = data.keys || {};
5578      order = data.translateProviderOrder || [];
5579    }
5580  } catch(e) {}
5581
5582  const defaultOrder = ['relay', 'google', 'mymemory', 'tencent', 'aliyun', 'azure', 'caiyun', 'youdao', 'google_gemini', 'zhipu', 'tokenhub', 'siliconflow'];
5583  
5584  // 补全与排序归一化
5585  order = order.filter(id => defaultOrder.includes(id));
5586  defaultOrder.forEach(id => {
5587    if (!order.includes(id)) order.push(id);
5588  });
5589
5590  const checkConfigured = (pId) => {
5591    // #315-T6: Web 端无服务端代理,这 5 家浏览器直连会被 CORS 阻断,恒不可用(仅扩展端可用)
5592    if (['tencent', 'aliyun', 'azure', 'caiyun', 'youdao'].includes(pId)) return false;
5593    if (pId === 'tencent') return keys.tencentId && keys.tencentKey;
5594    if (pId === 'aliyun') return keys.aliyunId && keys.aliyunKey;
5595    if (pId === 'azure') return keys.azureKey && keys.azureRegion;
5596    if (pId === 'caiyun') return keys.caiyunToken;
5597    if (pId === 'youdao') return keys.youdaoAppKey && keys.youdaoAppSecret;
5598    if (['relay', 'google', 'mymemory'].includes(pId)) return true;
5599    if (['google_gemini', 'zhipu', 'tokenhub', 'siliconflow'].includes(pId)) {
5600      if (pId === 'google_gemini') return !!keys.googleGeminiKey;
5601      if (pId === 'zhipu') return !!keys.zhipuKey || !!HordCore.assembleBuiltinKey('zhipu');
5602      if (pId === 'tokenhub') return !!keys.tokenhubKey || !!HordCore.assembleBuiltinKey('tokenhub');
5603      if (pId === 'siliconflow') return !!keys.siliconflowKey || !!HordCore.assembleBuiltinKey('siliconflow');
5604    }
5605    return false;
5606  };
5607
5608  const merged = order.filter(pId => {
5609    const enabled = (keys[`translate_enabled_${pId}`] ?? keys[`translate_${pId}`]) !== false;
5610    return enabled && checkConfigured(pId);
5611  });
5612
5613  if (merged.length < 2) {
5614    for (const id of ['google', 'mymemory', 'relay']) {
5615      if (merged.length >= 2) break;
5616      if (!merged.includes(id) && (keys[`translate_enabled_${id}`] ?? keys[`translate_${id}`]) !== false) merged.push(id);
5617    }
5618  }
5619
5620  return merged.slice(0, 2);
5621}
5622
5623async function translateProvider(providerId, text, keys = {}) {
5624  const tryWithBuiltin = async (pId) => {
5625    let apiKey = '';
5626    let model = '';
5627    const aiProviderMap = {
5628      zhipu: 'zhipu',
5629      tokenhub: 'tokenhub',
5630      siliconflow: 'siliconflow',
5631      google_gemini: 'google'
5632    };
5633    const provider = aiProviderMap[pId];
5634
5635    if (pId === 'google_gemini') apiKey = keys.googleGeminiKey;
5636    else if (pId === 'zhipu') apiKey = keys.zhipuKey || HordCore.assembleBuiltinKey('zhipu');
5637    else if (pId === 'tokenhub') { apiKey = keys.tokenhubKey || HordCore.assembleBuiltinKey('tokenhub'); model = keys.tokenhubModel || 'hy3'; }
5638    else if (pId === 'siliconflow') apiKey = keys.siliconflowKey || HordCore.assembleBuiltinKey('siliconflow');
5639
5640    if (!model) model = keys.aiModel || 'gpt-4o-mini';
5641    if (!apiKey) throw new Error('Key missing');
5642
5643    const res = await fetch('https://api.hord.asia/', {
5644      method: 'POST',
5645      headers: {
5646        'Content-Type': 'application/json',
5647        'X-Hord-Provider': provider,
5648        'X-Hord-User-Key': apiKey
5649      },
5650      body: JSON.stringify({
5651        model,
5652        messages: [
5653          { role: 'system', content: 'You are an accurate translator. Translate the English text provided by the user into concise, elegant Chinese. Return ONLY the translated Chinese text without any explanations.' },
5654          { role: 'user', content: text }
5655        ],
5656        max_tokens: 300,
5657        temperature: 0.3
5658      })
5659    });
5660    if (!res.ok) {
5661      if (res.status === 429) {
5662        if (pId === 'zhipu') zhipuBuiltinCooldownUntil = Date.now() + BUILTIN_COOLDOWN_MS;
5663        if (pId === 'tokenhub') tokenhubBuiltinCooldownUntil = Date.now() + BUILTIN_COOLDOWN_MS;
5664      }
5665      throw new Error('HTTP error ' + res.status);
5666    }
5667    const data = await res.json();
5668    const out = data.choices?.[0]?.message?.content
5669      || data.content?.[0]?.text
5670      || data.candidates?.[0]?.content?.parts?.[0]?.text
5671      || '';
5672    if (!out) throw new Error('Empty result');
5673    return out;
5674  };
5675
5676  const tryCascade = async (startProviderId) => {
5677    // 1. Zhipu
5678    if (startProviderId === 'zhipu') {
5679      if (Date.now() >= zhipuBuiltinCooldownUntil) {
5680        try { return await tryWithBuiltin('zhipu'); }
5681        catch(e) { console.warn('[reader] Zhipu fallback failed, try TokenHub', e); }
5682      }
5683      startProviderId = 'tokenhub';
5684    }
5685    // 2. TokenHub
5686    if (startProviderId === 'tokenhub') {
5687      if (Date.now() >= tokenhubBuiltinCooldownUntil) {
5688        try { return await tryWithBuiltin('tokenhub'); }
5689        catch(e) { console.warn('[reader] TokenHub fallback failed, try Google', e); }
5690      }
5691      startProviderId = 'google';
5692    }
5693    // 3. Google Translate
5694    if (startProviderId === 'google') {
5695      return await translateProvider('google', text, keys);
5696    }
5697    throw new Error('All fallbacks failed');
5698  };
5699
5700  if (providerId === 'relay') {
5701    const res = await fetch('https://license.hord.asia/v1/translate', {
5702      method: 'POST',
5703      headers: { 'Content-Type': 'application/json' },
5704      body: JSON.stringify({ text, source: 'auto', target: 'zh', max_candidates: 2 })
5705    });
5706    if (!res.ok) throw new Error('Relay http error ' + res.status);
5707    const data = await res.json();
5708    const out = data.translation || data.text || '';
5709    if (!out) throw new Error('Relay empty result');
5710    return out;
5711  }
5712  
5713  if (providerId === 'google') {
5714    const res = await fetch('https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=zh-CN&dt=t&q=' + encodeURIComponent(text));
5715    if (!res.ok) throw new Error('Google http error ' + res.status);
5716    const data = await res.json();
5717    const out = data?.[0]?.map(p => p?.[0] || '').join('') || '';
5718    if (!out) throw new Error('Google empty result');
5719    return out;
5720  }
5721  
5722  if (providerId === 'mymemory') {
5723    const res = await fetch(`https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=en|zh-CN`);
5724    if (!res.ok) throw new Error('MyMemory http error ' + res.status);
5725    const data = await res.json();
5726    const out = data?.responseData?.translatedText || '';
5727    if (!out) throw new Error('MyMemory empty result');
5728    return out;
5729  }
5730
5731  if (providerId === 'zhipu' || providerId === 'tokenhub') {
5732    return await tryCascade(providerId);
5733  }
5734
5735  const aiProviders = ['google_gemini', 'siliconflow'];
5736  if (aiProviders.includes(providerId)) {
5737    return await tryWithBuiltin(providerId);
5738  }
5739
5740  throw new Error('Not implemented on web or blocked by CORS');
5741}
5742
5743/**
5744 * 显示句子/段落翻译弹窗(G7:迁移到共享组件)
5745 *
5746 * 旧实现(~290 行内联 style 手写 DOM:自绘表头 / 药丸按钮 / 翻译 slots / AI 段 / 外链折叠 /
5747 * positionSentencePopup 定位 / 自管外部点击关闭)已整体删除,改为委托
5748 * HordReaderPopup.showSentencePopup → HordCore.PopupComponent → PopupView.renderSentenceCard
5749 * (与扩展端同一渲染真源)。翻译 / AI / 金句数据源经 globalThis.HordReaderSentenceApi
5750 * 注入 dataAdapter(见本文件尾部导出)。
5751 */
5752function showSentenceTranslation(text, rect, opts) {
5753  closeAllReaderPopups();
5754  const RP = globalThis.HordReaderPopup;
5755  if (!RP || typeof RP.showSentencePopup !== 'function') {
5756    console.warn('[Reader] HordReaderPopup.showSentencePopup 不可用,句子弹窗未显示');
5757    return;
5758  }
5759  RP.showSentencePopup(text, rect, Object.assign({}, opts || {}, getReaderSentenceContext()));
5760}
5761
5762/** 句子弹窗的书籍 / 章节上下文(收藏金句、AI 上下文共用) */
5763function getReaderSentenceContext() {
5764  let chapterName = '';
5765  try { chapterName = getChapterName() || ''; } catch (_) {}
5766  return {
5767    bookId: (currentBook && currentBook.id) || '',
5768    bookTitle: (currentBook && (currentBook.title || currentBook.name)) || '',
5769    chapterName: chapterName,
5770    txtIndex: (state && state.paragraphIndex != null) ? state.paragraphIndex : null,
5771  };
5772}
5773
5774/** EPUB iframe 文本选择桥接 */
5775function bindEpubSelectionBridge(iframe) {
5776  try {
5777    const doc = iframe.contentDocument;
5778    if (!doc) return;
5779
5780    // 双击 EPUB 内部单词 → 查词弹窗(对齐插件 content.js:dblclick 命中单词才弹,不点空白、不悬停)
5781    doc.addEventListener('dblclick', (ev) => {
5782      if (settingsOpen) closeSettingsPanel(); // R7 T0:EPUB 内划选/双击入口先关设置面板
5783      if (isPopupPinned && isPopupPinned()) return;
5784      let range;
5785      try {
5786        if (doc.caretRangeFromPoint) range = doc.caretRangeFromPoint(ev.clientX, ev.clientY);
5787        else if (doc.caretPositionFromPoint) {
5788          const pos = doc.caretPositionFromPoint(ev.clientX, ev.clientY);
5789          if (pos) { range = doc.createRange(); range.setStart(pos.offsetNode, pos.offset); range.setEnd(pos.offsetNode, pos.offset); }
5790        }
5791      } catch (_) { range = null; }
5792      if (!range || !range.startContainer || range.startContainer.nodeType !== Node.TEXT_NODE) return;
5793      const wm = HordCore.extractWordAtPosition(range.startContainer.textContent || '', range.startOffset);
5794      if (!wm) return;
5795      const iframeRect = iframe.getBoundingClientRect();
5796      const wordRect = {
5797        left: iframeRect.left + ev.clientX - 20,
5798        top: iframeRect.top + ev.clientY - 8,
5799        width: wm.word.length * 10,
5800        height: 20,
5801        right: iframeRect.left + ev.clientX + wm.word.length * 10,
5802        bottom: iframeRect.top + ev.clientY + 12,
5803      };
5804      const lemma = getLemma(wm.word);
5805      const meta = vocabMeta[lemma] || vocabMeta[wm.word.toLowerCase()];
5806      showWordPopup(lemma, wordRect, { meaning: meta?.meaning || '', source: currentBook?.title || '' });
5807    });
5808
5809    // EPUB 内部 mousedown 关闭弹窗已改由组件 registerExternalDoc('mousedown') 统一处理(C4 自包含),
5810    // 此处不再重复桥接,避免与组件逻辑双关、且 pin 早退不一致。
5811
5812    // 键盘快捷键事件桥接转发,透传回外层主窗口
5813    doc.addEventListener('keydown', (ev) => {
5814      if (epubLayout?._keyHandler) {
5815        epubLayout._keyHandler(ev);
5816      }
5817    });
5818
5819    doc.addEventListener('mouseup', () => {
5820      const sel = doc.getSelection();
5821      const text = (sel.toString() || '').trim();
5822      if (!text) return;
5823
5824      const iframeRect = iframe.getBoundingClientRect();
5825      const range = sel.rangeCount > 0 ? sel.getRangeAt(0) : null;
5826      if (!range) return;
5827      const rangeRect = range.getBoundingClientRect();
5828
5829      // 转换到主窗口坐标
5830      const rect = {
5831        left: iframeRect.left + rangeRect.left,
5832        top: iframeRect.top + rangeRect.top,
5833        right: iframeRect.left + rangeRect.right,
5834        bottom: iframeRect.top + rangeRect.bottom,
5835        width: rangeRect.width,
5836        height: rangeRect.height,
5837      };
5838
5839      // #314-T2: 与扩展对齐——≥2 词即触发句子翻译弹窗(原门槛 ≥3 词导致 2 词选区死区)
5840      const wordCount = text.split(/\s+/).length;
5841      if (text.length >= 3 && wordCount >= 2) {
5842        showSentenceTranslation(text, rect);
5843        return;
5844      }
5845
5846      // 单词 → 查词弹窗
5847      if (text.length < 2 || text.length > 100) return;
5848      if (!/^[a-zA-Z][a-zA-Z'-]*$/.test(text)) return;
5849
5850      const lemma = getLemma(text);
5851      showWordPopup(lemma, rect, {
5852        source: currentBook?.title || '',
5853      });
5854    });
5855  } catch (_) { /* 跨域 iframe 静默跳过 */ }
5856}
5857
5858// ================================================================
5859// 导航
5860// ================================================================
5861
5862function startReadingTimer() {
5863  readingStartTime = Date.now();
5864  stopReadingTimer();
5865  _readingTimer = setInterval(() => {
5866    const elapsed = Math.floor((Date.now() - readingStartTime) / 1000);
5867    const m = Math.floor(elapsed / 60);
5868    const s = elapsed % 60;
5869    const el = $root?.querySelector('#rdReadingTime');
5870    if (el) {
5871      // 本次会话:MM:SS(与扩展端 #readingTime 对齐)
5872      const text = `${String(m).padStart(2, '0')}
5872:${String(s).padStart(2, '0')}`;
5873      if (el.textContent !== text) el.textContent = text;
5874    }
5875    // 累计阅读时长:与扩展端 #cumulativeTime 对齐,独立 span(P1-4,R5b 恢复双 span 布局)
5876    const cumEl = $root?.querySelector('#rdCumulativeTime');
5877    if (cumEl) {
5878      const total = (currentBook?.totalSeconds || 0) + Math.floor((Date.now() - (sessionStart > 0 ? sessionStart : readingStartTime)) / 1000);
5879      const ctext = `累计 ${HordCore.formatCumulativeTime(total)}`;
5880      if (cumEl.textContent !== ctext) cumEl.textContent = ctext;
5881    }
5882  }, 1000);
5883}
5884
5885function stopReadingTimer() {
5886  if (_readingTimer) { clearInterval(_readingTimer); _readingTimer = null; }
5887}
5888
5889/**
5890 * 阅读器加载失败时的错误恢复:退出全屏模式,恢复书架视图
5891 */
5892function _exitReaderModeOnError() {
5893  currentBook = null;
5894  paragraphs = [];
5895  sessionStart = 0;
5896  _selectionBound = false;
5897  searchOpen = false;
5898  searchOrigin = null;
5899  // 通知宿主退出阅读模式
5900  window.dispatchEvent(new CustomEvent('hord:reader-exit'));
5901  // 恢复书架视图
5902  renderShelf();
5903}
5904
5905function goBack() {
5906  // 停止计时器
5907  stopReadingTimer();
5908  // 保存进度
5909  if (currentBook) {
5910    saveProgress();
5911    saveState();
5912
5913    // 记录连续打卡活动(阅读分钟数)
5914    try {
5915      const minutes = sessionStart > 0 ? Math.round((Date.now() - sessionStart) / 60000) : 0;
5916      if (minutes >= 1) recordStreakActivity('reading', minutes);
5917    } catch (_) {}
5918  }
5919  // 清理键盘监听
5920  document.removeEventListener('keydown', handleKeyNav);
5921  document.removeEventListener('mousemove', handleQuoteHover);
5922  removeTooltip();
5923  hideQuoteTooltip();
5924  closeReaderPopup();
5925  destroyReaderPopup();
5926  closeAllReaderPopups();
5927  // A2: 清理阅读器写入的 body 内联背景(L3047/L4459),防止泄漏回 Hub 主页
5928  document.body.style.background = '';
5929
5930  // 清理 EPUB 实例
5931  if (epubLayout) {
5932    try {
5933      if (epubLayout._keyHandler) document.removeEventListener('keydown', epubLayout._keyHandler);
5934      if (epubLayout._epubIframeObs) epubLayout._epubIframeObs.disconnect(); // T3(B6):退出时断开 EPUB viewer MutationObserver
5935      if (epubLayout.rendition) epubLayout.rendition.destroy();
5936      if (epubLayout.book) epubLayout.book.destroy();
5937    } catch (_) {}
5938    epubLayout = null;
5939  }
5940
5941  // 恢复容器 overflow(EPUB 渲染时被设为 hidden)
5942  if ($root) {
5943    $root.style.overflow = '';
5944  }
5945
5946  // 退出全屏
5947  if (isFullscreen && document.fullscreenElement) {
5948    document.exitFullscreen().catch(() => {});
5949  }
5950
5951  currentBook = null;
5952  paragraphs = [];
5953  sessionStart = 0;
5954  _selectionBound = false;
5955  searchOpen = false;
5956  searchOrigin = null;
5957  _lastQuoteHoverText = '';
5958
5959  // 通知宿主退出阅读模式(恢复 hero + 侧边栏)
5960  window.dispatchEvent(new CustomEvent('hord:reader-exit'));
5961
5962  // 防御性重施主题:防止阅读期间任何代码路径(如误触 shelf 主题按钮、
5963  // 或 EPUB 内部主题切换的副作用)污染全局 data-theme / vb-* 类,
5964  // 导致返回 Hub 主页后暗色泄漏。从 localStorage 读取用户真实偏好并强制同步。
5965  try {
5966    const tm = window.__hubThemeManager || globalThis.ThemeManager;
5967    if (tm && typeof tm.getSaved === 'function' && typeof tm.apply === 'function') {
5968      tm.apply(tm.getSaved());
5969    }
5970  } catch (_) {}
5971
5972  renderShelf();
5973}
5974
5975// ================================================================
5976// 导出(模块入口)
5977// ================================================================
5978
5979async function init(container) {
5980  $root = container;
5981  ensureStyles();
5982  document.removeEventListener('keydown', handleKeyNav);
5983
5984  // 加载书架数据
5985  await loadBookshelf();
5986
5987  // Hub SPA 模式:自动打开最近阅读的书(跳过中间书架步骤)
5988  // reader-window.html 独立页面:显示书架(通过 URL 参数 ?book= 指定书籍)
5989  if (!location.pathname.includes('reader-window')) {
5990    const activeBookId = lsGet(LS_ACTIVE_BOOK, '');
5991    const activeBook = activeBookId ? findBook(activeBookId) : null;
5992    const lastReadBook = activeBook || bookshelf
5993      .filter(b => !b.isDeleted && (b.progress || 0) > 0)
5994      .sort((a, b) => (b.lastOpened || 0) - (a.lastOpened || 0))[0];
5995
5996    if (lastReadBook) {
5997      await openBook(lastReadBook.id);
5998      return;
5999    }
6000  }
6001
6002  await renderShelf();
6003}
6004
6005function render(container) {
6006  document.removeEventListener('keydown', handleKeyNav);
6007  init(container);
6008}
6009
6010function destroy() {
6011  // 直接调用 goBack 的内部清理机制以确保卸载干净
6012  stopReadingTimer();
6013  if (currentBook) {
6014    saveProgress();
6015    saveState();
6016  }
6017  document.removeEventListener('keydown', handleKeyNav);
6018  document.removeEventListener('mousemove', handleQuoteHover);
6019  removeTooltip();
6020  hideQuoteTooltip();
6021  closeReaderPopup();
6022  destroyReaderPopup();
6023  closeAllReaderPopups();
6024
6025  // 清理 EPUB 实例与 keyHandler 监听
6026  if (epubLayout) {
6027    try {
6028      if (epubLayout._keyHandler) document.removeEventListener('keydown', epubLayout._keyHandler);
6029      if (epubLayout._epubIframeObs) epubLayout._epubIframeObs.disconnect(); // T3(B6):退出时断开 EPUB viewer MutationObserver
6030      if (epubLayout.rendition) epubLayout.rendition.destroy();
6031      if (epubLayout.book) epubLayout.book.destroy();
6032    } catch (_) {}
6033    epubLayout = null;
6034  }
6035
6036  // 隐藏模态框
6037  closeAiAnalysisModal();
6038
6039  if ($root) {
6040    $root.style.overflow = '';
6041  }
6042
6043  currentBook = null;
6044  paragraphs = [];
6045  sessionStart = 0;
6046  _selectionBound = false;
6047}
6048
6049// ================================================================
6050// AI 解析与金句卡片导出 (P1 对齐功能)
6051// ================================================================
6052
6053// 解析当前用于「AI 解析」的客户端(句子弹窗 AI 与段落弹窗 AI 共用)。
6054// #314-T3: 选源逻辑(含内置 key 兜底)已下沉 src-core/hord-ai-client.js,
6055// 本函数仅负责读取 localStorage 并透传,保证与单词弹窗(reader-popup.js)同源同行为。
6056function readHubAiKeys() {
6057  let keys = {};
6058  try {
6059    const raw = localStorage.getItem('hub_api_keys_v1');
6060    if (raw) keys = JSON.parse(raw).keys || {};
6061  } catch (_) {}
6062  return keys;
6063}
6064
6065// opts.skipRelay=true:跳过官方 AI 中转,直取下一级内置 key 通道(relay 失败时的兜底)
6066function resolveHubAiClient(opts) {
6067  return HordCore.resolveAiClient(readHubAiKeys(), opts);
6068}
6069
6070function getHubUserSettings() {
6071  if (typeof globalThis.HordUserSettings?.get === 'function') {
6072    return globalThis.HordUserSettings;
6073  }
6074  let keys = {};
6075  let customSlots = [];
6076  try {
6077    const raw = localStorage.getItem('hub_api_keys_v1');
6078    if (raw) {
6079      const parsed = JSON.parse(raw);
6080      keys = parsed.keys || {};
6081      customSlots = parsed.customSlots || [];
6082    }
6083  } catch (e) {}
6084  return {
6085    get: (key) => {
6086      if (key === 'aiProvider') return keys.aiTextAssistProvider || keys.aiProvider || 'openai';
6087      if (key === 'aiModel') {
6088        const prov = keys.aiTextAssistProvider || keys.aiProvider || 'openai';
6089        if (prov === 'zhipu') return keys.zhipuModel || 'glm-4-flash';
6090        if (prov === 'tokenhub') return keys.tokenhubModel || 'hy3';
6091        if (prov === 'google_gemini') return keys.googleGeminiModel || keys.aiModel || 'gemini-2.5-flash';
6092        if (prov === 'siliconflow') return keys.siliconflowModel || 'deepseek-ai/DeepSeek-V3';
6093        if (prov === 'hunyuan') return keys.hunyuanModel || 'hunyuan-2.0-instruct-20251111';
6094        if (prov.startsWith('custom_')) {
6095          const idx = parseInt(prov.replace('custom_', ''), 10);
6096          return customSlots[idx]?.model || '';
6097        }
6098        return keys.aiModel || 'gpt-4o-mini';
6099      }
6100      if (key === 'aiApiKey') {
6101        const prov = keys.aiTextAssistProvider || keys.aiProvider || 'openai';
6102        if (prov === 'zhipu') return keys.zhipuKey || HordCore.assembleBuiltinKey('zhipu');
6103        if (prov === 'tokenhub') return keys.tokenhubKey || HordCore.assembleBuiltinKey('tokenhub');
6104        if (prov === 'google_gemini') return keys.googleGeminiKey || '';
6105        if (prov === 'siliconflow') return keys.siliconflowKey || HordCore.assembleBuiltinKey('siliconflow');
6106        if (prov === 'hunyuan') return keys.hunyuanKey || keys.tencentKey || HordCore.assembleBuiltinKey('hunyuan');
6107        if (prov.startsWith('custom_')) {
6108          const idx = parseInt(prov.replace('custom_', ''), 10);
6109          return customSlots[idx]?.key || '';
6110        }
6111        return keys.googleGeminiKey || keys.siliconflowKey || keys.zhipuKey || keys.hunyuanKey || '';
6112      }
6113      return keys[key] || '';
6114    }
6115  };
6116}
6117
6118// ================================================================
6119// 句子 AI 语境解析(对齐插件端 renderAiTextAssistBody 结构化输出)
6120// ================================================================
6121
6122// HTML 转义(优先复用 HordCore.escapeHtml,缺失时自保底)
6123function hordEsc(s) {
6124  if (s == null) return '';
6125  if (typeof HordCore !== 'undefined' && typeof HordCore.escapeHtml === 'function') return HordCore.escapeHtml(String(s));
6126  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
6127}
6128
6129// 句子/段落语境解析提示词:要求模型返回结构化 JSON(翻译 + 句子分析 + 重点词汇 + 语法要点 + 摘要)
6130const AI_SENTENCE_PROMPT = `You are an English reading assistant for a Chinese-speaking English learner. Analyze the given English sentence or short paragraph. Return ONLY a single JSON object (no markdown, no code fences) with this exact structure:
6131{
6132  "translation": "流畅、准确的中文翻译",
6133  "sentenceAnalysis": [ { "sentence": "原文中值得讲解的子句或表达", "anal
6133ysis": "用中文讲解其含义、用法或修辞" } ],
6134  "vocabulary": [ { "word": "重点生词或短语", "meaning": "中文释义" } ],
6135  "grammarNote": "用中文点出关键语法现象(时态/从句/虚拟语气等);若无可填空字符串",
6136  "summary": "用一两句话概括这段话的核心意思(中文)"
6137}`;
6138
6139// Phase 1.5:官方 AI 中转 sentence 任务返回的是扩展端 schema
6140// ({translation, skeleton, chunks[], phrases[], grammarNote}),
6141// 而 fetchSentenceAi 的对外契约是 Hub schema({translation, sentenceAnalysis[], vocabulary[], grammarNote, summary})。
6142// 此处把中转结果归一到 Hub schema,prompt 零改动。
6143// G7 起,Hub schema 会在 reader-popup.js 的 dataAdapter.lookupAiSentence 里再映射回共享 schema
6144// 交给 PopupView.renderAiDeepAnalysisBody 渲染(两次映射互逆,保留是因为 AI_SENTENCE_PROMPT
6145// 与 7 天缓存都按 Hub schema 落库,改契约会击穿存量缓存)。
6146function mapRelaySentenceToHubSchema(src) {
6147  const d = src || {};
6148  if (Array.isArray(d.sentenceAnalysis) || Array.isArray(d.vocabulary)) return d; // 已是 Hub schema
6149  const chunks = Array.isArray(d.chunks) ? d.chunks : [];
6150  const phrases = Array.isArray(d.phrases) ? d.phrases : [];
6151  return {
6152    translation: d.translation || '',
6153    sentenceAnalysis: chunks.slice(0, 6).map(c => ({
6154      sentence: c.text || c.sentence || '',
6155      analysis: [c.type ? `【${c.type}】` : '', c.meaning || c.analysis || ''].filter(Boolean).join(' '),
6156    })),
6157    vocabulary: phrases.slice(0, 8).map(p => ({
6158      word: p.phrase || p.word || '',
6159      meaning: p.meaning || '',
6160    })),
6161    grammarNote: d.grammarNote || '',
6162    summary: d.summary || d.skeleton || '',
6163  };
6164}
6165
6166// 调用 api.hord.asia 做结构化句子解析(带 JSON 重试 + 7 天用户隔离缓存),对齐 reader-popup.js fetchAiLookup
6167async function fetchSentenceAi(text, opts) {
6168  const { provider, model, apiKey } = opts || {};
6169  // #315-T4: 内联 userId 解析(改用 HordCloudAuthCore.getSession),修复跨文件辅助函数不可见导致恒 'anon'、多账户串缓存
6170  let userId = 'anon';
6171  try {
6172    const sess = await globalThis.HordCloudAuthCore?.getSession?.();
6173    if (sess?.user?.id) userId = String(sess.user.id);
6174  } catch (_) {}
6175  const cacheKey = `hord_stAiCache_${userId}_${provider}_${model}_${(text || '').slice(0, 120)}`;
6176  try {
6177    const cached = JSON.parse(localStorage.getItem(cacheKey) || 'null');
6178    if (cached && Date.now() - cached.time < 7 * 24 * 3600 * 1000) return cached.result;
6179  } catch (_) {}
6180
6181  // Phase 1.5:零配置用户走 HORD 官方 AI 中转(钥匙池;Hub 源站在 origin 白名单内,免口令)
6182  if (provider === 'relay') {
6183    const r = await HordCore.callHordRelayAi({ task: 'sentence', text });
6184    if (r.ok && r.data) {
6185      const mapped = mapRelaySentenceToHubSchema(r.data);
6186      try {
6187        localStorage.setItem(cacheKey, JSON.stringify({ result: mapped, time: Date.now() }));
6188      } catch (_) {}
6189      return mapped;
6190    }
6191    // 中转不可用 → 回退到下一级内置 key 通道(保证不比接入前更差;递归深度 1)
6192    const fb = resolveHubAiClient({ skipRelay: true });
6193    if (fb && fb.apiKey) {
6194      return await fetchSentenceAi(text, { provider: fb.provider, model: fb.model, apiKey: fb.apiKey });
6195    }
6196    throw new Error('官方 AI 中转暂不可用(' + (r.error || 'empty') + '),可到设置页配置自己的 AI Key');
6197  }
6198
6199  const headers = {
6200    'Content-Type': 'application/json',
6201    'X-Hord-Provider': provider,
6202    'X-Hord-User-Key': apiKey,
6203  };
6204
6205  const doRequest = async (retry) => {
6206    const sysContent = retry
6207      ? AI_SENTENCE_PROMPT + '\n\nIMPORTANT: Output ONLY a single JSON object and nothing else. No markdown code fences, no explanatory text before or after.'
6208      : AI_SENTENCE_PROMPT;
6209    const resp = await fetch('https://api.hord.asia/', {
6210      method: 'POST',
6211      headers,
6212      body: JSON.stringify({
6213        model,
6214        messages: [
6215          { role: 'system', content: sysContent },
6216          { role: 'user', content: `Text: """${text}"""` }
6217        ],
6218        max_tokens: 1200,
6219        temperature: 0.7,
6220      }),
6221    });
6222    if (!resp.ok) {
6223      const err = await resp.json().catch(() => ({ error: 'Unknown error' }));
6224      throw new Error(resp.status === 401 ? 'AI Key 无效或已过期,请到设置页检查对应 Provider 的 Key' : (err.error || String(resp.status)));
6225    }
6226    const data = await resp.json();
6227    const content = data.choices?.[0]?.message?.content
6228      || data.content?.[0]?.text
6229      || data.candidates?.[0]?.content?.parts?.[0]?.text
6230      || '';
6231    const jsonMatch = content.match(/\{[\s\S]*\}/);
6232    if (!jsonMatch) {
6233      if (!retry) return doRequest(true);
6234      throw new Error('模型未返回结构化 JSON,请重试或切换 Provider');
6235    }
6236    try {
6237      return JSON.parse(jsonMatch[0]);
6238    } catch (_) {
6239      if (!retry) return doRequest(true);
6240      throw new Error('模型返回了无法解析的内容,请重试或切换 Provider');
6241    }
6242  };
6243
6244  const result = await doRequest(false);
6245  try {
6246    localStorage.setItem(cacheKey, JSON.stringify({ result, time: Date.now() }));
6247  } catch (_) {}
6248  return result;
6249}
6250
6251// G7:原 renderSentenceAiBody(内联 style 的 Hub 私有 AI 段渲染)已删除 —— 句子 AI 段改由共享
6252// PopupView.renderAiDeepAnalysisBody 渲染;Hub schema→共享 schema 的字段映射在
6253// reader-popup.js 的 dataAdapter.lookupAiSentence 内完成。
6254
6255function getActiveSelectionText() {
6256  let txt = '';
6257  // 1. 尝试从外层 selection 获取
6258  if (window.getSelection) {
6259    txt = window.getSelection().toString();
6260  }
6261  // 2. 尝试从 iframe 内部获取 (EPUB 适配)
6262  if (!txt.trim()) {
6263    try {
6264      const iframes = Array.from($root.querySelectorAll('#epubViewer iframe'));
6265      for (const f of iframes) {
6266        if (f.contentWindow && f.contentWindow.getSelection) {
6267          const selText = f.contentWindow.getSelection().toString();
6268          if (selText.trim()) {
6269            txt = selText;
6270            break;
6271          }
6272        }
6273      }
6274    } catch (_) {}
6275  }
6276  return txt.trim();
6277}
6278
6279async function startAiParagraphAnalysis() {
6280  const text = getActiveSelectionText();
6281  let textToAnalyze = text;
6282  if (!textToAnalyze) {
6283    if (currentBook?.type === 'epub') {
6284      showToast('请先划词选中您要解析的句子或段落', 'info');
6285      return;
6286    }
6287    // TXT 模式:无选区时,自动解析当前所在的段落
6288    textToAnalyze = paragraphs[state.paragraphIndex] || '';
6289  }
6290  if (!textToAnalyze.trim()) {
6291    showToast('当前段落内容为空,无法解析', 'warning');
6292    return;
6293  }
6294
6295  showAiAnalysisModal(textToAnalyze);
6296}
6297
6298function startParagraphExport() {
6299  const text = getActiveSelectionText();
6300  let textToExport = text;
6301  if (!textToExport) {
6302    if (currentBook?.type === 'epub') {
6303      showToast('请先划词选中您要导出的句子或段落', 'info');
6304      return;
6305    }
6306    // TXT 模式:无选区时,自动导出当前段落
6307    textToExport = paragraphs[state.paragraphIndex] || '';
6308  }
6309  if (!textToExport.trim()) {
6310    showToast('当前段落内容为空,无法导出', 'warning');
6311    return;
6312  }
6313
6314  // 构造临时的金句结构以喂给 Exporter
6315  const tempQuote = {
6316    id: 'temp_' + Date.now(),
6317    text: textToExport.trim(),
6318    source: currentBook?.title || currentBook?.name || '本地阅读',
6319    createdAt: Date.now()
6320  };
6321
6322  if (typeof globalThis.__hordOpenQuoteExport !== 'function') {
6323    showToast('[Hub/Export/Reader] 导出工作台未就绪,请刷新重试', 'error');
6324    return;
6325  }
6326  Promise.resolve(globalThis.__hordOpenQuoteExport(tempQuote)).catch(function (error) {
6327    console.error('[Hub/Export/Reader] formal workbench failed', error);
6328    showToast('[Hub/Export/Reader] 导出工作台未就绪,请从学习中枢打开阅读器后重试', 'error');
6329  });
6330}
6331
6332// ─── AI 解析模态框控制 ───
6333function showAiAnalysisModal(text) {
6334  // 1. 创建或获取 Modal 遮罩层
6335  let mask = document.getElementById('rdAiModalMask');
6336  if (!mask) {
6337    mask = document.createElement('div');
6338    mask.id = 'rdAiModalMask';
6339    mask.className = 'hord-ai-modal-mask';
6340    mask.innerHTML = `
6341      <div class="hord-ai-modal-card">
6342        <div class="hord-ai-modal-header">
6343          <span class="hord-ai-modal-title">✨ AI 深度句意解析</span>
6344          <button class="hord-ai-modal-close" id="rdAiModalClose">✕</button>
6345        </div>
6346        <div class="hord-ai-modal-body" id="rdAiModalBody">
6347          <div class="hord-ai-modal-loading">⚡️ AI 深度推理中,请稍候...</div>
6348        </div>
6349      </div>
6350    `;
6351    document.body.appendChild(mask);
6352    
6353    // 绑定关闭事件
6354    mask.querySelector('#rdAiModalClose').addEventListener('click', closeAiAnalysisModal);
6355    mask.addEventListener('click', (e) => {
6356      if (e.target === mask) closeAiAnalysisModal();
6357    });
6358  }
6359
6360  const bodyEl = mask.querySelector('#rdAiModalBody');
6361  bodyEl.innerHTML = `<div class="hord-ai-modal-loading">⚡️ AI 深度解析中,请稍候...</div>`;
6362  mask.style.display = 'flex';
6363
6364  // 2. 发起 AI 请求
6365  triggerAiRequest(text, bodyEl);
6366}
6367
6368function closeAiAnalysisModal() {
6369  const mask = document.getElementById('rdAiModalMask');
6370  if (mask) mask.style.display = 'none';
6371}
6372
6373// clientOverride:relay 失败后回退内置 key 通道时由自身递归传入(深度 1)
6374async function triggerAiRequest(text, containerEl, clientOverride) {
6375  // 复用与句子 AI 一致的解析:provider 与 apiKey 来自同一提供商,避免默认 openai 错配 key
6376  const client = clientOverride || resolveHubAiClient();
6377  const provider = client.provider;
6378  const model = client.model;
6379
6380  const headers = {
6381    'Content-Type': 'application/json',
6382    'X-Hord-Provider': provider,
6383  };
6384
6385  // Phase 1.5:零配置用户走 HORD 官方 AI 中转(钥匙池)。
6386  // worker sentence 任务返回的 data 即本模态框所需 schema,直接渲染、prompt 零改动。
6387  if (provider === 'relay') {
6388    try {
6389      const r = await HordCore.callHordRelayAi({ task: 'sentence', text });
6390      if (r.ok && r.data) {
6391        renderAiAnalysisResult(r.data, containerEl);
6392        return;
6393      }
6394      console.warn('[AI Analysis] relay unavailable:', r.error);
6395    } catch (err) {
6396      console.error('[AI Analysis] relay error:', err);
6397    }
6398    // 中转不可用 → 回退到下一级内置 key 通道(递归深度 1)
6399    const fb = resolveHubAiClient({ skipRelay: true });
6400    if (fb && fb.apiKey) return triggerAiRequest(text, containerEl, fb);
6401    containerEl.innerHTML = `<div class="hord-ai-modal-error">官方 AI 中转暂不可用,请稍后重试,或到设置页配置您自己的 AI API Key</div>`;
6402    return;
6403  }
6404
6405  const apiKey = client.apiKey;
6406  if (apiKey) {
6407    headers['X-Hord-User-Key'] = apiKey;
6408  } else {
6409    containerEl.innerHTML = `<div class="hord-ai-modal-error">请先在 Hub 设置页配置您的 AI API Key 密钥</div>`;
6410    return;
6411  }
6412
6413  const systemPrompt = [
6414    'You are an elite linguistic analyst and English teacher.',
6415    'Output STRICTLY valid JSON. Begin exactly with { and end with }. No additional text, no markdown.',
6416    'Schema: {"translation":"string (complete Chinese translation, no omissions, natural and accurate; translate slang appropriately)","skeleton":"string (core logical backbone: for narratives retain action sequence, for arguments retain cause-effect; do NOT reduce complex structures to plain short sentences)","chunks":[{"type":"string (precise grammar label: 主句, 宾语从句, 状语从句, 定语从句, 直接引语, 独立主格, 并列谓语, 并列句, 插入语, 同位语, etc.)","text":"string","meaning":"string (Simplified Chinese)"}],"phrases":[{"phrase":"string","meaning":"string","note":"string (grammar or usage tip)","level":"string|null (CEFR C1|C2|null)"}],"grammarNote":"string (1-2 precise Chinese sentences on grammar points UNIQUE to this text; no vague generalizations; if simple, return \'无特殊语法\')"}',
6417    'Rules:',
6418    '1. skeleton: Extract core logic for the whole passage; preserve narrative or argumentative relations.',
6419    '2. chunks: Extract 1-6 most structurally significant clauses or sense groups. Do not blindly fragment entire paragraphs.',
6420    '3. phrases: Extract 0-6 vocabulary items — individual advanced/rare words AND high-value phrases. Prioritize individual advanced words over multi-word phrases when both are present. Only include common words if they are part of a high-value idiom. If no valuable items exist, return [].',
6421    '4. grammarNote: Point out 1-2 most distinctive grammatical features. If the text is simple, return "无特殊语法".',
6422    '5. translation: Translate every sentence completely without omission.'
6423  ].join('\n');
6424
6425  try {
6426    const resp = await fetch('https://api.hord.asia/', {
6427      method: 'POST',
6428      headers,
6429      body: JSON.stringify({
6430        model,
6431        messages: [
6432          { role: 'system', content: systemPrompt },
6433          { role: 'user', content: `Analyze this text:\n${text}` }
6434        ],
6435        max_tokens: 2000,
6436        temperature: 0.7,
6437      }),
6438    });
6439
6440    if (!resp.ok) {
6441      throw new Error(`Request failed with status ${resp.status}`);
6442    }
6443
6444    const data = await resp.json();
6445    const result = parseAiSentenceAnalysisJson(data.choices?.[0]?.message?.content || '');
6446    if (result.ok) {
6447      renderAiAnalysisResult(result.data, containerEl);
6448    } else {
6449      throw new Error('JSON 解析失败');
6450    }
6451  } catch (err) {
6452    console.error('[AI Analysis] Error:', err);
6453    containerEl.innerHTML = `<div class="hord-ai-modal-error">解析失败: ${err.message || '网络连接异常'}</div>`;
6454  }
6455}
6456
6457function parseAiSentenceAnalysisJson(raw) {
6458  try {
6459    let text = raw.trim();
6460    // 剔除 markdown 标记
6461    if (text.startsWith('```')) {
6462      text = text.replace(/^```[a-zA-Z]*\n/, '').replace(/\n```$/, '');
6463    }
6464    const start = text.indexOf('{');
6465    const end = text.lastIndexOf('}');
6466    if (start >= 0 && end > start) {
6467      text = text.slice(start, end + 1);
6468    }
6469    const data = JSON.parse(text);
6470    return {
6471      ok: true,
6472      data: {
6473        translation: data.translation || '',
6474        skeleton: data.skeleton || '',
6475        chunks: Array.isArray(data.chunks) ? data.chunks : [],
6476        phrases: Array.isArray(data.phrases) ? data.phrases : [],
6477        grammarNote: data.grammarNote || data.note || ''
6478      }
6479    };
6480  } catch (e) {
6481    return { ok: false, error: e };
6482  }
6483}
6484
6485function renderAiAnalysisResult(data, containerEl) {
6486  let chunksHtml = '';
6487  if (data.chunks && data.chunks.length > 0) {
6488    chunksHtml = `
6489      <div class="analysis-section-title">📐 句法结构拆解 (Chunks)</div>
6490      <div class="analysis-chunks-list">
6491        ${data.chunks.map(c => `
6492          <div class="analysis-chunk-card">
6493            <span class="chunk-badge">${c.type}</span>
6494            <div class="chunk-text">${c.text}</div>
6495            <div class="chunk-zh">${c.meaning || c.zh}</div>
6496          </div>
6497        `).join('')}
6498      </div>
6499    `;
6500  }
6501
6502  let phrasesHtml = '';
6503  if (data.phrases && data.phrases.length > 0) {
6504    phrasesHtml = `
6505      <div class="analysis-section-title">🔑 核心词汇与短语 (Vocabulary)</div>
6506      <div class="analysis-phrases-list">
6507        ${data.phrases.map(p => `
6508          <div class="analysis-phrase-card">
6509            <div class="phrase-head">
6510              <span class="phrase-text">${p.phrase}</span>
6511              ${p.level ? `<span class="phrase-level">${p.level}</span>` : ''}
6512            </div>
6513            <div class="phrase-zh">${p.meaning || p.zh}</div>
6514            ${p.note ? `<div class="phrase-note">💡 ${p.note}</div>` : ''}
6515          </div>
6516        `).join('')}
6517      </div>
6518    `;
6519  }
6520
6521  containerEl.innerHTML = `
6522    <div class="analysis-result-wrap">
6523      <div class="analysis-card bento-card">
6524        <div class="analysis-section-title">📚 中文意译 (Translation)</div>
6525        <div class="analysis-translation-text">${data.translation}</div>
6526      </div>
6527      
6528      <div class="analysis-card bento-card">
6529        <div class="analysis-section-title">🦴 逻辑骨架 (Skeleton)</div>
6530        <div class="analysis-skeleton-text">${data.skeleton}</div>
6531      </div>
6532
6533      <div class="analysis-card bento-card">
6534        <div class="analysis-section-title">📝 难点语法释要 (Grammar Note)</div>
6535        <div class="analysis-grammar-text">${data.grammarNote || '无特殊语法'}</div>
6536      </div>
6537
6538      ${chunksHtml}
6539      ${phrasesHtml}
6540    </div>
6541  `;
6542}
6543
6544// ================================================================
6545// G7:句子弹窗数据源(供 reader-popup.js 的 dataAdapter 跨文件调用)
6546// ----------------------------------------------------------------
6547// reader.js 与 reader-popup.js 是两个独立 IIFE,翻译 / AI / 金句函数都是模块作用域,
6548// adapter 拿不到 —— 必须经 globalThis 显式导出。reader-popup.js 先加载,故它只能在
6549// 调用时刻(而非加载时刻)惰性读取本对象。
6550// ================================================================
6551
6552/** 金句 toggle(收藏 ⇄ 取消收藏),返回 toggle 后的收藏态 */
6553async function toggleQuoteByText(text, translation) {
6554  const normText = normalizeQuoteText(text);
6555  if (!normText) return false;
6556  // ⑱ 探针(F-⑫):金句 toggle 全链 trace(reader-popup toggleFavorite → 此处 → saveAsQuote/deleteQuote → loadQuoteData 读回)
6557  hordProbe('[HORD-PROBE] toggleQuoteByText: ENTER text=', JSON.stringify(String(text || '').slice(0, 40)), 'mutateAsset typeof=', typeof globalThis.HordAssetStore?.mutateAsset, 'quoteDataLen(before load)=', (typeof quoteData !== 'undefined' && quoteData) ? quoteData.length : 'n/a');
6558  await loadQuoteData();
6559  const existing = quoteData.find(q => normalizeQuoteText(getQuoteText(q)) === normText);
6560  hordProbe('[HORD-PROBE] toggleQuoteByText: existing=', existing ? { createdAt: existing.createdAt, text: String(getQuoteText(existing) || '').slice(0, 40) } : null, 'quoteDataLen(after load)=', quoteData.length);
6561  if (existing) {
6562    await deleteQuote(existing.createdAt);
6563    // P0-b:写/删后直接刷新高亮 + 重渲侧栏,不独靠组件回调链(双保险,立即生效)
6564    refreshHighlights();
6565    hubQuoteAdapter.renderQuoteSidebar();
6566    await loadQuoteData();
6567    const afterDelete = quoteData.some(q => normalizeQuoteText(getQuoteText(q)) === normText);
6568    hordProbe('[HORD-PROBE] toggleQuoteByText: 取消收藏后读回存在=', afterDelete, 'quoteDataLen=', quoteData.length);
6569    return afterDelete;
6570  }
6571  await saveAsQuote(text, translation);
6572  // 修复错位一拍:先确保本地 quotes 已重载为最新(loadQuoteData 内部走 readerComponent.loadQuotes),
6573  // 再刷高亮 + 重渲侧栏——保证渲染用的是最新 bookQuotes,而非 reload 那一刻的旧值。
6574  await loadQuoteData();
6575  refreshHighlights();
6576  hubQuoteAdapter.renderQuoteSidebar();
6577  const afterAdd = quoteData.some(q => normalizeQuoteText(getQuoteText(q)) === normText);
6578  hordProbe('[HORD-PROBE] toggleQuoteByText: 写入后读回存在=', afterAdd, 'quoteDataLen=', quoteData.length);
6579  return afterAdd;
6580}
6581
6582/** 句子批注:写入金句记录的 note(金句不存在则连带创建,等价「批注即收藏」) */
6583async function saveQuoteNoteByText(text, note) {
6584  if (typeof globalThis.HordAssetStore?.mutateAsset !== 'function') return;
6585  const normText = normalizeQuoteText(text);
6586  if (!normText) return;
6587  const now = Date.now();
6588  const ctxInfo = getReaderSentenceContext();
6589  const result = await globalThis.HordAssetStore.mutateAsset((ctx) => {
6590    const asset = ctx.asset;
6591    if (!Array.isArray(asset.quotes)) asset.quotes = [];
6592    const hit = asset.quotes.find(q => !q.isDeleted && normalizeQuoteText(getQuoteText(q)) === normText);
6593    if (hit) {
6594      hit.note = note;
6595      hit.updatedAt = now;
6596    } else {
6597      asset.quotes.push({
6598        id: String(now),
6599        text: String(text || '').trim(),
6600        bookId: ctxInfo.bookId,
6601        bookTitle: ctxInfo.bookTitle,
6602        chapterName: ctxInfo.chapterName,
6603        txtIndex: ctxInfo.txtIndex,
6604        createdAt: now,
6605        note: note,
6606        isDeleted: false,
6607      });
6608    }
6609  }, { syncDelay: 2000 });
6610  if (!result?.ok) {
6611    showToast(result?.error === 'SENTENCE_LIMIT_50' ? '免费版最多收藏 50 条句子,升级 PRO 后可继续收藏。' : '批注保存失败,请稍后重试。', 'error');
6612    return;
6613  }
6614  await loadQuoteData();
6615  refreshHighlights();
6616  showToast('批注已保存', 'success');
6617}
6618
6619/** 金句列表(归一成组件 getSavedQuotes 契约:{text,url,note}) */
6620async function getQuotesForPopup() {
6621  await loadQuoteData();
6622  const url = (typeof location !== 'undefined' && location.href) || '';
6623  return quoteData.map(q => ({
6624    text: getQuoteText(q),
6625    url: url,
6626    note: q.note || '',
6627    createdAt: q.createdAt || 0,
6628  }));
6629}
6630
6631globalThis.HordReaderSentenceApi = {
6632  getTranslateProviders: getHubTranslateProviders,
6633  translate: translateProvider,
6634  readKeys: readHubAiKeys,
6635  resolveAiClient: resolveHubAiClient,
6636  fetchSentenceAi: fetchSentenceAi,
6637  getQuotes: getQuotesForPopup,
6638  toggleQuote: toggleQuoteByText,
6639  saveQuoteNote: saveQuoteNoteByText,
6640  getSentenceContext: getReaderSentenceContext,
6641  toast: showToast,
6642};
6643
6644// ── globalThis 导出 ──
6645globalThis.HordReader = { init, render, destroy };
6646
6647})(typeof self !== 'undefined' ? self : typeof window !== 'undefined' ? window : this);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.