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, '&').replace(/"/g, '"').replace(/</g, '<'); 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">← è¿å书æ¶</button> 4257 </div> 4258 <div class="reader-epub-placeholder"> 4259 <div class="icon">📚</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, '&').replace(/</g, '<').replace(/>/g, '>'); 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.