const MAX_LIVE_ADAPTERS = 3; import { createVisualContext, toAiVisualContext, withOcrResult } from './visual-context.mjs'; import { ocrAvailability, recognizeOcr } from './ocr-provider.mjs'; const PROGRESS_DELAY = 800; const PDF_SCALES = [0.25, 0.33, 0.5, 0.75, 1, 1.2, 1.5, 1.75, 2, 2.5, 3, 4, 5]; const SCALE_MIN = PDF_SCALES[0]; const SCALE_MAX = PDF_SCALES[PDF_SCALES.length - 1]; const FONT_MIN = 12; const FONT_MAX = 32; const api = window.api; const $ = (id) => document.getElementById(id); const el = { bookTitle: $('bookTitle'), uiThemeBtn: $('uiThemeBtn'), minBtn: $('minBtn'), maxBtn: $('maxBtn'), closeBtn: $('closeBtn'), docTabs: $('docTabs'), addTabBtn: $('addTabBtn'), tocPane: $('tocPane'), tocList: $('tocList'), tocHideBtn: $('tocHideBtn'), tocToggleBtn: $('tocToggleBtn'), docArea: $('docArea'), docEmpty: $('docEmpty'), emptyOpenBtn: $('emptyOpenBtn'), sidePane: $('sidePane'), sideHideBtn: $('sideHideBtn'), sideToggleBtn: $('sideToggleBtn'), addBookmarkBtn: $('addBookmarkBtn'), bookmarkList: $('bookmarkList'), annotationList: $('annotationList'), addNoteBtn: $('addNoteBtn'), noteCollectionFilter: $('noteCollectionFilter'), noteList: $('noteList'), aiStatus: $('aiStatus'), aiQuote: $('aiQuote'), aiOutput: $('aiOutput'), aiError: $('aiError'), aiStopBtn: $('aiStopBtn'), aiSaveBtn: $('aiSaveBtn'), aiCopyBtn: $('aiCopyBtn'), aiQuestion: $('aiQuestion'), aiSendBtn: $('aiSendBtn'), aiScope: $('aiScope'), aiCost: $('aiCost'), aiVisualCard: $('aiVisualCard'), aiVisualPreview: $('aiVisualPreview'), aiVisualLabel: $('aiVisualLabel'), aiVisualMeta: $('aiVisualMeta'), aiOcrStatus: $('aiOcrStatus'), aiVisualReselectBtn: $('aiVisualReselectBtn'), aiVisualRemoveBtn: $('aiVisualRemoveBtn'), aiOcrBtn: $('aiOcrBtn'), posLabel: $('posLabel'), pctLabel: $('pctLabel'), progressRange: $('progressRange'), statusMsg: $('statusMsg'), prevBtn: $('prevBtn'), nextBtn: $('nextBtn'), zoomOutBtn: $('zoomOutBtn'), zoomInBtn: $('zoomInBtn'), zoomLabel: $('zoomLabel'), fitWidthBtn: $('fitWidthBtn'), pdfViewControls: $('pdfViewControls'), pdfViewMode: $('pdfViewMode'), pdfPageLayout: $('pdfPageLayout'), themeSelect: $('themeSelect'), selBar: $('selBar'), toast: $('toast'), pickModal: $('pickModal'), pickList: $('pickList'), pickCancelBtn: $('pickCancelBtn'), aiConfirmModal: $('aiConfirmModal'), aiConfirmScope: $('aiConfirmScope'), aiConfirmCost: $('aiConfirmCost'), aiConfirmNotice: $('aiConfirmNotice'), aiConfirmCancelBtn: $('aiConfirmCancelBtn'), aiConfirmSendBtn: $('aiConfirmSendBtn'), annotationToolbar: $('annotationToolbar'), annotationToggleBtn: $('annotationToggleBtn'), annotationCloseBtn: $('annotationCloseBtn'), annotationColor: $('annotationColor'), annotationWidth: $('annotationWidth'), annotationUndoBtn: $('annotationUndoBtn'), annotationRedoBtn: $('annotationRedoBtn'), annotationClearBtn: $('annotationClearBtn'), annotationStatus: $('annotationStatus'), annotationClearModal: $('annotationClearModal'), annotationClearCancelBtn: $('annotationClearCancelBtn'), annotationClearConfirmBtn: $('annotationClearConfirmBtn'), noteEditorModal: $('noteEditorModal'), noteEditorTitle: $('noteEditorTitle'), noteTypeChooser: $('noteTypeChooser'), noteEditorFields: $('noteEditorFields'), noteAssociation: $('noteAssociation'), noteTitleInput: $('noteTitleInput'), noteRichEditor: $('noteRichEditor'), noteQuotePreview: $('noteQuotePreview'), noteCollectionInput: $('noteCollectionInput'), noteTagsInput: $('noteTagsInput'), notePinnedInput: $('notePinnedInput'), noteEditorCancelBtn: $('noteEditorCancelBtn'), noteEditorSaveBtn: $('noteEditorSaveBtn') }; const tabs = []; let tabSeq = 0; let touchSeq = 0; let activeId = 0; let theme = 'light'; let uiTheme = 'dark'; let pdfViewMode = 'continuous'; let pdfPageLayout = 'single'; let lastSel = null; let aiRun = null; let lastAiResult = null; let aiReady = false; let aiUnavailableReason = '尚未配置模型,请先在主窗口设置中配置'; let aiSupportsVision = false; let visualContext = null; let visualSelection = null; let ocrRun = null; let toastTimer = 0; let adapterModules = null; let aiConfirmResolve = null; let annotationClearResolve = null; let annotationOpen = false; let annotationTool = 'pan'; let annotationStyle = { color: '#ff4d4f', width: 3 }; let unsubscribeReaderOpen = null; let unsubscribeReaderClose = null; let unsubscribeReaderPurge = null; let unsubscribeReaderShutdown = null; let unsubscribeNotesChanged = null; let unsubscribeUiTheme = null; let unsubscribeAiChanged = null; let noteCollections = []; let noteEditorState = null; let noteRichEditor = null; let pinchGesture = null; function activeTab() { return tabs.find((t) => t.id === activeId) || null; } function toast(msg, isErr) { el.toast.textContent = String(msg || ''); el.toast.classList.toggle('err', !!isErr); el.toast.classList.remove('hidden'); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => el.toast.classList.add('hidden'), isErr ? 6000 : 2600); } function setStatus(msg) { el.statusMsg.textContent = String(msg || ''); } function errText(res, fallback) { if (res && res.error) return String(res.error); return fallback; } function timeText(at) { if (!at) return ''; try { return new Date(at).toLocaleString('zh-CN'); } catch (e) { return ''; } } function loadAdapters() { if (!adapterModules) { adapterModules = Promise.all([ import('./pdf-adapter.mjs'), import('./epub-adapter.mjs'), import('./mobi-adapter.mjs') ]).then(([pdf, epub, mobi]) => ({ pdf: pdf.createPdfAdapter, epub: epub.createEpubAdapter, mobi: mobi.createMobiAdapter, azw: mobi.createMobiAdapter, azw3: mobi.createMobiAdapter })); } return adapterModules; } /* --- 覆盖层 --- */ function showOverlay(tab, title, msg, withBar) { hideOverlay(tab); const box = document.createElement('div'); box.className = 'doc-overlay'; const h = document.createElement('div'); h.className = 'doc-overlay-title'; h.textContent = title; box.appendChild(h); if (msg) { const m = document.createElement('div'); m.className = 'doc-overlay-msg'; m.textContent = msg; box.appendChild(m); } if (withBar) { const track = document.createElement('div'); track.className = 'prog-track'; const fill = document.createElement('div'); fill.className = 'prog-fill'; track.appendChild(fill); box.appendChild(track); } tab.view.appendChild(box); tab.overlay = box; return box; } function overlayProgress(tab, ratio) { if (!tab.overlay) return; const fill = tab.overlay.querySelector('.prog-fill'); if (fill) fill.style.width = `${Math.round(Math.max(0, Math.min(1, ratio)) * 100)}%`; } function showError(tab, msg, retry) { const box = showOverlay(tab, '打开失败', msg); box.classList.add('err'); if (retry) { const btn = document.createElement('button'); btn.className = 'tb-btn'; btn.textContent = '重试'; btn.addEventListener('click', () => { ensureLoaded(tab); }); box.appendChild(btn); } if (tab.format && ['epub', 'mobi', 'azw', 'azw3'].includes(tab.format)) { const external = document.createElement('button'); external.className = 'tb-btn ghost'; external.textContent = '使用系统应用打开'; external.addEventListener('click', async () => { const result = await api.reader.openExternal(tab.entryId, tab.fileIndex); if (!result || !result.ok) toast(errText(result, '外部程序打开失败'), true); }); box.appendChild(external); } } function hideOverlay(tab) { if (tab.overlay) tab.overlay.remove(); tab.overlay = null; } /* --- tab 生命周期 --- */ function makeTab(entryId, fileIndex) { const view = document.createElement('div'); view.className = 'doc-view inactive'; view.dataset.theme = theme; el.docArea.appendChild(view); return { id: ++tabSeq, entryId: String(entryId), fileIndex: Number.isInteger(fileIndex) ? fileIndex : null, title: '正在打开…', format: '', view, overlay: null, host: null, adapter: null, loaded: false, loading: null, needsReload: false, chain: null, frameHooks: null, locator: null, percent: 0, toc: [], scale: 1.2, fontSize: 18, bookmarks: [], notes: [], documentKey: null, annotations: {}, progressTimer: 0, progressSave: Promise.resolve(), annotationSaves: new Map(), savedKey: '', touch: 0, restored: false, closed: false }; } function touch(tab) { tab.touch = ++touchSeq; } function evictExcept(keep) { const pool = tabs.filter((t) => t.adapter); const victims = pool .filter((t) => t !== keep && t.id !== activeId) .sort((a, b) => a.touch - b.touch); let live = pool.length + (keep.adapter ? 0 : 1); while (live > MAX_LIVE_ADAPTERS && victims.length) { const victim = victims.shift(); release(victim, true); live--; setStatus(`已回收「${victim.title}」占用的内存`); } renderTabs(); } function release(tab, reusable) { flushProgress(tab); detachFrame(tab); if (tab.progressTimer) { clearTimeout(tab.progressTimer); tab.progressTimer = 0; } if (tab.adapter) { try { tab.adapter.setLocatorChangeHandler(null); } catch (e) { /* ignore */ } try { tab.adapter.setAnnotationStateHandler(null); } catch (e) { /* ignore */ } try { if (tab.adapter.setTouchGestureHandler) tab.adapter.setTouchGestureHandler(null); } catch (e) { /* ignore */ } try { tab.adapter.destroy(); } catch (e) { /* ignore */ } } tab.adapter = null; tab.loaded = false; tab.loading = null; tab.chain = null; tab.toc = []; tab.host = null; tab.overlay = null; tab.view.textContent = ''; tab.needsReload = !!reusable; if (reusable) showOverlay(tab, '内容已释放', '为控制内存占用,这本书的解析结果已回收。点击此标签可重新加载。'); } function buildHost(tab) { Array.from(tab.view.children).forEach((c) => { if (c !== tab.overlay) c.remove(); }); if (tab.format === 'pdf') { const host = document.createElement('div'); host.className = 'host-pdf'; tab.view.appendChild(host); tab.host = host; } else { const scroll = document.createElement('div'); scroll.className = 'epub-scroll'; const host = document.createElement('div'); host.className = 'host-epub'; scroll.appendChild(host); tab.view.appendChild(scroll); tab.host = host; } if (tab.overlay) tab.view.appendChild(tab.overlay); } function ensureLoaded(tab) { if (tab.closed) return Promise.resolve(false); if (tab.loaded && tab.adapter) return Promise.resolve(true); if (tab.loading) return tab.loading; tab.loading = doLoad(tab).finally(() => { tab.loading = null; }); return tab.loading; } async function openPdfRangeSource(entryId, fileIndex) { const opened = await api.reader.rangeOpen(entryId, fileIndex); if (!opened || !opened.ok || !opened.data) { throw new Error(errText(opened, '无法创建 PDF 分段读取会话')); } const data = opened.data; if (!data.sessionId || !Number.isSafeInteger(data.size) || data.size <= 0 || !Number.isInteger(data.chunkSize) || data.chunkSize <= 0) { throw new Error('PDF 分段读取会话响应无效'); } let closed = false; return { kind: 'range', size: data.size, chunkSize: data.chunkSize, async read(begin, end) { if (closed) throw new Error('PDF 分段读取会话已关闭'); const result = await api.reader.rangeRead(data.sessionId, begin, end); if (!result || !result.ok || !result.data) { throw new Error(errText(result, 'PDF 分段读取失败')); } return result.data; }, async close() { if (closed) return; closed = true; try { await api.reader.rangeClose(data.sessionId); } catch (error) { /* window may be closing */ } } }; } async function doLoad(tab) { tab.view.textContent = ''; tab.overlay = null; tab.needsReload = false; showOverlay(tab, '正在打开…', '', true); const idx = tab.fileIndex === null ? undefined : tab.fileIndex; let meta; try { meta = await api.reader.meta(tab.entryId, idx); } catch (e) { showError(tab, `无法读取书籍信息:${(e && e.message) || e}`, true); return false; } if (!meta || !meta.ok) { showError(tab, errText(meta, '无法读取书籍信息'), true); return false; } if (tab.closed) return false; const info = meta.data; tab.title = String(info.title || '未命名'); tab.format = String(info.format || '').toLowerCase(); tab.fileIndex = Number.isInteger(info.fileIndex) ? info.fileIndex : 0; tab.documentKey = typeof info.documentKey === 'string' ? info.documentKey : null; tab.bookmarks = (info.state && info.state.bookmarks) || []; tab.notes = (info.state && info.state.notes) || []; const stored = info.state && info.state.progress; renderTabs(); syncTitle(); if (tab === activeTab()) { renderBookmarks(); renderAnnotations(); renderNotes(); } evictExcept(tab); let factories; try { factories = await loadAdapters(); } catch (e) { showError(tab, `阅读组件加载失败:${(e && e.message) || e}`, true); return false; } const factory = factories[tab.format]; if (!factory) { showError(tab, `暂不支持在阅读器中打开 .${tab.format || 'unknown'} 文件`, true); return false; } const adapter = factory(tab.format); let source; try { if (tab.format === 'pdf') { source = await openPdfRangeSource(tab.entryId, idx); } else { const bytesRes = await api.reader.bytes(tab.entryId, idx); if (!bytesRes || !bytesRes.ok) { throw new Error(errText(bytesRes, '读取文件失败')); } source = bytesRes.data; } await adapter.load(source, { onProgress: (p) => overlayProgress(tab, p), onError: (error) => { if (tab.closed) return; const message = (error && error.message) || 'PDF 分段读取失败'; if (tab === activeTab()) setStatus(message); toast(message, true); } }); if (tab.format === 'pdf') { const savedAnnotations = await api.reader.getAnnotations(tab.entryId, tab.fileIndex); if (!savedAnnotations || !savedAnnotations.ok) { throw new Error(errText(savedAnnotations, '无法读取 PDF 批注')); } tab.annotations = savedAnnotations.data && savedAnnotations.data.pages ? JSON.parse(JSON.stringify(savedAnnotations.data.pages)) : {}; adapter.setAnnotations(savedAnnotations.data); } } catch (e) { try { adapter.destroy(); } catch (err) { /* ignore */ } if (source && source.kind === 'range') source.close().catch(() => {}); showError(tab, (e && e.message) || '文件解析失败', true); return false; } // 解析期间 tab 可能已被关闭,此时不能把 adapter 挂回去,否则它永远等不到 destroy if (tab.closed) { try { adapter.destroy(); } catch (e) { /* ignore */ } return false; } tab.adapter = adapter; tab.loaded = true; touch(tab); buildHost(tab); adapter.setLocatorChangeHandler((locator, percent) => { tab.locator = locator; tab.percent = Number(percent) || 0; if (tab === activeTab()) syncStatus(); scheduleProgress(tab); }); if (adapter.setTouchGestureHandler) { adapter.setTouchGestureHandler((type, event) => handlePinchTouch(tab, type, event, true)); } if (tab.format === 'pdf') { if (adapter.setViewMode) adapter.setViewMode(pdfViewMode); if (adapter.setPageLayout) adapter.setPageLayout(pdfPageLayout); adapter.setAnnotationChangeHandler((page, data) => { updateAnnotationIndex(tab, page, data); saveAnnotationPage(tab, page, data); }); adapter.setAnnotationStateHandler((state) => { if (tab === activeTab()) syncAnnotationState(state); }); adapter.setAnnotationTool(annotationOpen ? annotationTool : 'text-select'); adapter.setAnnotationStyle(annotationStyle); } const start = tab.locator || (stored && stored.locator) || null; const first = !tab.restored && stored && stored.locator; await renderAt(tab, start); try { tab.toc = await adapter.toc(); } catch (e) { tab.toc = []; } hideOverlay(tab); if (tab === activeTab()) { renderToc(); renderAnnotations(); syncStatus(); } if (first && tab.adapter) { tab.restored = true; toast(`已恢复到上次阅读位置:${tab.adapter.locatorLabel(tab.locator)}`); } return true; } function renderOpts(tab) { if (tab.format === 'pdf') return { scale: tab.scale, theme }; return { fontSize: tab.fontSize, theme, lineHeight: 1.7 }; } // renderTo 不能并发(PDF 会重挂容器、EPUB 会重写 iframe 文档),串成队列 function renderAt(tab, locator) { const run = () => doRender(tab, locator); tab.chain = (tab.chain || Promise.resolve()).then(run, run); return tab.chain; } async function doRender(tab, locator) { if (!tab.adapter || !tab.host) return; try { const r = await tab.adapter.renderTo(tab.host, locator, renderOpts(tab)); tab.locator = r.locator; tab.percent = Number(r.percent) || 0; } catch (e) { setStatus(`渲染失败:${(e && e.message) || e}`); toast(`渲染失败:${(e && e.message) || e}`, true); return; } if (tab.format !== 'pdf') attachFrame(tab); if (tab === activeTab()) syncStatus(); scheduleProgress(tab); } async function openBook(entryId, fileIndex, locator) { const id = String(entryId || '').trim(); if (!id) return; const requestedFileIndex = Number.isInteger(fileIndex) ? fileIndex : null; const exist = tabs.find((t) => t.entryId === id && (requestedFileIndex == null || t.fileIndex === requestedFileIndex)); if (exist) { await activate(exist.id); if (locator) await jumpTo(exist, locator); return; } const tab = makeTab(id, fileIndex); if (locator && typeof locator === 'object') tab.locator = locator; tabs.push(tab); renderTabs(); await activate(tab.id); } function subscribeWindowCommands() { unsubscribeReaderOpen = api.reader.onOpenEntry((data) => { if (!data || !data.entryId) return; openBook( data.entryId, Number.isInteger(data.fileIndex) ? data.fileIndex : undefined, data.locator && typeof data.locator === 'object' ? data.locator : null ); }); unsubscribeReaderClose = api.reader.onCloseEntry((entryId) => { tabs.filter((item) => item.entryId === String(entryId)) .map((tab) => tab.id) .reverse() .forEach(closeTab); }); unsubscribeReaderPurge = api.reader.onPurgeEntry(async (data) => { const entryId = data && data.entryId ? String(data.entryId) : ''; const ids = tabs.filter((tab) => tab.entryId === entryId).map((tab) => tab.id).reverse(); for (const id of ids) await closeTab(id); }); unsubscribeReaderShutdown = api.reader.onPrepareClose(drainAllTabWrites); } function subscribeNoteChanges() { unsubscribeNotesChanged = api.reader.onNotesChanged(async (data) => { await refreshNoteCollections(); const entryId = data && data.entryId ? String(data.entryId) : ''; const targets = entryId ? tabs.filter((tab) => tab.entryId === entryId) : tabs.slice(); await Promise.all(targets.map(async (tab) => { try { const state = await api.reader.getState(tab.entryId, tab.documentKey); if (state && state.ok && state.data) tab.notes = state.data.notes || []; } catch (e) { /* 下次刷新时重试 */ } })); renderNotes(); }); } async function activate(id) { const tab = tabs.find((t) => t.id === id); if (!tab) return; const prev = activeTab(); if (visualSelection) cancelVisualSelection(); if (visualContext && Number(visualContext.source.tabId) !== tab.id) clearVisualContext(false); if (prev && prev !== tab) { flushProgress(prev); prev.view.classList.add('inactive'); } activeId = tab.id; tab.view.classList.remove('inactive'); touch(tab); lastSel = null; hideSelBar(); setStatus(''); renderTabs(); syncTitle(); renderToc(); renderBookmarks(); renderAnnotations(); renderNotes(); syncStatus(); syncEmpty(); const wasLoaded = tab.loaded && tab.adapter; await ensureLoaded(tab); // 隐藏期间容器没有尺寸,EPUB 的偏移与 PDF 的可视页都失准;重回前台补一次渲染 if (wasLoaded && tab.id === activeId) await renderAt(tab, tab.locator); } async function drainTabWrites(tab) { if (tab.chain) await Promise.allSettled([tab.chain]); if (tab.adapter && tab.adapter.flushAnnotations) tab.adapter.flushAnnotations(); flushProgress(tab); while (true) { const progress = tab.progressSave; const annotations = Array.from(tab.annotationSaves.values()); await Promise.allSettled([progress, ...annotations].filter(Boolean)); if (!tab.progressTimer && progress === tab.progressSave && tab.annotationSaves.size === 0) break; flushProgress(tab); } } async function drainAllTabWrites() { tabs.forEach((tab) => { tab.closed = true; }); for (const tab of tabs) await drainTabWrites(tab); } async function closeTab(id) { const i = tabs.findIndex((t) => t.id === id); if (i < 0) return; const tab = tabs[i]; if (tab.closed) return; if (visualSelection) cancelVisualSelection(); if (visualContext && Number(visualContext.source.tabId) === tab.id) clearVisualContext(false); tab.closed = true; await drainTabWrites(tab); const currentIndex = tabs.indexOf(tab); if (currentIndex < 0) return; release(tab, false); tab.view.remove(); tabs.splice(currentIndex, 1); if (activeId === id) { activeId = 0; const next = tabs[Math.min(currentIndex, tabs.length - 1)]; if (next) { await activate(next.id); return; } } renderTabs(); syncTitle(); renderToc(); renderBookmarks(); renderAnnotations(); renderNotes(); syncStatus(); syncEmpty(); } function syncEmpty() { el.docEmpty.classList.toggle('hidden', tabs.length > 0); } function syncTitle() { const tab = activeTab(); el.bookTitle.textContent = tab ? tab.title : '未打开书籍'; } /* --- 进度写入 --- */ function scheduleProgress(tab) { if (tab.closed || !tab.loaded || !tab.locator) return; if (tab.progressTimer) clearTimeout(tab.progressTimer); tab.progressTimer = setTimeout(() => { tab.progressTimer = 0; writeProgress(tab); }, PROGRESS_DELAY); } function flushProgress(tab) { if (tab.progressTimer) { clearTimeout(tab.progressTimer); tab.progressTimer = 0; } return writeProgress(tab); } function writeProgress(tab) { if (!tab.locator) return tab.progressSave; const key = `${JSON.stringify(tab.locator)}|${tab.percent.toFixed(4)}`; if (key === tab.savedKey) return tab.progressSave; tab.savedKey = key; const save = tab.progressSave.catch(() => {}).then(() => ( api.reader.setProgress(tab.entryId, tab.documentKey, tab.locator, tab.percent) )) .then((res) => { if (res && res.ok === false) { tab.savedKey = ''; setStatus(`进度保存失败:${errText(res, '未知错误')}`); } }) .catch((e) => { tab.savedKey = ''; setStatus(`进度保存失败:${(e && e.message) || e}`); }); tab.progressSave = save; return save; } /* --- tab 条 --- */ function renderTabs() { el.docTabs.textContent = ''; tabs.forEach((tab) => { const item = document.createElement('div'); item.className = 'doctab' + (tab.id === activeId ? ' active' : ''); item.dataset.tabId = String(tab.id); item.title = tab.needsReload ? `${tab.title}(内容已释放,点击重新加载)` : tab.title; const name = document.createElement('span'); name.className = 'doctab-name'; name.textContent = tab.title; item.appendChild(name); if (tab.format) { const fmt = document.createElement('span'); fmt.className = 'doctab-fmt'; fmt.textContent = tab.format; item.appendChild(fmt); } const close = document.createElement('button'); close.className = 'doctab-close'; close.title = '关闭'; close.textContent = '\u2715'; close.addEventListener('click', (e) => { e.stopPropagation(); closeTab(tab.id); }); item.appendChild(close); item.addEventListener('click', () => { if (tab.id === activeId) { if (tab.needsReload) ensureLoaded(tab); return; } activate(tab.id); }); el.docTabs.appendChild(item); }); } /* --- 目录 --- */ function tocCurrent(tab) { if (!tab.toc.length || !tab.locator) return -1; let idx = -1; tab.toc.forEach((t, i) => { const l = t.locator || {}; if (tab.format === 'pdf') { if ((l.page || 1) <= (tab.locator.page || 1)) idx = i; } else if ((l.chapter || 0) <= (tab.locator.chapter || 0)) idx = i; }); return idx; } function renderToc() { el.tocList.textContent = ''; const tab = activeTab(); if (!tab) { el.tocList.appendChild(emptyHint('打开书籍后这里显示目录')); return; } if (tab.needsReload) { el.tocList.appendChild(emptyHint('内容已释放,点击标签重新加载')); return; } if (!tab.loaded) { el.tocList.appendChild(emptyHint('正在加载…')); return; } if (!tab.toc.length) { el.tocList.appendChild(emptyHint('这本书没有内嵌目录')); return; } const cur = tocCurrent(tab); tab.toc.forEach((t, i) => { const btn = document.createElement('button'); btn.className = 'toc-item' + (i === cur ? ' current' : ''); btn.style.paddingLeft = `${8 + Math.min(4, t.depth || 0) * 12}px`; btn.textContent = t.label || '未命名'; btn.title = t.label || ''; btn.addEventListener('click', () => renderAt(tab, t.locator)); el.tocList.appendChild(btn); }); } function markToc() { const tab = activeTab(); if (!tab || !tab.toc.length) return; const cur = tocCurrent(tab); Array.from(el.tocList.children).forEach((node, i) => { if (node.classList && node.classList.contains('toc-item')) node.classList.toggle('current', i === cur); }); } function emptyHint(text) { const d = document.createElement('div'); d.className = 'list-empty'; d.textContent = text; return d; } /* --- 状态栏 --- */ function saveAnnotationPage(tab, page, data) { const previous = tab.annotationSaves.get(page) || Promise.resolve(); const next = previous.catch(() => {}).then(() => ( api.reader.setAnnotationPage(tab.entryId, tab.fileIndex, page, data) )).then((res) => { if (!res || !res.ok) throw new Error(errText(res, '保存 PDF 批注失败')); if (tab.annotations[page]) { tab.annotations[page].updatedAt = (res.data && res.data.updatedAt) || Date.now(); if (tab === activeTab()) renderAnnotations(); } }); tab.annotationSaves.set(page, next); next.catch((e) => { if (tab === activeTab()) toast(`保存批注失败:${(e && e.message) || e}`, true); }).finally(() => { if (tab.annotationSaves.get(page) === next) tab.annotationSaves.delete(page); }); } function updateAnnotationIndex(tab, page, data) { const objects = data && Array.isArray(data.objects) ? data.objects : []; if (objects.length) { tab.annotations[page] = { objects: JSON.parse(JSON.stringify(objects)), updatedAt: Date.now() }; } else { delete tab.annotations[page]; } if (tab === activeTab()) renderAnnotations(); } function annotationKinds(objects) { const labels = { pen: '画笔', highlight: '高亮', rectangle: '矩形', text: '文本' }; const found = new Set(); for (const object of objects || []) { const kind = String((object && (object.annotationKind || object.kind)) || '').toLowerCase(); if (labels[kind]) found.add(labels[kind]); } return [...found].join('、'); } function renderAnnotations() { el.annotationList.textContent = ''; const tab = activeTab(); if (!tab) { el.annotationList.appendChild(emptyHint('打开 PDF 后这里显示标注页面')); return; } if (tab.format !== 'pdf') { el.annotationList.appendChild(emptyHint('重排图书暂不支持页面标注')); return; } const pages = Object.entries(tab.annotations || {}) .map(([page, data]) => ({ page: Number(page), data })) .filter((item) => Number.isInteger(item.page) && item.data && Array.isArray(item.data.objects) && item.data.objects.length) .sort((a, b) => a.page - b.page); if (!pages.length) { el.annotationList.appendChild(emptyHint('还没有标注。\n使用上方批注工具在 PDF 页面中添加内容。')); return; } for (const item of pages) { const kinds = annotationKinds(item.data.objects); el.annotationList.appendChild(listItem({ label: `第 ${item.page} 页`, text: `${item.data.objects.length} 项标注${kinds ? ` · ${kinds}` : ''}`, at: item.data.updatedAt, onJump: () => jumpTo(tab, { kind: 'pdf', page: item.page }) })); } } function syncAnnotationState(state) { const value = state || { page: 1, count: 0, canUndo: false, canRedo: false }; el.annotationUndoBtn.disabled = !value.canUndo; el.annotationRedoBtn.disabled = !value.canRedo; el.annotationClearBtn.disabled = !value.count; el.annotationStatus.textContent = `第 ${value.page || 1} 页 · ${value.count || 0} 项`; } function syncAnnotationUi() { const tab = activeTab(); const available = !!(tab && tab.format === 'pdf' && tab.adapter); el.annotationToggleBtn.classList.toggle('hidden', !available); el.annotationToolbar.classList.toggle('hidden', !available || !annotationOpen); document.querySelectorAll('[data-annotation-tool]').forEach((button) => { button.classList.toggle('active', button.dataset.annotationTool === annotationTool); }); if (!available) return; try { tab.adapter.setAnnotationTool(annotationOpen ? annotationTool : 'text-select'); } catch (e) { /* ignore */ } try { tab.adapter.setAnnotationStyle(annotationStyle); } catch (e) { /* ignore */ } } function setAnnotationTool(tool) { annotationTool = String(tool || 'pan'); hideSelBar(); syncAnnotationUi(); } function applyAnnotationStyle() { syncAnnotationUi(); const tab = activeTab(); if (!tab || tab.format !== 'pdf' || !tab.adapter) return; try { tab.adapter.setAnnotationStyle(annotationStyle, true); } catch (e) { /* ignore */ } } async function annotationCommand(command) { const tab = activeTab(); if (!tab || tab.format !== 'pdf' || !tab.adapter) return; try { await tab.adapter.annotationCommand(command); } catch (e) { toast(`批注操作失败:${(e && e.message) || e}`, true); } } function closeAnnotationClear(accepted) { if (!annotationClearResolve) return; const resolve = annotationClearResolve; annotationClearResolve = null; el.annotationClearModal.classList.add('hidden'); resolve(!!accepted); } function confirmAnnotationClear() { if (annotationClearResolve) return Promise.resolve(false); el.annotationClearModal.classList.remove('hidden'); requestAnimationFrame(() => el.annotationClearCancelBtn.focus()); return new Promise((resolve) => { annotationClearResolve = resolve; }); } function syncStatus() { const tab = activeTab(); const ready = !!(tab && tab.adapter && tab.locator); const pdfReady = !!(tab && tab.format === 'pdf'); el.pdfViewControls.classList.toggle('hidden', !pdfReady); el.pdfViewMode.value = pdfViewMode; el.pdfPageLayout.value = pdfPageLayout; el.posLabel.textContent = ready ? tab.adapter.locatorLabel(tab.locator) : '—'; const pct = ready ? tab.percent : 0; el.pctLabel.textContent = `${Math.round(pct * 100)}%`; el.progressRange.value = String(Math.round(pct * 1000)); el.progressRange.disabled = !ready; el.prevBtn.disabled = !ready; el.nextBtn.disabled = !ready; el.zoomInBtn.disabled = !ready; el.zoomOutBtn.disabled = !ready; el.fitWidthBtn.classList.toggle('hidden', !pdfReady); el.fitWidthBtn.disabled = !ready || !pdfReady; el.addBookmarkBtn.disabled = !ready; if (!tab) el.zoomLabel.textContent = '—'; else if (tab.format !== 'pdf') el.zoomLabel.textContent = `${tab.fontSize}px`; else el.zoomLabel.textContent = `${Math.round(tab.scale * 100)}%`; markToc(); syncAnnotationUi(); } function applyPdfViewPreference(kind, value, persist = true) { if (kind === 'mode') { pdfViewMode = value === 'paged' ? 'paged' : 'continuous'; el.pdfViewMode.value = pdfViewMode; } else { pdfPageLayout = value === 'auto' ? 'auto' : 'single'; el.pdfPageLayout.value = pdfPageLayout; } tabs.forEach((tab) => { if (tab.format !== 'pdf' || !tab.adapter) return; try { if (kind === 'mode' && tab.adapter.setViewMode) tab.adapter.setViewMode(pdfViewMode); if (kind === 'layout' && tab.adapter.setPageLayout) tab.adapter.setPageLayout(pdfPageLayout); } catch (error) { if (tab === activeTab()) toast(`PDF 版式切换失败:${error.message || error}`, true); } }); if (persist) { const key = kind === 'mode' ? 'reader.pdfViewMode' : 'reader.pdfPageLayout'; const saved = kind === 'mode' ? pdfViewMode : pdfPageLayout; Promise.resolve(api.settings.set(key, saved)).catch(() => { /* 偏好丢失不影响阅读 */ }); } syncStatus(); } function step(dir) { const tab = activeTab(); if (!tab || !tab.adapter || !tab.locator) return; const next = dir > 0 ? tab.adapter.nextLocator(tab.locator) : tab.adapter.prevLocator(tab.locator); if (!next) { toast(dir > 0 ? '已经是最后一页了' : '已经是第一页了'); return; } renderAt(tab, next); } function touchDistance(touches) { const dx = touches[0].clientX - touches[1].clientX; const dy = touches[0].clientY - touches[1].clientY; return Math.max(1, Math.hypot(dx, dy)); } function touchMidpoint(touches) { return { x: (touches[0].clientX + touches[1].clientX) / 2, y: (touches[0].clientY + touches[1].clientY) / 2 }; } function stopPinchEvent(event, stopPropagation = true) { if (event.cancelable) event.preventDefault(); if (stopPropagation) event.stopImmediatePropagation(); } function previewPinch(gesture) { const host = gesture.tab.host; if (!host) return; const rect = host.getBoundingClientRect(); const point = gesture.previewPoint || gesture.midpoint; host.classList.add('pinch-preview'); host.style.transformOrigin = `${point.x - rect.left}px ${point.y - rect.top}px`; host.style.transform = `scale(${gesture.previewRatio})`; } function clearPinchPreview(gesture) { if (!gesture || !gesture.tab.host) return; gesture.tab.host.classList.remove('pinch-preview'); gesture.tab.host.style.transform = ''; gesture.tab.host.style.transformOrigin = ''; } async function finishPinch(gesture) { clearPinchPreview(gesture); if (!gesture || !gesture.tab.adapter || gesture.tab.closed) return; if (gesture.suspendPromise) await gesture.suspendPromise; if (gesture.suspendError) throw gesture.suspendError; const tab = gesture.tab; if (tab.format === 'pdf') tab.scale = gesture.value; else tab.fontSize = gesture.value; await renderAt(tab, gesture.anchor && tab.format === 'pdf' ? { kind: 'pdf', page: gesture.anchor.page } : (gesture.anchor ? { kind: tab.format, chapter: gesture.anchor.chapter, offset: gesture.anchor.offset } : tab.locator)); if (tab.adapter && tab.adapter.restorePinchAnchor && gesture.anchor) { tab.adapter.restorePinchAnchor(gesture.anchor); tab.locator = tab.format === 'pdf' ? { kind: 'pdf', page: gesture.anchor.page } : { kind: tab.format, chapter: gesture.anchor.chapter, offset: gesture.anchor.offset }; scheduleProgress(tab); } if (tab.adapter && tab.adapter.resumeTouchGesture) tab.adapter.resumeTouchGesture(); syncStatus(); } function handlePinchTouch(tab, type, event, fromFrame = false) { if (!tab || tab !== activeTab() || !tab.loaded || !tab.adapter) return; const touches = event.touches || []; if (type === 'touchstart' && touches.length >= 2 && !pinchGesture) { const midpoint = touchMidpoint(touches); const anchor = tab.adapter.capturePinchAnchor ? tab.adapter.capturePinchAnchor(midpoint.x, midpoint.y) : null; pinchGesture = { tab, fromFrame, startDistance: touchDistance(touches), startValue: tab.format === 'pdf' ? tab.scale : tab.fontSize, value: tab.format === 'pdf' ? tab.scale : tab.fontSize, previewRatio: 1, midpoint, previewPoint: fromFrame && anchor && Number.isFinite(anchor.outerX) ? { x: anchor.outerX, y: anchor.outerY } : midpoint, anchor, suspendPromise: null }; if (tab.adapter.suspendTouchGesture) { const gesture = pinchGesture; gesture.suspendPromise = Promise.resolve(tab.adapter.suspendTouchGesture()) .catch((error) => { gesture.suspendError = error; }); } stopPinchEvent(event); hideSelBar(); return; } const gesture = pinchGesture; if (!gesture || gesture.tab !== tab || gesture.fromFrame !== fromFrame) return; if (type === 'touchmove' && touches.length >= 2) { const ratio = touchDistance(touches) / gesture.startDistance; gesture.value = tab.format === 'pdf' ? Math.max(SCALE_MIN, Math.min(SCALE_MAX, gesture.startValue * ratio)) : Math.round(Math.max(FONT_MIN, Math.min(FONT_MAX, gesture.startValue * ratio))); gesture.previewRatio = gesture.value / gesture.startValue; gesture.midpoint = touchMidpoint(touches); if (!fromFrame) gesture.previewPoint = gesture.midpoint; previewPinch(gesture); stopPinchEvent(event); if (tab === activeTab()) { el.zoomLabel.textContent = tab.format === 'pdf' ? `${Math.round(gesture.value * 100)}%` : `${gesture.value}px`; } return; } if ((type === 'touchend' || type === 'touchcancel') && touches.length === 0) { pinchGesture = null; stopPinchEvent(event, false); window.setTimeout(() => finishPinch(gesture).catch((error) => { clearPinchPreview(gesture); if (tab.adapter && tab.adapter.resumeTouchGesture) tab.adapter.resumeTouchGesture(); toast(`缩放失败:${error && error.message ? error.message : error}`, true); }), 0); return; } if (touches.length < 2) stopPinchEvent(event); } function bindTouchGestures() { const listener = (type) => (event) => { const tab = activeTab(); if (!tab || !tab.view.contains(event.target)) return; handlePinchTouch(tab, type, event, false); }; document.addEventListener('touchstart', listener('touchstart'), { capture: true, passive: false }); document.addEventListener('touchmove', listener('touchmove'), { capture: true, passive: false }); document.addEventListener('touchend', listener('touchend'), { capture: true, passive: false }); document.addEventListener('touchcancel', listener('touchcancel'), { capture: true, passive: false }); } function zoom(dir) { const tab = activeTab(); if (!tab || !tab.adapter) return; if (tab.format !== 'pdf') { const next = Math.max(FONT_MIN, Math.min(FONT_MAX, tab.fontSize + dir * 2)); if (next === tab.fontSize) return; tab.fontSize = next; } else { const next = dir > 0 ? (PDF_SCALES.find((value) => value > tab.scale + 0.001) || SCALE_MAX) : (PDF_SCALES.findLast((value) => value < tab.scale - 0.001) || SCALE_MIN); if (next === tab.scale) return; tab.scale = next; } syncStatus(); renderAt(tab, tab.locator); } async function fitPdfWidth() { const tab = activeTab(); if (!tab || tab.format !== 'pdf' || !tab.adapter || !tab.adapter.fitWidthScale) return; const raw = Number(tab.adapter.fitWidthScale(tab.locator)); if (!Number.isFinite(raw) || raw <= 0) return; const next = Math.max(SCALE_MIN, Math.min(SCALE_MAX, raw)); const anchor = tab.adapter.captureViewAnchor ? tab.adapter.captureViewAnchor() : null; const page = anchor && anchor.page ? anchor.page : (tab.locator && tab.locator.page ? tab.locator.page : 1); tab.scale = next; syncStatus(); await renderAt(tab, { kind: 'pdf', page }); if (anchor && tab.adapter.restorePinchAnchor) { tab.adapter.restorePinchAnchor(anchor); tab.locator = { kind: 'pdf', page: anchor.page }; scheduleProgress(tab); } syncStatus(); } function applyTheme(next) { theme = next; tabs.forEach((t) => { t.view.dataset.theme = next; }); const tab = activeTab(); if (tab && tab.adapter) renderAt(tab, tab.locator); Promise.resolve(api.settings.set('reader.theme', next)).catch(() => { /* 主题偏好丢失不影响阅读 */ }); } function applyUiTheme(next, persist = true) { uiTheme = next === 'light' ? 'light' : 'dark'; document.documentElement.dataset.uiTheme = uiTheme; const targetLabel = uiTheme === 'light' ? '切换到暗色主题' : '切换到明亮主题'; el.uiThemeBtn.title = targetLabel; el.uiThemeBtn.setAttribute('aria-label', targetLabel); if (persist) { Promise.resolve(api.ui.setTheme(uiTheme)) .catch(() => { /* 界面主题偏好丢失不影响阅读 */ }); } } /* --- 书签 / 笔记 --- */ async function addBookmark(sel) { const tab = activeTab(); if (!tab || !tab.adapter || !tab.locator) { toast('还没有可加书签的位置', true); return; } const locator = (sel && sel.locator) || tab.locator; const mark = { locator, label: tab.adapter.locatorLabel(locator), excerpt: (sel && sel.excerpt) || '', documentKey: tab.documentKey }; let res; try { res = await api.reader.addBookmark(tab.entryId, mark); } catch (e) { toast(`加书签失败:${(e && e.message) || e}`, true); return; } if (!res || !res.ok) { toast(`加书签失败:${errText(res, '未知错误')}`, true); return; } tab.bookmarks = tab.bookmarks.concat([res.data]); if (tab === activeTab()) renderBookmarks(); toast(`已添加书签:${mark.label}`); } function renderBookmarks() { el.bookmarkList.textContent = ''; const tab = activeTab(); if (!tab) { el.bookmarkList.appendChild(emptyHint('打开书籍后可以添加书签')); return; } if (!tab.bookmarks.length) { el.bookmarkList.appendChild(emptyHint('还没有书签。\n可以选中正文后点「加书签」,或按 Ctrl+B 记下当前位置。')); return; } tab.bookmarks.slice().reverse().forEach((b) => { el.bookmarkList.appendChild(listItem({ label: b.label || '未命名位置', text: '', quote: b.excerpt || '', at: b.at, onJump: () => jumpTo(tab, b.locator), onDelete: () => removeBookmark(tab, b.id) })); }); } async function removeBookmark(tab, markId) { let res; try { res = await api.reader.removeBookmark(tab.entryId, markId); } catch (e) { toast(`删除失败:${(e && e.message) || e}`, true); return; } if (!res || !res.ok) { toast(`删除失败:${errText(res, '未知错误')}`, true); return; } tab.bookmarks = tab.bookmarks.filter((b) => b.id !== markId); if (tab === activeTab()) renderBookmarks(); } function renderNotes() { el.noteList.textContent = ''; const tab = activeTab(); if (!tab) { el.noteList.appendChild(emptyHint('打开书籍后这里显示笔记')); return; } const collectionId = el.noteCollectionFilter.value; const notes = tab.notes.filter((note) => !collectionId || note.collectionId === collectionId); if (!notes.length) { el.noteList.appendChild(emptyHint(collectionId ? '当前笔记本中没有这本书的笔记' : '还没有笔记。\n可以人工输入、摘录正文,或保存 AI 生成内容。')); return; } notes.slice().sort((a, b) => { if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1; return (b.updatedAt || b.at || 0) - (a.updatedAt || a.at || 0); }).forEach((n) => { const source = n.source || (n.kind === 'ai' ? 'ai' : 'manual'); const sourceLabel = source === 'ai' ? 'AI' : (source === 'selection' ? '摘录' : '人工'); const noteType = n.noteType || (n.canvasContent ? 'canvas' : 'reading'); const typeLabel = noteType === 'canvas' ? '画布笔记' : '读书笔记'; const collection = noteCollections.find((item) => item.id === n.collectionId); el.noteList.appendChild(listItem({ label: `${n.pinned ? '置顶 · ' : ''}${n.title || labelOf(tab, n.locator)}`, kind: collection ? `${typeLabel} · ${sourceLabel} · ${collection.name}` : `${typeLabel} · ${sourceLabel}`, text: n.text || '', richContent: n.richContent || null, canvasContent: n.canvasContent || null, quote: n.quote || '', tags: n.tags || [], at: n.updatedAt || n.at, onJump: n.locator ? () => jumpTo(tab, n.locator) : null, onEdit: () => openNoteEditor(n), onDelete: () => removeNote(tab, n.id) })); }); } function tagsFromInput(value) { return [...new Set(String(value || '').split(/[,,]/).map((tag) => tag.trim()).filter(Boolean))]; } function upsertTabNote(tab, note) { if (!tab || !note || !note.id) return; const index = tab.notes.findIndex((item) => item.id === note.id); if (index >= 0) tab.notes.splice(index, 1, note); else tab.notes.push(note); } function fillCollectionSelect(select, firstLabel) { const previous = select.value; select.textContent = ''; const first = document.createElement('option'); first.value = ''; first.textContent = firstLabel; select.appendChild(first); noteCollections.forEach((collection) => { const option = document.createElement('option'); option.value = collection.id; option.textContent = collection.name; select.appendChild(option); }); if (Array.from(select.options).some((option) => option.value === previous)) select.value = previous; } async function refreshNoteCollections() { let res; try { res = await api.reader.listCollections(); } catch (e) { res = null; } noteCollections = res && res.ok && Array.isArray(res.data) ? res.data : []; fillCollectionSelect(el.noteCollectionFilter, '全部笔记本'); fillCollectionSelect(el.noteCollectionInput, '未分类'); } function closeNoteEditor() { if (noteRichEditor) noteRichEditor.destroy(); noteRichEditor = null; noteEditorState = null; el.noteTypeChooser.classList.add('hidden'); el.noteEditorFields.classList.remove('hidden'); el.noteEditorSaveBtn.classList.remove('hidden'); el.noteEditorModal.classList.remove('canvas-note-modal'); el.noteEditorModal.classList.add('hidden'); } function openNoteTypeChooser() { if (!activeTab()) { toast('请先打开一本书', true); return; } if (noteRichEditor) noteRichEditor.destroy(); noteRichEditor = null; noteEditorState = null; el.noteEditorTitle.textContent = '选择笔记类型'; el.noteTypeChooser.classList.remove('hidden'); el.noteEditorFields.classList.add('hidden'); el.noteEditorSaveBtn.classList.add('hidden'); el.noteEditorModal.classList.remove('canvas-note-modal', 'hidden'); el.noteTypeChooser.querySelector('[data-note-type="reading"]')?.focus(); } function openNoteEditor(note, selection, requestedType) { const tab = activeTab(); if (!tab) { toast('请先打开一本书', true); return; } const selected = selection && String(selection.text || '').trim() ? selection : null; const editing = note && note.id ? note : null; const noteType = editing ? (editing.noteType || (editing.canvasContent ? 'canvas' : 'reading')) : (selected ? 'reading' : requestedType); if (noteType !== 'reading' && noteType !== 'canvas') { openNoteTypeChooser(); return; } noteEditorState = { tab, noteId: editing ? editing.id : null, noteType, locator: editing ? editing.locator : ((selected && selected.locator) || tab.locator), quote: editing ? String(editing.quote || '') : (selected ? String(selected.text || '') : ''), context: editing ? String(editing.context || '') : (selected ? String(selected.excerpt || '') : ''), source: editing ? (editing.source || (editing.kind === 'ai' ? 'ai' : 'manual')) : (selected ? 'selection' : 'manual') }; el.noteEditorTitle.textContent = editing ? (noteType === 'canvas' ? '编辑画布笔记' : '编辑读书笔记') : (selected ? '为摘录添加读书笔记' : (noteType === 'canvas' ? '新建画布笔记' : '新建读书笔记')); el.noteTypeChooser.classList.add('hidden'); el.noteEditorFields.classList.remove('hidden'); el.noteEditorSaveBtn.classList.remove('hidden'); el.noteEditorModal.classList.toggle('canvas-note-modal', noteType === 'canvas'); el.noteAssociation.textContent = `关联当前书籍:${tab.title || '未命名书籍'}`; el.noteTitleInput.value = editing ? String(editing.title || '') : ''; if (noteRichEditor) noteRichEditor.destroy(); noteRichEditor = window.MixedNote.mount( el.noteRichEditor, noteType === 'reading' && editing ? (editing.richContent || window.RichNote.fromText(editing.text)) : null, noteType === 'canvas' && editing ? (editing.canvasContent || null) : null, { noteType, onError: (message) => toast(message, true) } ); el.noteCollectionInput.value = editing && editing.collectionId ? editing.collectionId : ''; el.noteTagsInput.value = editing && Array.isArray(editing.tags) ? editing.tags.join(', ') : ''; el.notePinnedInput.checked = !!(editing && editing.pinned); el.noteQuotePreview.textContent = noteEditorState.quote; el.noteQuotePreview.classList.toggle('hidden', !noteEditorState.quote); el.noteEditorModal.classList.remove('hidden'); requestAnimationFrame(() => (selected ? noteRichEditor.focus() : el.noteTitleInput.focus())); } async function saveNoteEditor() { if (!noteEditorState) return; const state = noteEditorState; if (noteRichEditor) await noteRichEditor.ready(); const richContent = noteRichEditor ? noteRichEditor.richContent() : null; const canvasContent = noteRichEditor ? noteRichEditor.canvasContent() : null; const payload = { noteType: state.noteType, title: el.noteTitleInput.value.trim(), ...(state.noteType === 'canvas' ? { canvasContent } : { text: noteRichEditor ? noteRichEditor.text().trim() : '', richContent }), quote: state.quote, context: state.context, source: state.source, locator: state.locator || null, documentKey: state.tab.documentKey, fileIndex: state.tab.fileIndex, collectionId: el.noteCollectionInput.value || null, tags: tagsFromInput(el.noteTagsInput.value), pinned: el.notePinnedInput.checked }; if (!payload.quote && !(noteRichEditor && noteRichEditor.hasContent())) { toast('请输入笔记内容', true); return; } el.noteEditorSaveBtn.disabled = true; let res; try { res = state.noteId ? await api.reader.updateNote(state.tab.entryId, state.noteId, payload) : await api.reader.addNote(state.tab.entryId, payload); } catch (e) { res = { ok: false, error: (e && e.message) || String(e) }; } finally { el.noteEditorSaveBtn.disabled = false; } if (!res || !res.ok) { toast(`保存失败:${errText(res, '未知错误')}`, true); return; } upsertTabNote(state.tab, res.data); closeNoteEditor(); showPane('notes'); renderNotes(); toast(state.noteId ? '笔记已更新' : '笔记已保存'); } async function saveExcerpt(tab, selection) { let res; try { res = await api.reader.addNote(tab.entryId, { noteType: 'reading', title: '', text: '', quote: String(selection.text || ''), context: String(selection.excerpt || ''), source: 'selection', locator: selection.locator || tab.locator, documentKey: tab.documentKey, fileIndex: tab.fileIndex, collectionId: null, tags: [] }); } catch (e) { res = { ok: false, error: (e && e.message) || String(e) }; } if (!res || !res.ok) { toast(`摘录失败:${errText(res, '未知错误')}`, true); return; } upsertTabNote(tab, res.data); showPane('notes'); renderNotes(); toast('摘录已保存'); } async function removeNote(tab, noteId) { let res; try { res = await api.reader.removeNote(tab.entryId, noteId); } catch (e) { toast(`删除失败:${(e && e.message) || e}`, true); return; } if (!res || !res.ok) { toast(`删除失败:${errText(res, '未知错误')}`, true); return; } tab.notes = tab.notes.filter((n) => n.id !== noteId); if (tab === activeTab()) renderNotes(); } function labelOf(tab, locator) { if (!locator) return '未定位'; if (tab.adapter) { try { return tab.adapter.locatorLabel(locator); } catch (e) { /* 退回下面的粗略描述 */ } } if (locator.kind === 'pdf') return `第 ${locator.page} 页`; if (['epub', 'mobi', 'azw', 'azw3'].includes(locator.kind)) { return `第 ${(locator.chapter || 0) + 1} 章`; } return '未定位'; } function listItem(o) { const box = document.createElement('div'); box.className = 'list-item'; const head = document.createElement('div'); head.className = 'list-item-head'; const label = document.createElement('button'); label.className = 'list-item-label'; label.textContent = o.label; label.title = o.onJump ? '跳转到此位置' : o.label; if (o.onJump) label.addEventListener('click', o.onJump); else label.disabled = true; head.appendChild(label); if (o.kind) { const k = document.createElement('span'); k.className = 'list-item-kind'; k.textContent = o.kind; head.appendChild(k); } if (o.onEdit) { const edit = document.createElement('button'); edit.className = 'list-item-edit'; edit.title = '编辑'; edit.textContent = '\u270e'; edit.addEventListener('click', (event) => { event.stopPropagation(); o.onEdit(); }); head.appendChild(edit); } if (o.onDelete) { const del = document.createElement('button'); del.className = 'list-item-del'; del.title = '删除'; del.textContent = '\u2715'; del.addEventListener('click', (event) => { event.stopPropagation(); o.onDelete(); }); head.appendChild(del); } box.appendChild(head); if (o.quote) { const q = document.createElement('div'); q.className = 'list-item-quote'; q.textContent = o.quote; box.appendChild(q); } if (o.text || o.richContent) { const t = document.createElement('div'); t.className = 'list-item-text'; window.RichNote.render(t, o.richContent, o.text); box.appendChild(t); } if (o.canvasContent && Array.isArray(o.canvasContent.pages)) { const summary = document.createElement('div'); summary.className = 'list-item-canvas-summary'; const pdfPages = o.canvasContent.pages.filter((page) => ( page.background && page.background.type === 'pdf' )).length; summary.textContent = `自由画布 · ${o.canvasContent.pages.length} 页` + (pdfPages ? ` · ${pdfPages} 页 PDF 底版` : ''); box.appendChild(summary); } if (o.tags && o.tags.length) { const tags = document.createElement('div'); tags.className = 'list-item-tags'; tags.textContent = o.tags.map((tag) => `#${tag}`).join(' '); box.appendChild(tags); } const time = document.createElement('div'); time.className = 'list-item-time'; time.textContent = timeText(o.at); box.appendChild(time); return box; } async function jumpTo(tab, locator) { if (tab !== activeTab()) await activate(tab.id); if (!tab.adapter) { const ok = await ensureLoaded(tab); if (!ok) return; } renderAt(tab, locator); } /* --- 划选工具条 --- */ function selectionRect(tab) { if (tab.format !== 'pdf') { const frame = tab.host && tab.host.querySelector('iframe'); if (!frame) return null; let r = null; try { const s = frame.contentDocument.getSelection(); if (!s || !s.rangeCount) return null; r = s.getRangeAt(0).getBoundingClientRect(); } catch (e) { return null; } const f = frame.getBoundingClientRect(); return { left: f.left + r.left, top: f.top + r.top, bottom: f.top + r.bottom, width: r.width }; } const s = window.getSelection(); if (!s || !s.rangeCount) return null; const r = s.getRangeAt(0).getBoundingClientRect(); return { left: r.left, top: r.top, bottom: r.bottom, width: r.width }; } function isReaderControlTarget(target) { return !!(target && typeof target.closest === 'function' && target.closest([ 'button', 'select', 'input', 'textarea', 'a[href]', '[role="button"]', '.titlebar', '.doctabs', '.annotation-toolbar', '.pane-head', '.pane-tabs', '.pane-toolbar', '.statusbar', '.modal-actions' ].join(','))); } function clearDocumentSelection() { const tab = activeTab(); if (!tab) return; if (tab.format === 'pdf') { const selection = window.getSelection(); if (selection) selection.removeAllRanges(); return; } const frame = tab.host && tab.host.querySelector('iframe'); try { const selection = frame && frame.contentDocument.getSelection(); if (selection) selection.removeAllRanges(); } catch (e) { /* 跨文档状态变化不影响控件操作 */ } } function handleSelection(e) { // 点在工具条上时不能重算:按下按钮会折叠选区,一旦隐藏工具条 click 就再也不会派发 if (e && e.target && el.selBar.contains(e.target)) return; if (e && isReaderControlTarget(e.target)) { hideSelBar(); return; } const tab = activeTab(); if (!tab || !tab.adapter) { hideSelBar(); return; } let sel = null; try { sel = tab.adapter.getSelection(); } catch (e) { sel = null; } if (!sel || !String(sel.text || '').trim()) { hideSelBar(); return; } lastSel = sel; refreshCostHint(); const rect = selectionRect(tab); if (!rect) { hideSelBar(); return; } el.selBar.classList.remove('hidden'); const w = el.selBar.offsetWidth; const h = el.selBar.offsetHeight; let left = rect.left + rect.width / 2 - w / 2; left = Math.max(8, Math.min(window.innerWidth - w - 8, left)); let top = rect.top - h - 8; if (top < 84) top = Math.min(window.innerHeight - h - 8, rect.bottom + 8); el.selBar.style.left = `${Math.round(left)}px`; el.selBar.style.top = `${Math.round(top)}px`; } function hideSelBar() { el.selBar.classList.add('hidden'); } function currentSelection() { const tab = activeTab(); if (tab && tab.adapter) { let live = null; try { live = tab.adapter.getSelection(); } catch (e) { live = null; } if (live && String(live.text || '').trim()) { lastSel = live; return live; } } return lastSel; } async function onSelAction(action) { const tab = activeTab(); const sel = currentSelection(); hideSelBar(); if (!tab || !sel) { toast('请先在正文中选中文本', true); return; } if (action === 'copy') { try { await api.copy(sel.text); toast('已复制'); } catch (e) { toast('复制失败', true); } return; } if (action === 'bookmark') { addBookmark(sel); return; } if (action === 'excerpt') { saveExcerpt(tab, sel); return; } if (action === 'note') { openNoteEditor(null, sel); return; } showPane('ai'); // 划选触发的翻译/解释只发选中内容,不夹带整章 if (!await confirmCost('selection', sel.text)) return; runAi({ task: action, text: sel.text, quote: sel.excerpt || sel.text, locator: sel.locator, entryId: tab.entryId }); } /* --- AI --- */ function isVisualScope(scope) { return scope === 'page-image' || scope === 'region-image'; } function visualKindOf(scope) { return scope === 'region-image' ? 'region' : 'page'; } function formatImageBytes(bytes) { const value = Math.max(0, Number(bytes) || 0); return value >= 1024 * 1024 ? `${(value / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`; } function renderVisualContext() { if (!visualContext) { el.aiVisualCard.classList.add('hidden'); el.aiVisualPreview.removeAttribute('src'); return; } const image = visualContext.image; el.aiVisualPreview.src = `data:${image.mimeType};base64,${image.base64}`; el.aiVisualLabel.textContent = visualContext.kind === 'region' ? '框选区域' : '当前页面'; el.aiVisualMeta.textContent = [ visualContext.source.label, `${image.width} × ${image.height}`, formatImageBytes(image.bytes) ].filter(Boolean).join(' · '); const availability = ocrAvailability(); if (visualContext.ocr.status === 'ready') { el.aiOcrStatus.textContent = `OCR:${visualContext.ocr.text.length.toLocaleString()} 字`; } else if (visualContext.ocr.status === 'pending') { el.aiOcrStatus.textContent = 'OCR:正在识别…'; } else if (visualContext.ocr.status === 'error') { el.aiOcrStatus.textContent = `OCR:${visualContext.ocr.error || '识别失败'}`; } else { el.aiOcrStatus.textContent = availability.available ? 'OCR:尚未识别' : 'OCR:未安装引擎'; } el.aiOcrBtn.disabled = !availability.available || visualContext.ocr.status === 'pending'; el.aiOcrBtn.title = availability.available ? '识别当前图像中的文字' : 'OCR 引擎将在后续版本接入'; el.aiVisualCard.classList.remove('hidden'); } function clearVisualContext(resetScope = true) { if (ocrRun) { ocrRun.abort(); ocrRun = null; } visualContext = null; renderVisualContext(); if (resetScope && isVisualScope(currentScope())) { el.aiScope.value = 'selection'; try { api.settings.set('reader.aiScope', 'selection'); } catch (e) { /* ignore */ } } refreshCostHint(); } function visualSource(tab, label) { return { tabId: tab.id, entryId: tab.entryId, fileIndex: tab.fileIndex, documentKey: tab.documentKey, label: String(label || '') }; } async function captureReaderRect(rect) { const result = await api.reader.captureRect({ x: Math.max(0, Math.floor(rect.left)), y: Math.max(0, Math.floor(rect.top)), width: Math.max(2, Math.floor(rect.width)), height: Math.max(2, Math.floor(rect.height)) }); if (!result || !result.ok || !result.data) { throw new Error(errText(result, '无法截取当前阅读区域')); } return result.data; } function setVisualContext(context) { visualContext = context; renderVisualContext(); refreshCostHint(); return context; } async function runVisualOcr() { if (!visualContext || ocrRun) return; const contextId = visualContext.id; const controller = new AbortController(); ocrRun = controller; visualContext = { ...visualContext, ocr: { ...visualContext.ocr, status: 'pending', error: null } }; renderVisualContext(); try { const result = await recognizeOcr(visualContext.image, { signal: controller.signal }); if (visualContext && visualContext.id === contextId) { visualContext = withOcrResult(visualContext, result); renderVisualContext(); refreshCostHint(); } } catch (error) { if (error && error.name === 'AbortError') return; if (visualContext && visualContext.id === contextId) { visualContext = { ...visualContext, ocr: { ...visualContext.ocr, status: 'error', include: false, error: (error && error.message) || String(error) } }; renderVisualContext(); } } finally { if (ocrRun === controller) ocrRun = null; } } async function captureCurrentPageVisual(tab) { if (!tab || !tab.adapter) throw new Error('请先打开一本书'); el.aiCost.textContent = '正在获取页面图像…'; let captured; if (tab.format === 'pdf' && tab.adapter.captureVisual) { captured = await tab.adapter.captureVisual(tab.locator); } else { const viewport = tab.adapter.visualViewportRect && tab.adapter.visualViewportRect(); if (!viewport || !viewport.rect) throw new Error('当前阅读区域不可见'); captured = { image: await captureReaderRect(viewport.rect), crop: null, locator: viewport.locator || tab.locator, label: viewport.label || tab.adapter.locatorLabel(tab.locator) }; } return setVisualContext(createVisualContext({ kind: 'page', format: tab.format, source: visualSource(tab, captured.label), locator: captured.locator || tab.locator, crop: captured.crop, image: captured.image })); } function selectionBounds(info, viewRect) { const rect = info.rect; const left = Math.max(viewRect.left, rect.left, 0); const top = Math.max(viewRect.top, rect.top, 0); const right = Math.min(viewRect.right, rect.left + rect.width, window.innerWidth); const bottom = Math.min(viewRect.bottom, rect.top + rect.height, window.innerHeight); return { left, top, right, bottom }; } function setSelectionBox(session, rect) { session.selection = rect; session.box.style.left = `${rect.left - session.viewRect.left}px`; session.box.style.top = `${rect.top - session.viewRect.top}px`; session.box.style.width = `${rect.width}px`; session.box.style.height = `${rect.height}px`; session.box.classList.remove('hidden'); } function pageForVisualPoint(tab, x, y) { if (tab.format === 'pdf' && tab.adapter.visualPageAtPoint) { return tab.adapter.visualPageAtPoint(x, y); } const viewport = tab.adapter.visualViewportRect && tab.adapter.visualViewportRect(); if (!viewport || !viewport.rect) return null; const rect = viewport.rect; if (x < rect.left || x > rect.left + rect.width || y < rect.top || y > rect.top + rect.height) return null; return { ...viewport, width: rect.width, height: rect.height }; } function settleVisualSelection(context) { const session = visualSelection; if (!session) return; visualSelection = null; window.removeEventListener('keydown', session.keyListener, true); session.overlay.remove(); session.resolve(context || null); refreshCostHint(); } function cancelVisualSelection() { settleVisualSelection(null); } async function confirmVisualSelection(session) { if (visualSelection !== session || !session.selection || !session.page || session.capturing) return; session.capturing = true; session.confirmBtn.disabled = true; session.redoBtn.disabled = true; session.cancelBtn.disabled = true; try { let captured; if (session.tab.format === 'pdf' && session.tab.adapter.captureVisual) { const pageRect = session.page.rect; const selected = session.selection; const crop = { x: (selected.left - pageRect.left) / pageRect.width * session.page.width, y: (selected.top - pageRect.top) / pageRect.height * session.page.height, width: selected.width / pageRect.width * session.page.width, height: selected.height / pageRect.height * session.page.height }; captured = await session.tab.adapter.captureVisual(session.page.locator, crop); } else { session.overlay.classList.add('visual-select-capturing'); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); captured = { image: await captureReaderRect(session.selection), crop: { x: session.selection.left - session.page.rect.left, y: session.selection.top - session.page.rect.top, width: session.selection.width, height: session.selection.height }, locator: session.page.locator || session.tab.locator, label: session.page.label || session.tab.adapter.locatorLabel(session.tab.locator) }; } const context = setVisualContext(createVisualContext({ kind: 'region', format: session.tab.format, source: visualSource(session.tab, captured.label), locator: captured.locator || session.tab.locator, crop: captured.crop, image: captured.image })); settleVisualSelection(context); } catch (error) { session.overlay.classList.remove('visual-select-capturing'); session.capturing = false; session.confirmBtn.disabled = false; session.redoBtn.disabled = false; session.cancelBtn.disabled = false; toast(`截图失败:${(error && error.message) || error}`, true); } } function beginVisualSelection(tab) { if (visualSelection) return visualSelection.promise; const overlay = document.createElement('div'); overlay.className = 'visual-select-overlay'; const hint = document.createElement('div'); hint.className = 'visual-select-hint'; hint.textContent = '在单个页面内拖动框选,可拖动选框或使用四角调整,Esc 取消'; const box = document.createElement('div'); box.className = 'visual-select-box hidden'; for (const handle of ['nw', 'ne', 'se', 'sw']) { const node = document.createElement('span'); node.className = `visual-select-handle handle-${handle}`; node.dataset.handle = handle; box.appendChild(node); } const actions = document.createElement('div'); actions.className = 'visual-select-actions hidden'; const confirmBtn = document.createElement('button'); confirmBtn.className = 'tb-btn sm'; confirmBtn.textContent = '使用此区域'; const redoBtn = document.createElement('button'); redoBtn.className = 'tb-btn ghost sm'; redoBtn.textContent = '重新框选'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'tb-btn ghost sm'; cancelBtn.textContent = '取消'; actions.append(confirmBtn, redoBtn, cancelBtn); overlay.append(hint, box, actions); tab.view.appendChild(overlay); let resolvePromise; const session = { tab, overlay, box, actions, confirmBtn, redoBtn, cancelBtn, viewRect: tab.view.getBoundingClientRect(), page: null, bounds: null, selection: null, gesture: null, capturing: false, resolve: (value) => resolvePromise(value), keyListener: null, promise: null }; session.promise = new Promise((resolve) => { resolvePromise = resolve; }); session.keyListener = (event) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); cancelVisualSelection(); return; } if (['ArrowLeft', 'ArrowRight', 'PageUp', 'PageDown', 'Home', 'End'].includes(event.key)) { event.preventDefault(); event.stopPropagation(); } }; visualSelection = session; window.addEventListener('keydown', session.keyListener, true); overlay.addEventListener('wheel', (event) => event.preventDefault(), { passive: false }); function clampPoint(event) { return { x: Math.max(session.bounds.left, Math.min(session.bounds.right, event.clientX)), y: Math.max(session.bounds.top, Math.min(session.bounds.bottom, event.clientY)) }; } overlay.addEventListener('pointerdown', (event) => { if (event.button !== 0 || event.target.closest('button')) return; event.preventDefault(); if (box.contains(event.target) && session.selection) { session.gesture = { mode: event.target.dataset.handle ? 'resize' : 'move', handle: event.target.dataset.handle || '', startX: event.clientX, startY: event.clientY, original: { ...session.selection } }; } else { const page = pageForVisualPoint(tab, event.clientX, event.clientY); if (!page) { toast('请从可见页面内部开始框选', true); return; } const bounds = selectionBounds(page, session.viewRect); if (bounds.right - bounds.left < 12 || bounds.bottom - bounds.top < 12) { toast('当前页面的可见区域过小', true); return; } session.page = page; session.bounds = bounds; const point = clampPoint(event); session.gesture = { mode: 'draw', startX: point.x, startY: point.y }; session.actions.classList.add('hidden'); setSelectionBox(session, { left: point.x, top: point.y, width: 1, height: 1 }); } overlay.setPointerCapture(event.pointerId); }); overlay.addEventListener('pointermove', (event) => { const gesture = session.gesture; if (!gesture || !session.bounds) return; event.preventDefault(); if (gesture.mode === 'draw') { const point = clampPoint(event); setSelectionBox(session, { left: Math.min(gesture.startX, point.x), top: Math.min(gesture.startY, point.y), width: Math.abs(point.x - gesture.startX), height: Math.abs(point.y - gesture.startY) }); return; } const dx = event.clientX - gesture.startX; const dy = event.clientY - gesture.startY; const original = gesture.original; if (gesture.mode === 'move') { const left = Math.max(session.bounds.left, Math.min( session.bounds.right - original.width, original.left + dx )); const top = Math.max(session.bounds.top, Math.min( session.bounds.bottom - original.height, original.top + dy )); setSelectionBox(session, { left, top, width: original.width, height: original.height }); return; } let left = original.left; let top = original.top; let right = original.left + original.width; let bottom = original.top + original.height; if (gesture.handle.includes('w')) left = Math.max(session.bounds.left, Math.min(right - 12, original.left + dx)); if (gesture.handle.includes('e')) right = Math.min(session.bounds.right, Math.max(left + 12, right + dx)); if (gesture.handle.includes('n')) top = Math.max(session.bounds.top, Math.min(bottom - 12, original.top + dy)); if (gesture.handle.includes('s')) bottom = Math.min(session.bounds.bottom, Math.max(top + 12, bottom + dy)); setSelectionBox(session, { left, top, width: right - left, height: bottom - top }); }); const finishPointer = (event) => { if (!session.gesture) return; session.gesture = null; try { if (overlay.hasPointerCapture(event.pointerId)) overlay.releasePointerCapture(event.pointerId); } catch (e) { /* ignore */ } if (!session.selection || session.selection.width < 12 || session.selection.height < 12) { session.selection = null; session.box.classList.add('hidden'); session.actions.classList.add('hidden'); toast('框选区域太小,请重新框选', true); return; } session.actions.classList.remove('hidden'); }; overlay.addEventListener('pointerup', finishPointer); overlay.addEventListener('pointercancel', finishPointer); confirmBtn.addEventListener('click', () => confirmVisualSelection(session)); redoBtn.addEventListener('click', () => { session.page = null; session.bounds = null; session.selection = null; box.classList.add('hidden'); actions.classList.add('hidden'); }); cancelBtn.addEventListener('click', cancelVisualSelection); return session.promise; } async function prepareVisualContext(scope, force = false) { const tab = activeTab(); if (!tab || !tab.adapter) throw new Error('请先打开一本书'); if (!aiSupportsVision) throw new Error('请先在 AI 设置中启用“图像输入”'); const kind = visualKindOf(scope); if ( !force && visualContext && visualContext.kind === kind && Number(visualContext.source.tabId) === tab.id ) return visualContext; visualContext = null; renderVisualContext(); return kind === 'region' ? beginVisualSelection(tab) : captureCurrentPageVisual(tab); } let unsubDelta = null; let aiRenderTimer = 0; function renderAiOutput(source, forceScroll = false) { const stickToBottom = forceScroll || el.aiOutput.scrollHeight - el.aiOutput.scrollTop - el.aiOutput.clientHeight < 48; try { window.AiMarkdown.mount(el.aiOutput, source); } catch (error) { el.aiOutput.classList.add('ai-output-plain'); el.aiOutput.textContent = String(source || ''); } if (stickToBottom) el.aiOutput.scrollTop = el.aiOutput.scrollHeight; } function flushAiOutput(source, forceScroll = false) { if (aiRenderTimer) { clearTimeout(aiRenderTimer); aiRenderTimer = 0; } renderAiOutput(source, forceScroll); } function scheduleAiOutput(source) { if (aiRenderTimer) return; aiRenderTimer = window.setTimeout(() => { aiRenderTimer = 0; renderAiOutput(source()); }, 80); } function subscribeDelta() { unsubDelta = api.ai.onDelta((d) => { if (!aiRun || !d || d.runId !== aiRun.runId) return; const piece = String(d.delta || ''); aiRun.text += piece; scheduleAiOutput(() => (aiRun ? aiRun.text : '')); }); } function syncVisionOptions() { if (!el.aiScope) return; el.aiScope.querySelectorAll('option[data-requires-vision]').forEach((option) => { option.disabled = !aiSupportsVision; }); if (!aiSupportsVision && isVisualScope(el.aiScope.value)) clearVisualContext(true); } async function refreshAiStatus() { let res; try { res = await api.ai.status(); } catch (e) { aiReady = false; aiSupportsVision = false; syncVisionOptions(); aiUnavailableReason = `无法读取模型配置:${(e && e.message) || e}`; el.aiStatus.textContent = aiUnavailableReason; el.aiStatus.classList.add('warn'); return; } if (!res || !res.ok) { aiReady = false; aiSupportsVision = false; syncVisionOptions(); aiUnavailableReason = errText(res, '无法读取模型配置'); el.aiStatus.textContent = aiUnavailableReason; el.aiStatus.classList.add('warn'); return; } const s = res.data; aiReady = s.ready === undefined ? !!(s.hasKey || s.isLocal) : !!s.ready; aiSupportsVision = !!s.vision; syncVisionOptions(); if (!aiReady) { if (s.modelConfigured) { aiUnavailableReason = s.keyState === 'unreadable' ? '模型已配置,但已保存的 API Key 无法读取,请在主窗口重新输入 API Key' : '模型已配置,但尚缺 API Key,请在主窗口设置中填写'; } else { aiUnavailableReason = '尚未保存模型配置,请先在主窗口设置接口地址与模型名称'; } el.aiStatus.textContent = aiUnavailableReason; el.aiStatus.classList.add('warn'); return; } aiUnavailableReason = ''; el.aiStatus.classList.remove('warn'); const extra = s.isLocal ? '本地服务' : (s.persistent ? '密钥已加密保存' : '密钥仅本次运行有效'); const protocol = { anthropic: 'Anthropic', 'openai-responses': 'OpenAI Responses', 'chat-completions': 'OpenAI 兼容' }[s.protocol] || 'OpenAI 兼容'; el.aiStatus.textContent = `${protocol} · ${s.model} · ${s.baseUrl} · ${extra}${aiSupportsVision ? ' · 图像输入' : ''}`; } function aiBusy(busy) { el.aiStopBtn.classList.toggle('hidden', !busy); el.aiSendBtn.disabled = busy; document.querySelectorAll('[data-ai-task]').forEach((b) => { b.disabled = busy; }); el.aiOutput.classList.toggle('streaming', busy); } async function runAi(job) { if (aiRun) { toast('正在生成中,请先停止当前任务', true); return; } if (!aiReady) { toast(aiUnavailableReason || 'AI 模型尚未就绪', true); return; } const text = String(job.text || ''); const visuals = Array.isArray(job.visualContexts) ? job.visualContexts.filter(Boolean) : []; if (!text.trim() && !visuals.length && job.task !== 'ask') { toast('没有可处理的上下文', true); return; } const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; aiRun = { runId, text: '', entryId: job.entryId || (activeTab() && activeTab().entryId), locator: job.locator || null, quote: job.quote || '', task: job.task || null }; flushAiOutput(''); el.aiError.classList.add('hidden'); el.aiError.textContent = ''; el.aiSaveBtn.classList.add('hidden'); el.aiCopyBtn.classList.add('hidden'); if (aiRun.quote) { el.aiQuote.textContent = aiRun.quote; el.aiQuote.classList.remove('hidden'); } else { el.aiQuote.classList.add('hidden'); el.aiQuote.textContent = ''; } aiBusy(true); let res; try { res = await api.ai.run({ runId, task: job.task, text, question: job.question || '', visualContexts: visuals }); } catch (e) { res = { ok: false, error: (e && e.message) || String(e) }; } const done = aiRun; aiRun = null; aiBusy(false); if (res && res.ok) { const full = String((res.data && res.data.text) || done.text || ''); flushAiOutput(full); done.text = full; if (full.trim()) { el.aiSaveBtn.classList.remove('hidden'); el.aiCopyBtn.classList.remove('hidden'); lastAiResult = done; } return; } if (res && res.cancelled) { flushAiOutput(done.text); el.aiError.textContent = '已停止生成。'; el.aiError.classList.remove('hidden'); if (done.text.trim()) { lastAiResult = done; el.aiSaveBtn.classList.remove('hidden'); el.aiCopyBtn.classList.remove('hidden'); } return; } flushAiOutput(done.text); el.aiError.textContent = errText(res, 'AI 请求失败'); el.aiError.classList.remove('hidden'); } const CONFIRM_CHARS = 4000; function scopeName(scope) { const names = { selection: '选中文本', page: '当前页', document: '全文', 'page-image': '当前页面(图像)', 'region-image': '框选区域(图像)' }; return names[scope] || '上下文'; } function estimateTokens(text) { const s = String(text || ''); if (!s) return 0; const cjk = (s.match(/[\u4e00-\u9fff\u3040-\u30ff]/g) || []).length; return Math.ceil(cjk + (s.length - cjk) / 3.5); } async function resolveContext(tab, scope) { if (isVisualScope(scope)) { try { const context = await prepareVisualContext(scope); if (!context) return { error: '尚未选择图像区域' }; const visual = toAiVisualContext(context); if (!visual) return { error: '图像上下文已被移除' }; return { text: '', locator: context.locator || tab.locator, quote: `图像上下文:${context.source.label || scopeName(scope)}`, visualContexts: [visual] }; } catch (error) { return { error: `获取图像失败:${(error && error.message) || error}` }; } } if (scope === 'selection') { const sel = currentSelection(); const text = sel && String(sel.text || '').trim(); if (!text) return { error: '请先在正文中选中文本,或把上下文改为"当前页"/"全文"' }; return { text: sel.text, locator: sel.locator, quote: sel.excerpt || sel.text }; } try { const text = await tab.adapter.textOf(tab.locator, scope === 'page' ? 'page' : 'document'); if (!String(text || '').trim()) { return { error: '当前位置没有可提取的文本,可能是扫描页面或纯图片内容' }; } return { text, locator: tab.locator, quote: '' }; } catch (e) { return { error: `取正文失败:${(e && e.message) || e}` }; } } function closeAiConfirm(accepted) { if (!aiConfirmResolve) return; const resolve = aiConfirmResolve; aiConfirmResolve = null; el.aiConfirmModal.classList.add('hidden'); resolve(!!accepted); } function showAiConfirm(scope, chars, tokens, visualContexts = []) { if (aiConfirmResolve) return Promise.resolve(false); el.aiConfirmScope.textContent = scopeName(scope); if (visualContexts.length) { const image = visualContexts[0].image; const ocrText = visualContexts .map((item) => item.ocr && item.ocr.include ? item.ocr.text : '') .join(''); const totalChars = chars + ocrText.length; const textCost = totalChars ? ` · ${totalChars.toLocaleString()} 字 / 约 ${(tokens + estimateTokens(ocrText)).toLocaleString()} tokens` : ''; el.aiConfirmCost.textContent = image ? `1 张图像 · ${image.width} × ${image.height} · ${formatImageBytes(image.bytes)}${textCost}` : `OCR 文字${textCost}`; el.aiConfirmNotice.textContent = image ? '图像上下文将发送到你配置的模型接口,并可能产生费用。图像只保存在内存中,确认后才会上传。' : 'OCR 文字将发送到你配置的模型接口,并可能产生费用。确认后才会上传。'; } else { el.aiConfirmCost.textContent = `${chars.toLocaleString()} 字 / 约 ${tokens.toLocaleString()} tokens`; el.aiConfirmNotice.textContent = scope === 'document' ? '全文可能超过模型的上下文限制。过长正文会由 PeopleLib 保留首尾并截断后发送,且可能产生费用。只有确认后才会继续。' : '正文将发送到你配置的模型接口,并可能产生费用。PeopleLib 不会自动发送,只有确认后才会继续。'; } el.aiConfirmModal.classList.remove('hidden'); requestAnimationFrame(() => el.aiConfirmSendBtn.focus()); return new Promise((resolve) => { aiConfirmResolve = resolve; }); } async function confirmCost(scope, text, visualContexts = []) { const chars = String(text || '').length; const tokens = estimateTokens(text); if (!visualContexts.length && scope !== 'document' && chars <= CONFIRM_CHARS) return true; return showAiConfirm(scope, chars, tokens, visualContexts); } function currentScope() { return (el.aiScope && el.aiScope.value) || 'selection'; } async function quickAi(task) { const tab = activeTab(); if (!tab || !tab.adapter) { toast('请先打开一本书', true); return; } // 翻译/解释针对选中文本;总结默认跟随上下文选择 const scope = task === 'summarize' ? currentScope() : 'selection'; const ctx = await resolveContext(tab, scope); if (ctx.error) { toast(ctx.error, true); return; } if (!await confirmCost(scope, ctx.text, ctx.visualContexts)) return; runAi({ task, text: ctx.text, quote: ctx.quote, locator: ctx.locator, entryId: tab.entryId, visualContexts: ctx.visualContexts }); } async function askAi() { const tab = activeTab(); if (!tab || !tab.adapter) { toast('请先打开一本书', true); return; } const q = el.aiQuestion.value.trim(); if (!q) { toast('请输入问题', true); return; } const scope = currentScope(); const ctx = await resolveContext(tab, scope); if (ctx.error) { toast(ctx.error, true); return; } if (!await confirmCost(scope, ctx.text, ctx.visualContexts)) return; el.aiQuestion.value = ''; runAi({ task: 'ask', text: ctx.text, question: q, quote: `问:${q}`, locator: ctx.locator, entryId: tab.entryId, visualContexts: ctx.visualContexts }); } // 让用户在点发送之前就看见代价,而不是事后才知道 async function refreshCostHint() { if (!el.aiCost) return; const tab = activeTab(); if (!tab || !tab.adapter) { el.aiCost.textContent = '未打开文档'; return; } const scope = currentScope(); if (isVisualScope(scope)) { if (!aiSupportsVision) { el.aiCost.textContent = '当前模型未启用图像输入'; return; } if ( visualContext && visualContext.kind === visualKindOf(scope) && Number(visualContext.source.tabId) === tab.id ) { const image = visualContext.image; el.aiCost.textContent = `${image.width} × ${image.height} · ${formatImageBytes(image.bytes)}`; } else { el.aiCost.textContent = scope === 'region-image' ? '请选择页面区域' : '尚未获取页面图像'; } return; } if (scope === 'selection') { const sel = currentSelection(); const n = sel ? String(sel.text || '').trim().length : 0; el.aiCost.textContent = n ? `约 ${n.toLocaleString()} 字 / ${estimateTokens(sel.text).toLocaleString()} tokens` : '未选中文本'; return; } try { const text = await tab.adapter.textOf(tab.locator, scope === 'page' ? 'page' : 'document'); const n = String(text || '').length; el.aiCost.textContent = n ? `约 ${n.toLocaleString()} 字 / ${estimateTokens(text).toLocaleString()} tokens${scope === 'document' ? ' · 可能超过模型限制' : ''}` : '无可提取文本'; } catch (e) { el.aiCost.textContent = '无法估算'; } } async function saveAiNote() { if (!lastAiResult || !lastAiResult.text.trim()) { toast('没有可保存的内容', true); return; } const entryId = lastAiResult.entryId || (activeTab() && activeTab().entryId); if (!entryId) { toast('没有可关联的书籍', true); return; } let res; try { res = await api.reader.addNote(entryId, { noteType: 'reading', locator: lastAiResult.locator, text: lastAiResult.text, quote: lastAiResult.quote, source: 'ai', aiTask: lastAiResult.task, documentKey: activeTab() && activeTab().entryId === entryId ? activeTab().documentKey : null, fileIndex: activeTab() && activeTab().entryId === entryId ? activeTab().fileIndex : null }); } catch (e) { toast(`保存失败:${(e && e.message) || e}`, true); return; } if (!res || !res.ok) { toast(`保存失败:${errText(res, '未知错误')}`, true); return; } const tab = tabs.find((t) => t.entryId === entryId); if (tab) { upsertTabNote(tab, res.data); if (tab === activeTab()) renderNotes(); } toast('已保存为笔记'); } /* --- 右侧面板 --- */ function showPane(name) { document.querySelectorAll('.pane-tab').forEach((b) => { b.classList.toggle('active', b.dataset.pane === name); }); ['bookmarks', 'annotations', 'notes', 'ai'].forEach((n) => { $(`pane-${n}`).classList.toggle('hidden', n !== name); }); el.sidePane.classList.remove('collapsed'); if (name === 'annotations') renderAnnotations(); if (name === 'notes') renderNotes(); if (name === 'ai') refreshCostHint(); } /* --- 书库选择 --- */ async function openPicker() { el.pickList.textContent = ''; el.pickModal.classList.remove('hidden'); el.pickList.appendChild(emptyHint('正在读取书库…')); let res; try { res = await api.library.list(); } catch (e) { el.pickList.textContent = ''; el.pickList.appendChild(emptyHint(`读取书库失败:${(e && e.message) || e}`)); return; } el.pickList.textContent = ''; if (!res || !res.ok) { el.pickList.appendChild(emptyHint(errText(res, '读取书库失败'))); return; } const items = (res.data || []).filter((it) => (it.files || []).some((f) => f && /\.(pdf|epub|mobi|azw|azw3)$/i.test(f.path || ''))); if (!items.length) { el.pickList.appendChild(emptyHint('书库里还没有可内置阅读的文件')); return; } items.forEach((it) => { const btn = document.createElement('button'); btn.className = 'pick-item'; const name = document.createElement('span'); name.className = 'pick-item-name'; name.textContent = it.title || '未命名'; btn.appendChild(name); if (tabs.some((t) => t.entryId === String(it.id))) { const tag = document.createElement('span'); tag.className = 'doctab-fmt'; tag.textContent = '已打开'; btn.appendChild(tag); } btn.addEventListener('click', () => { el.pickModal.classList.add('hidden'); openBook(it.id); }); el.pickList.appendChild(btn); }); } /* --- iframe 内的事件 --- */ function attachFrame(tab) { const frame = tab.host && tab.host.querySelector('iframe'); if (!frame) return; let doc = null; try { doc = frame.contentDocument; } catch (e) { return; } if (!doc) return; detachFrame(tab); const onUp = (event) => handleSelection(event); const onDown = (event) => onDocMouseDown(event); doc.addEventListener('mouseup', onUp); doc.addEventListener('keyup', onUp); doc.addEventListener('mousedown', onDown); doc.addEventListener('keydown', onKeyDown); tab.frameHooks = { doc, onUp, onDown }; } function detachFrame(tab) { const h = tab.frameHooks; if (!h) return; tab.frameHooks = null; try { h.doc.removeEventListener('mouseup', h.onUp); h.doc.removeEventListener('keyup', h.onUp); h.doc.removeEventListener('mousedown', h.onDown); h.doc.removeEventListener('keydown', onKeyDown); } catch (e) { /* 文档已被替换,监听器随之消失 */ } } function onDocMouseDown(e) { if (visualSelection && (!e.target || !visualSelection.overlay.contains(e.target))) { cancelVisualSelection(); } if (e.target && el.selBar.contains(e.target)) return; hideSelBar(); if (isReaderControlTarget(e.target)) clearDocumentSelection(); } /* --- 快捷键 --- */ function onKeyDown(e) { if (e.key === 'Escape') { if (aiConfirmResolve) { e.preventDefault(); closeAiConfirm(false); } else if (annotationClearResolve) { e.preventDefault(); closeAnnotationClear(false); } else if (!el.noteEditorModal.classList.contains('hidden')) { e.preventDefault(); closeNoteEditor(); } else { hideSelBar(); } return; } const t = e.target; const tag = t && t.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (e.ctrlKey || e.metaKey) { const k = String(e.key).toLowerCase(); if (annotationOpen && (k === 'z' || k === 'y')) { e.preventDefault(); annotationCommand(k === 'z' && !e.shiftKey ? 'undo' : 'redo'); return; } if (k === 'b') { e.preventDefault(); addBookmark(currentSelection()); } if (k === 'f') e.preventDefault(); return; } if (e.altKey) return; if (annotationOpen && !['pan', 'text-select'].includes(annotationTool)) { if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); annotationCommand('delete'); } return; } if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); step(-1); return; } if (e.key === 'ArrowRight' || e.key === 'PageDown') { e.preventDefault(); step(1); } } /* --- 绑定 --- */ function bind() { el.minBtn.addEventListener('click', () => api.minimize()); el.maxBtn.addEventListener('click', () => api.maximize()); el.closeBtn.addEventListener('click', () => api.close()); el.addTabBtn.addEventListener('click', openPicker); el.emptyOpenBtn.addEventListener('click', openPicker); el.pickCancelBtn.addEventListener('click', () => el.pickModal.classList.add('hidden')); el.pickModal.addEventListener('click', (e) => { if (e.target === el.pickModal) el.pickModal.classList.add('hidden'); }); el.aiConfirmCancelBtn.addEventListener('click', () => closeAiConfirm(false)); el.aiConfirmSendBtn.addEventListener('click', () => closeAiConfirm(true)); el.aiConfirmModal.addEventListener('click', (e) => { if (e.target === el.aiConfirmModal) closeAiConfirm(false); }); el.annotationClearCancelBtn.addEventListener('click', () => closeAnnotationClear(false)); el.annotationClearConfirmBtn.addEventListener('click', () => closeAnnotationClear(true)); el.annotationClearModal.addEventListener('click', (e) => { if (e.target === el.annotationClearModal) closeAnnotationClear(false); }); el.annotationToggleBtn.addEventListener('click', () => { annotationOpen = !annotationOpen; syncAnnotationUi(); }); el.annotationCloseBtn.addEventListener('click', () => { annotationOpen = false; syncAnnotationUi(); }); document.querySelectorAll('[data-annotation-tool]').forEach((button) => { button.addEventListener('click', () => setAnnotationTool(button.dataset.annotationTool)); }); el.annotationColor.addEventListener('change', () => { annotationStyle = { ...annotationStyle, color: el.annotationColor.value }; applyAnnotationStyle(); }); el.annotationWidth.addEventListener('change', () => { annotationStyle = { ...annotationStyle, width: Number(el.annotationWidth.value) || 3 }; applyAnnotationStyle(); }); el.annotationUndoBtn.addEventListener('click', () => annotationCommand('undo')); el.annotationRedoBtn.addEventListener('click', () => annotationCommand('redo')); el.annotationClearBtn.addEventListener('click', async () => { if (await confirmAnnotationClear()) annotationCommand('clear'); }); el.addNoteBtn.addEventListener('click', openNoteTypeChooser); el.noteTypeChooser.querySelectorAll('[data-note-type]').forEach((button) => { button.addEventListener('click', () => openNoteEditor( null, null, button.dataset.noteType )); }); el.noteCollectionFilter.addEventListener('change', renderNotes); el.noteEditorCancelBtn.addEventListener('click', closeNoteEditor); el.noteEditorSaveBtn.addEventListener('click', saveNoteEditor); el.noteEditorModal.addEventListener('click', (e) => { if (e.target === el.noteEditorModal) closeNoteEditor(); }); el.uiThemeBtn.addEventListener('click', () => { applyUiTheme(uiTheme === 'dark' ? 'light' : 'dark'); }); el.tocHideBtn.addEventListener('click', () => el.tocPane.classList.add('collapsed')); el.tocToggleBtn.addEventListener('click', () => el.tocPane.classList.toggle('collapsed')); el.sideHideBtn.addEventListener('click', () => el.sidePane.classList.add('collapsed')); el.sideToggleBtn.addEventListener('click', () => el.sidePane.classList.toggle('collapsed')); document.querySelectorAll('.pane-tab').forEach((b) => { b.addEventListener('click', () => showPane(b.dataset.pane)); }); el.prevBtn.addEventListener('click', () => step(-1)); el.nextBtn.addEventListener('click', () => step(1)); el.zoomInBtn.addEventListener('click', () => zoom(1)); el.zoomOutBtn.addEventListener('click', () => zoom(-1)); el.fitWidthBtn.addEventListener('click', () => { fitPdfWidth().catch((error) => { toast(`适应内容宽度失败:${error && error.message ? error.message : error}`, true); }); }); el.pdfViewMode.addEventListener('change', () => { applyPdfViewPreference('mode', el.pdfViewMode.value); }); el.pdfPageLayout.addEventListener('change', () => { applyPdfViewPreference('layout', el.pdfPageLayout.value); }); el.themeSelect.addEventListener('change', () => applyTheme(el.themeSelect.value)); el.progressRange.addEventListener('input', () => { el.pctLabel.textContent = `${Math.round(Number(el.progressRange.value) / 10)}%`; }); el.progressRange.addEventListener('change', () => { const tab = activeTab(); if (!tab || !tab.adapter) return; const p = Number(el.progressRange.value) / 1000; renderAt(tab, tab.adapter.locatorFromPercent(p)); }); el.addBookmarkBtn.addEventListener('click', () => addBookmark(null)); el.selBar.querySelectorAll('.sel-btn').forEach((b) => { b.addEventListener('click', () => onSelAction(b.dataset.sel)); }); document.querySelectorAll('[data-ai-task]').forEach((b) => { b.addEventListener('click', () => quickAi(b.dataset.aiTask)); }); el.aiSendBtn.addEventListener('click', askAi); el.aiQuestion.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); askAi(); } }); if (el.aiScope) { el.aiScope.addEventListener('change', async () => { const scope = el.aiScope.value; if (visualSelection) cancelVisualSelection(); if (isVisualScope(scope)) { try { await prepareVisualContext(scope); } catch (error) { toast((error && error.message) || String(error), true); } } else if (visualContext) { clearVisualContext(false); } if (scope === 'document') { toast('全文可能超过模型上下文限制,发送前会再次确认;过长内容将保留首尾并截断。'); } try { api.settings.set('reader.aiScope', scope); } catch (e) { /* ignore */ } refreshCostHint(); }); } el.aiVisualReselectBtn.addEventListener('click', async () => { const scope = currentScope(); if (!isVisualScope(scope)) return; try { await prepareVisualContext(scope, true); } catch (error) { toast((error && error.message) || String(error), true); } }); el.aiVisualRemoveBtn.addEventListener('click', () => clearVisualContext(true)); el.aiOcrBtn.addEventListener('click', runVisualOcr); el.aiStopBtn.addEventListener('click', () => { if (aiRun) api.ai.cancel(aiRun.runId); }); el.aiSaveBtn.addEventListener('click', saveAiNote); el.aiCopyBtn.addEventListener('click', async () => { if (!lastAiResult) return; try { await api.copy(lastAiResult.text); toast('已复制'); } catch (e) { toast('复制失败', true); } }); const activateAiLink = async (event) => { if (event.type === 'auxclick' && event.button !== 1) return; const link = event.target && typeof event.target.closest === 'function' ? event.target.closest('a') : null; if (!link || !el.aiOutput.contains(link)) return; event.preventDefault(); const url = window.AiMarkdown && window.AiMarkdown.externalUrl ? window.AiMarkdown.externalUrl(link) : ''; if (!url) { toast('已阻止不安全的链接', true); return; } const result = await api.openExternal(url); if (!result || !result.ok) toast(errText(result, '无法打开链接'), true); }; el.aiOutput.addEventListener('click', activateAiLink); el.aiOutput.addEventListener('auxclick', activateAiLink); el.aiOutput.addEventListener('dragstart', (event) => { const link = event.target && typeof event.target.closest === 'function' ? event.target.closest('a') : null; if (link && el.aiOutput.contains(link)) event.preventDefault(); }); document.addEventListener('mouseup', handleSelection); document.addEventListener('mousedown', onDocMouseDown); document.addEventListener('keydown', onKeyDown); window.addEventListener('resize', () => { hideSelBar(); if (visualSelection) cancelVisualSelection(); }); window.addEventListener('beforeunload', () => { if (unsubDelta) { try { unsubDelta(); } catch (e) { /* ignore */ } } if (unsubscribeReaderOpen) { try { unsubscribeReaderOpen(); } catch (e) { /* ignore */ } } if (unsubscribeReaderClose) { try { unsubscribeReaderClose(); } catch (e) { /* ignore */ } } if (unsubscribeReaderPurge) { try { unsubscribeReaderPurge(); } catch (e) { /* ignore */ } } if (unsubscribeReaderShutdown) { try { unsubscribeReaderShutdown(); } catch (e) { /* ignore */ } } if (unsubscribeNotesChanged) { try { unsubscribeNotesChanged(); } catch (e) { /* ignore */ } } if (unsubscribeUiTheme) { try { unsubscribeUiTheme(); } catch (e) { /* ignore */ } } if (unsubscribeAiChanged) { try { unsubscribeAiChanged(); } catch (e) { /* ignore */ } } if (aiRun) { try { api.ai.cancel(aiRun.runId); } catch (e) { /* ignore */ } } if (aiRenderTimer) clearTimeout(aiRenderTimer); if (visualSelection) cancelVisualSelection(); if (ocrRun) ocrRun.abort(); visualContext = null; tabs.slice().forEach((t) => release(t, false)); }); } async function start() { if (!api || !api.reader) { document.body.textContent = '初始化失败:预加载脚本未生效,无法访问本地接口。'; return; } bind(); bindTouchGestures(); try { const savedUiTheme = await api.ui.getTheme(); applyUiTheme(savedUiTheme && savedUiTheme.ok ? savedUiTheme.data : 'dark', false); } catch (e) { applyUiTheme('dark', false); } unsubscribeUiTheme = api.ui.onThemeChanged((next) => applyUiTheme(next, false)); if (api.ai && api.ai.onChanged) { unsubscribeAiChanged = api.ai.onChanged(() => refreshAiStatus()); } subscribeWindowCommands(); subscribeNoteChanges(); subscribeDelta(); await refreshNoteCollections(); showPane('bookmarks'); syncEmpty(); syncStatus(); renderToc(); renderBookmarks(); renderAnnotations(); renderNotes(); await refreshAiStatus(); try { const savedScope = await api.settings.get('reader.aiScope', 'selection'); const storedScope = savedScope && savedScope.ok ? savedScope.data : 'selection'; const sv = storedScope === 'chapter' ? 'document' : storedScope; if ( el.aiScope && ['selection', 'page', 'document', 'page-image', 'region-image'].includes(sv) && (!isVisualScope(sv) || aiSupportsVision) ) { el.aiScope.value = sv; if (storedScope === 'chapter') api.settings.set('reader.aiScope', 'document').catch(() => {}); } } catch (e) { /* 用默认值 */ } try { const saved = await api.settings.get('reader.theme', 'light'); const v = saved && saved.ok ? saved.data : 'light'; if (v === 'dark' || v === 'sepia' || v === 'light') { theme = v; el.themeSelect.value = v; } } catch (e) { /* 用默认浅色主题 */ } try { const [modeResult, layoutResult] = await Promise.all([ api.settings.get('reader.pdfViewMode', 'continuous'), api.settings.get('reader.pdfPageLayout', 'single') ]); applyPdfViewPreference( 'mode', modeResult && modeResult.ok ? modeResult.data : 'continuous', false ); applyPdfViewPreference( 'layout', layoutResult && layoutResult.ok ? layoutResult.data : 'single', false ); } catch (e) { /* 使用默认 PDF 阅读版式 */ } const query = new URLSearchParams(location.search); const entryId = query.get('entryId'); if (!entryId) { await api.reader.ready(); toast('没有指定要打开的书籍', true); return; } const rawFileIndex = query.get('fileIndex'); const fileIndex = rawFileIndex === null ? NaN : Number(rawFileIndex); let locator = null; try { const rawLocator = query.get('locator'); locator = rawLocator ? JSON.parse(rawLocator) : null; } catch (e) { locator = null; } await openBook( entryId, Number.isInteger(fileIndex) && fileIndex >= 0 ? fileIndex : undefined, locator ); await api.reader.ready(); } start();