在任意网页中阅读EPUB电子书,支持EPUB渲染模式和纯文本模式
// ==UserScript== // @name Fake Reader // @namespace // @version 1.1.0 // @description 在任意网页中阅读EPUB电子书,支持EPUB渲染模式和纯文本模式 // @author kudou61 // @match *://*/* // @grant none // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/epub.min.js // ==/UserScript== /** * Fake Reader - 在网页中阅读EPUB电子书的油猴脚本 * * 功能特性: * - EPUB渲染模式:使用epub.js进行完整渲染 * - 纯文本模式:提取文本内容,按章节或字符数分页 * - 键盘翻页:← → 或 Cmd/Ctrl + [ ] * - 页码跳转:在设置面板输入页码 * - 进度保存:自动保存阅读进度 * - 快捷键:Cmd/Ctrl + B 打开设置面板 */ (function() { 'use strict'; // ============================================================================ // 常量配置 // ============================================================================ const CONSTANTS = { // DOM 元素 ID IDs: { OVERLAY: 'er-overlay', SETTINGS_PANEL: 'er-settings', READER: 'er-reader', TEXT_READER: 'er-text-reader', TEXT_DISPLAY: 'er-text-display', PAGE_CONTENT: 'er-page-content', STYLES: 'er-styles', READING_PROGRESS: 'er-reading-progress' }, // 存储键 STORAGE: { TARGET_DIV: 'epub_reader_target_div', BOOK_PROGRESS: 'epub_reader_progress' }, // 默认配置 CONFIG: { CHARS_PER_PAGE_MIN: 500, CHARS_PER_PAGE_MAX: 5000, EPUB_LOCATION_INTERVAL: 1600, NOTIFICATION_DURATION: 3000 }, // 命名空间(全局变量前缀) NS: 'epubReader' }; // ============================================================================ // 全局状态 // ============================================================================ const State = { // EPUB 相关 book: null, rendition: null, currentBookFile: null, // 阅读器状态 isReaderActive: false, settingsPanel: null, // 键盘事件清理函数 keydownCleanup: null }; // 暴露给全局的接口(供设置面板调用) window[CONSTANTS.NS + 'Interface'] = null; window[CONSTANTS.NS + 'State'] = null; // ============================================================================ // 工具模块 (Utils) // ============================================================================ const Utils = { /** * 读取文件为 ArrayBuffer * @param {File} file - 文件对象 * @returns {Promise<ArrayBuffer>} */ readFileAsArrayBuffer(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => resolve(e.target.result); reader.onerror = () => reject(new Error('文件读取失败')); reader.readAsArrayBuffer(file); }); }, /** * 显示通知消息 * @param {string} message - 消息内容 * @param {string} type - 类型: 'info' | 'error' | 'success' */ showNotification(message, type = 'info') { const notification = document.createElement('div'); notification.className = `${CONSTANTS.IDs.STYLES}-notification`; const colors = { info: { borderLeft: '#3b82f6', bg: '#eff6ff' }, error: { borderLeft: '#ef4444', bg: '#fef2f2' }, success: { borderLeft: '#22c55e', bg: '#f0fdf4' } }; const color = colors[type] || colors.info; notification.style.cssText = ` position: fixed; top: 24px; right: 24px; background: ${color.bg}; color: #333; padding: 14px 20px; border-radius: 12px; z-index: 10001; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; font-weight: 500; box-shadow: 0 8px 24px rgba(0,0,0,0.15); max-width: 320px; border-left: 4px solid ${color.borderLeft}; opacity: 0; transform: translateX(20px); animation: er-notification-in 0.3s ease forwards; transition: opacity 0.3s ease, transform 0.3s ease; `; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { notification.style.opacity = '0'; notification.style.transform = 'translateX(20px)'; setTimeout(() => notification.remove(), 300); }, CONSTANTS.CONFIG.NOTIFICATION_DURATION); }, /** * 存储适配器 */ Storage: { set(key, value) { window.localStorage.setItem(key, JSON.stringify(value)); }, get(key, defaultValue = null) { try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : defaultValue; } catch { return defaultValue; } }, remove(key) { window.localStorage.removeItem(key); } }, /** * 根据选择器获取 DOM 元素 * @param {string} selector - CSS 选择器 * @returns {Element|null} */ getElementBySelector(selector) { if (!selector) return null; if (selector.startsWith('#')) { return document.getElementById(selector.slice(1)); } if (selector.startsWith('.')) { return document.querySelector(selector); } return document.getElementById(selector) || document.querySelector(selector); }, /** * 验证并限制字符数范围 * @param {number|null} value - 输入值 * @returns {number|null} */ clampCharsPerPage(value) { if (value === null) return null; if (value < CONSTANTS.CONFIG.CHARS_PER_PAGE_MIN) { return CONSTANTS.CONFIG.CHARS_PER_PAGE_MIN; } if (value > CONSTANTS.CONFIG.CHARS_PER_PAGE_MAX) { return CONSTANTS.CONFIG.CHARS_PER_PAGE_MAX; } return value; } }; // ============================================================================ // 样式模块 (Styles) // ============================================================================ const Styles = ` @keyframes er-settings-in { to { opacity: 1; transform: translate(-50%, -50%) scale(1); } } @keyframes er-overlay-in { to { opacity: 1; } } @keyframes er-notification-in { to { opacity: 1; transform: translateX(0); } } #${CONSTANTS.IDs.SETTINGS_PANEL} { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.95); background: white; padding: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); z-index: 10000; max-width: 380px; width: 90%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; border-radius: 10px; opacity: 0; animation: er-settings-in 0.2s ease forwards; } #${CONSTANTS.IDs.SETTINGS_PANEL} h2 { margin: 0 0 12px 0; color: #1a1a1a; font-size: 16px; font-weight: 600; letter-spacing: -0.3px; padding-bottom: 8px; border-bottom: 1px solid #eee; } #${CONSTANTS.IDs.SETTINGS_PANEL} .er-form-group { margin-bottom: 10px; } #${CONSTANTS.IDs.SETTINGS_PANEL} label { display: block; margin-bottom: 4px; font-weight: 500; color: #555; font-size: 12px; } #${CONSTANTS.IDs.SETTINGS_PANEL} input[type="text"], #${CONSTANTS.IDs.SETTINGS_PANEL} input[type="number"] { width: 100%; padding: 7px 9px; border: 1px solid #ddd; border-radius: 5px; box-sizing: border-box; font-size: 13px; font-family: inherit; background: #fafafa; transition: all 0.15s ease; } #${CONSTANTS.IDs.SETTINGS_PANEL} input[type="text"]:focus, #${CONSTANTS.IDs.SETTINGS_PANEL} input[type="number"]:focus { outline: none; border-color: #3b82f6; background: white; } #${CONSTANTS.IDs.SETTINGS_PANEL} input[type="file"] { width: 100%; padding: 5px; border: 1px dashed #ddd; border-radius: 5px; box-sizing: border-box; background: #fafafa; cursor: pointer; font-size: 12px; } #${CONSTANTS.IDs.SETTINGS_PANEL} #er-reading-progress { padding: 7px 9px; background: #f0f9ff; border-radius: 5px; color: #0369a1; font-size: 12px; text-align: center; } #${CONSTANTS.IDs.SETTINGS_PANEL} small { display: block; margin-top: 2px; color: #999; font-size: 11px; } #${CONSTANTS.IDs.SETTINGS_PANEL} .er-button-group { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 2px; } #${CONSTANTS.IDs.SETTINGS_PANEL} button { background: #3b82f6; color: white; border: none; padding: 6px 11px; border-radius: 5px; cursor: pointer; font-size: 12px; font-weight: 500; font-family: inherit; transition: all 0.15s ease; flex: 0 0 auto; } #${CONSTANTS.IDs.SETTINGS_PANEL} button:hover { background: #2563eb; } #${CONSTANTS.IDs.SETTINGS_PANEL} .er-close-btn { background: #ef4444; } #${CONSTANTS.IDs.SETTINGS_PANEL} .er-close-btn:hover { background: #dc2626; } #${CONSTANTS.IDs.SETTINGS_PANEL} .er-shortcut-hint { margin-top: 10px; padding: 6px; background: #f8f9fa; border-radius: 5px; font-size: 10px; color: #999; text-align: center; } #${CONSTANTS.IDs.OVERLAY} { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.3); z-index: 9999; opacity: 0; animation: er-overlay-in 0.2s ease forwards; } .${CONSTANTS.IDs.READER} { width: 100%; height: 600px; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; background: white; min-height: 600px; } .${CONSTANTS.IDs.READER} iframe { width: 100%; height: 100%; border: none; } .${CONSTANTS.IDs.TEXT_READER} { width: 100%; height: 100%; display: flex; flex-direction: column; } #${CONSTANTS.IDs.TEXT_DISPLAY} { flex: 1; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.8; font-family: Arial, sans-serif; font-size: 16px; white-space: pre-line; overflow-y: auto; height: calc(100vh - 120px); } `; /** * 注入样式到页面 */ function injectStyles() { if (!document.getElementById(CONSTANTS.IDs.STYLES)) { const style = document.createElement('style'); style.id = CONSTANTS.IDs.STYLES; style.textContent = Styles; document.head.appendChild(style); } } // ============================================================================ // 分页模块 (Pagination) // ============================================================================ const Pagination = { /** * 按章节分页(每章一页) * @param {string} text - 完整文本 * @returns {string[]} 页面数组 */ byChapter(text) { const pages = []; const sections = text.split(/\n\n## /); sections.forEach((section, index) => { let trimmed = section.trim(); if (index > 0 && !trimmed.startsWith('## ')) { trimmed = '## ' + trimmed; } if (trimmed) { pages.push(trimmed); } }); console.log(`[${CONSTANTS.NS}] 按章节分页完成: ${pages.length} 页`); return pages; }, /** * 按字符数分页 * @param {string} text - 完整文本 * @param {number} charsPerPage - 每页字符数 * @returns {string[]} 页面数组 */ byChars(text, charsPerPage) { const pages = []; let currentPage = ''; let currentLength = 0; const sections = text.split(/\n\n## /); sections.forEach((section, index) => { let s = section; if (index > 0 && !s.startsWith('## ')) { s = '## ' + s; } // 超长章节需进一步分割 if (s.length > charsPerPage) { if (currentPage.trim()) { pages.push(currentPage.trim()); currentPage = ''; currentLength = 0; } const paragraphs = s.split(/\n{2,}/); paragraphs.forEach(para => { const trimmedPara = para.trim(); if (!trimmedPara) return; const separator = currentPage.length > 0 ? '\n\n' : ''; if (currentLength + trimmedPara.length + separator.length > charsPerPage) { if (currentPage.trim()) { pages.push(currentPage.trim()); } currentPage = trimmedPara; currentLength = trimmedPara.length; } else { currentPage += separator + trimmedPara; currentLength += trimmedPara.length + separator.length; } }); } else { const separator = currentPage.length > 0 ? '\n\n' : ''; if (currentLength + s.length + separator.length > charsPerPage) { if (currentPage.trim()) { pages.push(currentPage.trim()); } currentPage = s; currentLength = s.length; } else { currentPage += separator + s; currentLength += s.length + separator.length; } } }); if (currentPage.trim()) { pages.push(currentPage.trim()); } if (pages.length === 0 && text.trim()) { pages.push(text.trim()); } console.log(`[${CONSTANTS.NS}] 按字符数分页完成: ${pages.length} 页, 每页 ${charsPerPage} 字`); return pages; } }; // ============================================================================ // UI 模块 (Settings Panel) // ============================================================================ const Panel = { /** * 创建设置面板 */ create: function() { if (State.settingsPanel) { State.settingsPanel.remove(); } const overlay = document.createElement('div'); overlay.id = CONSTANTS.IDs.OVERLAY; const panel = document.createElement('div'); panel.id = CONSTANTS.IDs.SETTINGS_PANEL; panel.innerHTML = ` <h2>EPUB 阅读器设置</h2> <div class="er-form-group"> <label for="er-file">选择EPUB文件:</label> <input type="file" id="er-file" accept=".epub"> </div> <div class="er-form-group"> <label for="er-div-selector">目标DIV选择器:</label> <input type="text" id="er-div-selector" placeholder="如: #content 或 .main-content"> </div> <div class="er-form-group"> <label for="er-page-number">跳转到页码:</label> <input type="number" id="er-page-number" min="1" placeholder="输入页码"> </div> <div class="er-form-group" id="er-chars-group" style="display: none;"> <label for="er-chars-per-page">每页字符数 (留空按章节):</label> <input type="number" id="er-chars-per-page" min="${CONSTANTS.CONFIG.CHARS_PER_PAGE_MIN}" max="${CONSTANTS.CONFIG.CHARS_PER_PAGE_MAX}" placeholder="留空按章节"> <small>范围: ${CONSTANTS.CONFIG.CHARS_PER_PAGE_MIN} - ${CONSTANTS.CONFIG.CHARS_PER_PAGE_MAX} 字</small> </div> <div class="er-form-group"> <label>阅读进度:</label> <div id="${CONSTANTS.IDs.READING_PROGRESS}">未开始阅读</div> </div> <div class="er-button-group"> <button id="er-apply">应用</button> <button id="er-start-epub">EPUB模式</button> <button id="er-start-text">文本模式</button> <button class="er-close-btn" id="er-close">关闭</button> </div> <div class="er-shortcut-hint"> 翻页: ← → 或 Cmd/Ctrl + [ ] · 设置: Cmd/Ctrl + B </div> `; document.body.appendChild(overlay); document.body.appendChild(panel); State.settingsPanel = panel; Panel._bindEvents(); Panel._loadSettings(); }, /** * 绑定面板事件 */ _bindEvents: function() { const overlay = document.getElementById(CONSTANTS.IDs.OVERLAY); overlay.addEventListener('click', Panel.close); document.getElementById('er-close').addEventListener('click', Panel.close); document.getElementById('er-apply').addEventListener('click', Panel.applySettings); document.getElementById('er-start-epub').addEventListener('click', EpubReader.start); document.getElementById('er-start-text').addEventListener('click', TextReader.start); document.getElementById('er-file').addEventListener('change', Panel.handleFileSelect); }, /** * 加载已保存的设置 */ _loadSettings: function() { const savedDiv = Utils.Storage.get(CONSTANTS.STORAGE.TARGET_DIV); if (savedDiv) { document.getElementById('er-div-selector').value = savedDiv; } const progress = Utils.Storage.get(CONSTANTS.STORAGE.BOOK_PROGRESS); const state = window[CONSTANTS.NS + 'State']; // 文本模式: 通过 keydownCleanup 判断(最可靠) const isTextMode = !!State.keydownCleanup; const charsGroup = document.getElementById('er-chars-group'); const progressEl = document.getElementById(CONSTANTS.IDs.READING_PROGRESS); // 文本模式: 显示字符数设置组,从全局状态读取进度 if (isTextMode && charsGroup) { charsGroup.style.display = 'block'; if (state && state.totalPages > 0) { const percentage = state.totalPages > 1 ? Math.round((state.currentPage / (state.totalPages - 1)) * 100) : 100; progressEl.textContent = `第 ${state.currentPage + 1} / ${state.totalPages} (${percentage}%)`; const charsInput = document.getElementById('er-chars-per-page'); if (charsInput) { charsInput.value = state.charsPerPage !== undefined && state.charsPerPage !== null ? state.charsPerPage : ''; } } } else if (charsGroup) { // EPUB 模式或其他: 隐藏字符数设置组 charsGroup.style.display = 'none'; // 从存储读取进度 if (progress && progressEl) { progressEl.textContent = `第 ${progress.currentPage} / ${progress.totalPages || '未知'}`; } } }, /** * 关闭设置面板 */ close: function() { const overlay = document.getElementById(CONSTANTS.IDs.OVERLAY); const panel = document.getElementById(CONSTANTS.IDs.SETTINGS_PANEL); if (overlay) overlay.remove(); if (panel) panel.remove(); State.settingsPanel = null; }, /** * 处理文件选择 */ handleFileSelect: async function(event) { const file = event.target.files[0]; if (!file) return; if (!file.name.toLowerCase().endsWith('.epub')) { Utils.showNotification('请选择EPUB格式的文件', 'error'); return; } try { const arrayBuffer = await Utils.readFileAsArrayBuffer(file); if (arrayBuffer.byteLength === 0) { throw new Error('文件为空或损坏'); } State.currentBookFile = arrayBuffer; Utils.showNotification('电子书已加载'); } catch (error) { Utils.showNotification('加载失败: ' + error.message, 'error'); } }, /** * 应用设置(处理跳转和分页) */ applySettings: function() { const divSelector = document.getElementById('er-div-selector').value.trim(); if (!divSelector) { Utils.showNotification('请输入目标DIV选择器', 'error'); return; } Utils.Storage.set(CONSTANTS.STORAGE.TARGET_DIV, divSelector); // 处理每页字符数(文本模式) const charsInput = document.getElementById('er-chars-per-page'); if (charsInput) { const inputValue = charsInput.value.trim(); let newCharsPerPage = inputValue ? parseInt(inputValue) : null; newCharsPerPage = Utils.clampCharsPerPage(newCharsPerPage); charsInput.value = newCharsPerPage || ''; const readerInterface = window[CONSTANTS.NS + 'Interface']; if (readerInterface && readerInterface.repaginate) { readerInterface.repaginate(newCharsPerPage); } } // 处理页码跳转 const pageNumber = parseInt(document.getElementById('er-page-number').value); if (pageNumber && pageNumber > 0) { Panel._jumpToPage(pageNumber); } else { Utils.showNotification('设置已保存'); } }, /** * 跳转到指定页码 */ _jumpToPage: function(pageNumber) { // EPUB 模式 if (State.rendition && State.book) { State.book.locations.generate(CONSTANTS.CONFIG.EPUB_LOCATION_INTERVAL) .then(() => { const location = State.book.locations.cfiFromLocation(pageNumber); if (location) { return State.rendition.goto(location); } throw new Error('页码超出范围'); }) .then(() => Utils.showNotification(`已跳转到第 ${pageNumber} 页`)) .catch(() => { Utils.showNotification('跳转失败', 'error'); }); return; } // 文本模式 const readerInterface = window[CONSTANTS.NS + 'Interface']; if (readerInterface && readerInterface.displayPage) { const state = window[CONSTANTS.NS + 'State']; const totalPages = state ? state.totalPages : 0; if (pageNumber <= totalPages) { readerInterface.displayPage(pageNumber - 1); Utils.showNotification(`已跳转到第 ${pageNumber} 页`); } else { Utils.showNotification(`页码超出范围,共 ${totalPages} 页`, 'error'); } return; } Utils.showNotification('请先启动阅读器', 'error'); } }; // ============================================================================ // EPUB 阅读器模块 // ============================================================================ const EpubReader = { /** * 启动 EPUB 阅读器 */ start: async function() { // 先停止当前阅读器 stopReader(); const divSelector = document.getElementById('er-div-selector').value.trim() || Utils.Storage.get(CONSTANTS.STORAGE.TARGET_DIV); const pageNumber = parseInt(document.getElementById('er-page-number').value) || 1; if (!divSelector) { Utils.showNotification('请先设置目标DIV选择器', 'error'); return; } const file = await EpubReader._getBookFile(); if (!file) { Utils.showNotification('请先选择EPUB文件', 'error'); return; } State.currentBookFile = file; try { await EpubReader._initialize(divSelector, pageNumber); Panel.close(); } catch (error) { Utils.showNotification('启动失败: ' + error.message, 'error'); } }, /** * 获取 EPUB 文件 */ _getBookFile: function() { if (State.currentBookFile) return State.currentBookFile; const fileInput = document.getElementById('er-file'); if (fileInput && fileInput.files && fileInput.files[0]) { return Utils.readFileAsArrayBuffer(fileInput.files[0]); } return null; }, /** * 初始化 EPUB 阅读器 */ _initialize: async function(divSelector, startPage = 1) { const targetDiv = Utils.getElementBySelector(divSelector); if (!targetDiv) { throw new Error('找不到指定的DIV元素: ' + divSelector); } if (!State.currentBookFile) { throw new Error('请先选择EPUB文件'); } // 清空目标DIV并创建容器 targetDiv.innerHTML = ''; const readerContainer = document.createElement('div'); readerContainer.id = CONSTANTS.IDs.READER; readerContainer.className = CONSTANTS.IDs.READER; targetDiv.appendChild(readerContainer); readerContainer.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">正在加载...</div>'; // 暴露 JSZip 到全局 if (typeof unsafeWindow !== 'undefined') { unsafeWindow.JSZip = JSZip; unsafeWindow.ePub = ePub; } else { window.JSZip = JSZip; window.ePub = ePub; } try { State.book = ePub(State.currentBookFile); State.rendition = State.book.renderTo(CONSTANTS.IDs.READER, { width: '100%', height: '600px', spread: 'always', allowScriptedContent: true }); // 加载书籍 State.book.ready .then(() => State.book.loaded.metadata) .then(() => { readerContainer.innerHTML = ''; return State.rendition.display(); }) .then(() => EpubReader._jumpToPage(startPage)); // 设置事件监听 State.rendition.on('relocated', (location) => { const percentage = location.start.percentage || 0; const currentLocation = location.start.location || 0; const currentPage = Math.max(1, location.start.location || 0); const totalPages = State.book.locations && State.book.locations.length(); Utils.Storage.set(CONSTANTS.STORAGE.BOOK_PROGRESS, { currentPage, totalPages: totalPages || '未知', percentage }); console.log(`[${CONSTANTS.NS}] EPUB 翻页: 第${currentPage}页, ${totalPages || '未知'}总页`); }); State.rendition.on('error', (error) => { readerContainer.innerHTML = `<div style="padding: 20px; text-align: center; color: red;">渲染错误: ${error.message}</div>`; }); State.rendition.on('rendered', (section) => { console.log(`[${CONSTANTS.NS}] 章节渲染完成`); }); // 生成位置信息 if (startPage <= 1) { State.book.locations.generate(CONSTANTS.CONFIG.EPUB_LOCATION_INTERVAL).catch(() => {}); } } catch (error) { readerContainer.innerHTML = `<div style="padding: 20px; text-align: center; color: red;">初始化失败: ${error.message}</div>`; throw error; } State.isReaderActive = true; Utils.Storage.set(CONSTANTS.STORAGE.TARGET_DIV, divSelector); }, /** * 跳转到指定页码 */ _jumpToPage(pageNumber) { if (pageNumber <= 1) return; State.book.locations.generate(CONSTANTS.CONFIG.EPUB_LOCATION_INTERVAL) .then(() => { const location = State.book.locations.cfiFromLocation(pageNumber); if (location) { return State.rendition.goto(location); } }) .catch(() => {}); } }; // ============================================================================ // 纯文本阅读器模块 // ============================================================================ const TextReader = { /** * 启动纯文本模式 */ start: async function() { // 先停止当前阅读器 stopReader(); const divSelector = document.getElementById('er-div-selector').value.trim() || Utils.Storage.get(CONSTANTS.STORAGE.TARGET_DIV); if (!divSelector) { Utils.showNotification('请先设置目标DIV选择器', 'error'); return; } let file = null; if (State.currentBookFile) { file = State.currentBookFile; } else { const fileInput = document.getElementById('er-file'); if (fileInput && fileInput.files && fileInput.files[0]) { file = await Utils.readFileAsArrayBuffer(fileInput.files[0]); } } if (!file) { Utils.showNotification('请先选择EPUB文件', 'error'); return; } State.currentBookFile = file; const targetDiv = Utils.getElementBySelector(divSelector); if (!targetDiv) { Utils.showNotification('找不到指定的DIV元素', 'error'); return; } try { const initData = await TextReader._initialize(file, targetDiv); const readerInterface = TextReader._createInterface(targetDiv, initData.pages, initData.metadata, initData.allText, null); console.log(`[${CONSTANTS.NS}] 纯文本模式启动: ${initData.chapters.length} 章, ${initData.pages.length} 页`); Panel.close(); Utils.showNotification('纯文本模式已启动'); } catch (error) { Utils.showNotification('启动失败: ' + error.message, 'error'); } }, /** * 初始化纯文本模式 */ _initialize: async function(arrayBuffer, targetDiv) { State.isReaderActive = true; targetDiv.innerHTML = ''; const container = document.createElement('div'); container.id = CONSTANTS.IDs.TEXT_READER; container.className = CONSTANTS.IDs.TEXT_READER; targetDiv.appendChild(container); container.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">正在加载...</div>'; State.book = ePub(arrayBuffer); await State.book.ready; const metadata = await State.book.loaded.metadata; const navigation = await State.book.loaded.navigation; let allText = `${metadata.title || '未知标题'}\n作者: ${metadata.creator || '未知作者'}\n\n`; const chapters = []; for (const item of State.book.spine.items) { try { const section = await State.book.load(item.href); let content = section.documentElement?.textContent || section.body?.textContent || section.textContent || ''; content = content.trim(); if (!content) continue; // 获取章节标题 let chapterTitle = TextReader._extractChapterTitle(navigation, item, content, chapters.length); chapters.push({ title: chapterTitle, content }); allText += `\n\n## ${chapterTitle}\n\n${content}`; } catch (error) { console.warn(`[${CONSTANTS.NS}] 加载章节失败:`, error); } } container.innerHTML = ''; // 默认按章节分页 const pages = Pagination.byChapter(allText); return { chapters, metadata, allText, pages }; }, /** * 提取章节标题 */ _extractChapterTitle: function(navigation, item, content, index) { // 尝试从导航获取 if (Array.isArray(navigation)) { const navItem = navigation.find(nav => { if (!nav || !nav.href || !item.href) return false; return nav.href === item.href || nav.href.endsWith(item.href) || item.href.endsWith(nav.href); }); if (navItem && navItem.label) return navItem.label; } if (item.title) return item.title; // 从内容提取 const titleMatch = content.match(/<(h[1-6]|title)[^>]*>(.*?)<\/\1>/i); if (titleMatch) return titleMatch[2].trim(); // 从文件名提取 const fileName = item.href.split('/').pop().replace(/\.[^/.]+$/, ''); return fileName || `第${index + 1}章`; }, /** * 创建阅读器界面 */ _createInterface: function(targetDiv, pages, metadata, allText, charsPerPage) { targetDiv.innerHTML = ''; targetDiv.style.cssText = ` position: relative; height: 100%; display: flex; flex-direction: column; `; const textDisplay = document.createElement('div'); textDisplay.id = CONSTANTS.IDs.TEXT_DISPLAY; textDisplay.style.cssText = ` flex: 1; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.8; font-family: Arial, sans-serif; font-size: 16px; white-space: pre-line; overflow-y: auto; height: calc(100vh - 120px); `; const content = document.createElement('div'); content.id = CONSTANTS.IDs.PAGE_CONTENT; textDisplay.appendChild(TextReader._createHeader(metadata)); textDisplay.appendChild(document.createElement('hr')); textDisplay.appendChild(content); targetDiv.appendChild(textDisplay); // 状态管理 let currentPage = 0; let currentPages = pages; // 保存到全局 const updateState = () => { window[CONSTANTS.NS + 'State'] = { currentPage, totalPages: currentPages.length, charsPerPage }; TextReader._updateProgressDisplay(currentPage, currentPages.length); }; // 显示页面 const displayPage = (pageIndex) => { if (pageIndex < 0) pageIndex = 0; if (pageIndex >= currentPages.length) pageIndex = currentPages.length - 1; currentPage = pageIndex; content.textContent = currentPages[pageIndex]; textDisplay.scrollTop = 0; updateState(); }; // 重新分页 const repaginate = (newCharsPerPage) => { const ratio = currentPages.length > 1 ? currentPage / (currentPages.length - 1) : 0; currentPages = newCharsPerPage === null ? Pagination.byChapter(allText) : Pagination.byChars(allText, newCharsPerPage); charsPerPage = newCharsPerPage; const newPage = Math.min( Math.floor(ratio * Math.max(currentPages.length - 1, 1)), currentPages.length - 1 ); displayPage(newPage); }; // 键盘控制 const handleKeydown = (event) => { // 文本模式键盘控制 if (!State.isReaderActive) return; const isCmdOrCtrl = event.metaKey || event.ctrlKey; switch (event.key) { case 'ArrowLeft': event.preventDefault(); displayPage(currentPage - 1); break; case 'ArrowRight': event.preventDefault(); displayPage(currentPage + 1); break; case '[': if (isCmdOrCtrl) { event.preventDefault(); displayPage(currentPage - 1); } break; case ']': if (isCmdOrCtrl) { event.preventDefault(); displayPage(currentPage + 1); } break; } }; // 先清理旧的键盘事件监听器 if (State.keydownCleanup) { State.keydownCleanup(); } document.addEventListener('keydown', handleKeydown); State.keydownCleanup = () => document.removeEventListener('keydown', handleKeydown); // 保存接口到全局 const readerInterface = { displayPage, repaginate }; window[CONSTANTS.NS + 'Interface'] = readerInterface; displayPage(0); return readerInterface; }, /** * 创建书籍头部信息 */ _createHeader: function(metadata) { const wrapper = document.createElement('div'); const title = document.createElement('h1'); title.textContent = metadata.title || '未知标题'; title.style.cssText = 'margin-bottom: 10px;'; const author = document.createElement('p'); author.innerHTML = `<strong>作者:</strong> ${metadata.creator || '未知作者'}`; author.style.cssText = 'margin-bottom: 20px; color: #666;'; wrapper.appendChild(title); wrapper.appendChild(author); return wrapper; }, /** * 更新进度显示 */ _updateProgressDisplay: function(currentPage, totalPages) { const progressEl = document.getElementById(CONSTANTS.IDs.READING_PROGRESS); if (progressEl && totalPages > 0) { const percentage = totalPages > 1 ? Math.round((currentPage / (totalPages - 1)) * 100) : 100; progressEl.textContent = `第 ${currentPage + 1} / ${totalPages} (${percentage}%)`; } } }; // ============================================================================ // 键盘控制模块 // ============================================================================ const Keyboard = { /** * 添加键盘事件监听 */ bind: function() { document.addEventListener('keydown', Keyboard.handle); }, /** * 移除键盘事件监听 */ unbind: function() { document.removeEventListener('keydown', Keyboard.handle); }, /** * 处理键盘事件 */ handle: function(event) { // Cmd/Ctrl + B: 打开设置面板(始终可用) if ((event.metaKey || event.ctrlKey) && event.key === 'b') { event.preventDefault(); Panel.create(); return; } // 检查阅读器是否激活 if (!State.isReaderActive) return; // 文本模式由内部处理 if (State.keydownCleanup) return; // EPUB 模式翻页 if (!State.rendition) return; const isCmdOrCtrl = event.metaKey || event.ctrlKey; switch (event.key) { case 'ArrowLeft': event.preventDefault(); State.rendition.prev(); break; case 'ArrowRight': event.preventDefault(); State.rendition.next(); break; case '[': if (isCmdOrCtrl) { event.preventDefault(); State.rendition.prev(); } break; case ']': if (isCmdOrCtrl) { event.preventDefault(); State.rendition.next(); } break; } } }; // ============================================================================ // 停止阅读器 // ============================================================================ function stopReader() { if (State.book) { State.book.destroy(); State.book = null; } if (State.rendition) { State.rendition.destroy(); State.rendition = null; } // 清理文本模式键盘事件 if (State.keydownCleanup) { State.keydownCleanup(); State.keydownCleanup = null; } // 移除容器 const readerEl = document.getElementById(CONSTANTS.IDs.READER); if (readerEl) readerEl.remove(); const textReaderEl = document.getElementById(CONSTANTS.IDs.TEXT_READER); if (textReaderEl) textReaderEl.remove(); State.isReaderActive = false; // 清理全局状态 window[CONSTANTS.NS + 'Interface'] = null; window[CONSTANTS.NS + 'State'] = null; } // ============================================================================ // 初始化 // ============================================================================ /** * 确保依赖库在全局可用 */ function ensureLibraries() { if (typeof JSZip === 'undefined') { console.error(`[${CONSTANTS.NS}] JSZip 库未加载`); Utils.showNotification('JSZip库加载失败,请刷新页面', 'error'); return false; } if (typeof unsafeWindow !== 'undefined') { unsafeWindow.JSZip = JSZip; unsafeWindow.ePub = ePub; } else { window.JSZip = JSZip; window.ePub = ePub; } return true; } /** * 主初始化函数 */ function init() { // 检查依赖 if (!ensureLibraries()) return; // 注入样式 injectStyles(); // 绑定全局键盘事件 Keyboard.bind(); // 加载保存的设置 const savedDiv = Utils.Storage.get(CONSTANTS.STORAGE.TARGET_DIV); if (savedDiv) { console.log(`[${CONSTANTS.NS}] 已加载上次使用的DIV选择器: ${savedDiv}`); } } // 页面加载完成后初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })(); // IIFE 结束