您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
🌟为 e 站添加预加载。🌟添加图片分区点击左边上一页,右边下一页。🌟让e站可以在不刷新的情况下加载图片。🌟支持键盘操作。🌟支持i18n。
// ==UserScript== // @name E-Hentai Reader Assistant // @name:en-US EX-Hentai Reader Assistant // @name:zh-CN EX-Hentai 助手 // @name:ja-JP EX-Hentai リーダーアシスタント // @namespace EX-Hentai Reader Assistant // @match https://e-hentai.org/s/* // @match https://exhentai.org/s/* // @grant none // @version 1.3 // @author Assistant // @description 🌟Add preloading to e-hentai. 🌟Add click zones (left=prev/right=next) to image sections. 🌟Load images without page reload. 🌟Support keyboard shortcuts. 🌟Support i18n. // @description:en-US 🌟Add preloading to e-hentai. 🌟Add click zones (left=prev/right=next) to image sections. 🌟Load images without page reload. 🌟Support keyboard shortcuts. 🌟Support i18n. // @description:zh-CN 🌟为 e 站添加预加载。🌟添加图片分区点击左边上一页,右边下一页。🌟让e站可以在不刷新的情况下加载图片。🌟支持键盘操作。🌟支持i18n。 // @description:ja-JP 🌟e-hentai にプリロードを追加。🌟画像セクションのクリックゾーン(左=前/右=次)を追加。🌟ページ遷移なしで画像を読み込む。🌟キーボード操作をサポート。🌟多言語対応。 // @run-at document-start // @license CC-BY-NC-SA-4.0 // @noframes true // ==/UserScript== (function() { 'use strict'; // 配置 const CONFIG = { preloadCount: 3, // 预加载页面数量 maxRetries: 3, // 最大重试次数 retryDelay: 100, // 重试延迟(ms) updateUrl: false, // 是否在不刷新情况下更新地址栏 // 统一时间/延迟参数 preloadStepDelay: 120, // 预加载步进小延迟(ms) imageTransitionMs: 210, // 图片切换过渡时长(ms) resizeThrottleMs: 100, // 窗口重排节流(ms) hintAutoHideMs: 5000, // 操作提示自动隐藏(ms) hintFadeMs: 1000, // 操作提示淡出时长(ms) errorDurationMs: 3000, // 错误提示显示时长(ms) messageDurationMs: 2000 // 普通消息显示时长(ms) }; // 全局变量 let currentPage = 1; let totalPages = 1; let imageCache = new Map(); // 图片缓存 let pageDataCache = new Map(); // 页面数据缓存 let pageUrlCache = new Map(); // 页码 -> 真实 URL(含 token) let isLoading = false; let fitMode = localStorage.getItem('ehx_fit_mode') || 'width'; // width | height // 新增:预加载状态跟踪 let preloadStatus = new Map(); // 页码 -> 状态 ('waiting', 'loading', 'completed', 'failed') let pendingPreloadRender = false; // rAF 批处理标志 let scriptEnabled = localStorage.getItem('ehx_script_enabled') !== 'false'; // 脚本开关 let showPreloadStatus = localStorage.getItem('ehx_show_preload_status') !== 'false'; // 预加载状态显示开关 let langSetting = localStorage.getItem('ehx_lang') || 'auto'; // 语言设置: auto | en | zh-CN | ja // 许可证 const LICENSE = 'CC-BY-NC-SA-4.0'; // 国际化字典 const I18N = { 'en': { fitWidth: 'Fit width', fitHeight: 'Fit height', settings: 'Settings', loading: 'Loading...', navHintTitle: 'Keyboard:', navHintLeft: '← / A: Prev page', navHintRight: '→ / D / Space: Next page', navHintClick: 'Click image: Left=Prev, Right=Next', menuTitle: 'E-Hentai Reader Settings', basicSettings: 'General', enableScript: 'Enable script', showPreloadStatus: 'Show preload status', updateUrl: 'Update address bar', preloadSettings: 'Preload', preloadCount: 'Preload pages', maxRetries: 'Max retries', retryDelayMs: 'Retry delay (ms)', cacheManagement: 'Cache', clearImageCache: 'Clear image cache', clearPageCache: 'Clear page cache', clearAllCache: 'Clear all cache', resetSettings: 'Reset settings', statusInfo: 'Status', cacheImageCount: 'Image cache', cachePageCount: 'Page cache', preloadStatus: 'Preload Status', close: 'Close', language: 'Language', lang_auto: 'Follow browser', lang_en: 'English', lang_zhCN: '简体中文', lang_ja: '日本語', confirmReset: 'Reset all settings?', msgScriptEnabled: 'Enabled. Please refresh the page.', msgScriptDisabled: 'Disabled.', msgSettingsSaved: 'Settings saved', msgImgCacheCleared: 'Image cache cleared', msgPageCacheCleared: 'Page cache cleared', msgAllCleared: 'All caches cleared', msgSettingsReset: 'Settings reset. Please refresh the page', loadFailed: 'Load failed, please retry', status_waiting: 'waiting', status_loading: 'loading', status_completed: 'completed', status_failed: 'failed', status_unknown: 'unknown', }, 'zh-CN': { fitWidth: '适应宽度', fitHeight: '适应高度', settings: '脚本设置', loading: '正在加载...', navHintTitle: '键盘操作:', navHintLeft: '← / A: 上一页', navHintRight: '→ / D / 空格: 下一页', navHintClick: '点击图片左半: 上一页,右半: 下一页', menuTitle: 'E-Hentai 助手设置', basicSettings: '基础设置', enableScript: '启用脚本', showPreloadStatus: '显示预加载状态', updateUrl: '地址栏同步', preloadSettings: '预加载设置', preloadCount: '预加载页数', maxRetries: '重试次数', retryDelayMs: '重试延迟(ms)', cacheManagement: '缓存管理', clearImageCache: '清除图片缓存', clearPageCache: '清除页面缓存', clearAllCache: '清除所有缓存', resetSettings: '重置设置', statusInfo: '状态信息', cacheImageCount: '图片缓存', cachePageCount: '页面缓存', preloadStatus: '预加载状态', close: '关闭', language: '语言', lang_auto: '自动', lang_en: 'English', lang_zhCN: '简体中文', lang_ja: '日本語', confirmReset: '确定要重置所有设置吗?', msgScriptEnabled: '脚本已启用,请刷新页面生效', msgScriptDisabled: '脚本已禁用', msgSettingsSaved: '设置已保存', msgImgCacheCleared: '图片缓存已清除', msgPageCacheCleared: '页面缓存已清除', msgAllCleared: '所有缓存已清除', msgSettingsReset: '设置已重置,请刷新页面', loadFailed: '加载失败,请重试', status_waiting: '等待', status_loading: '加载中', status_completed: '完成', status_failed: '失败', status_unknown: '未知', }, 'ja': { fitWidth: '幅に合わせる', fitHeight: '高さに合わせる', settings: '設定', loading: '読み込み中...', navHintTitle: 'キーボード:', navHintLeft: '← / A: 前のページ', navHintRight: '→ / D / Space: 次のページ', navHintClick: '画像クリック: 左=前, 右=次', menuTitle: 'E-Hentai リーダー設定', basicSettings: '基本設定', enableScript: 'スクリプトを有効', showPreloadStatus: 'プリロード状態を表示', updateUrl: 'アドレスバーを更新', preloadSettings: 'プリロード', preloadCount: 'プリロード枚数', maxRetries: '再試行回数', retryDelayMs: '再試行遅延(ms)', cacheManagement: 'キャッシュ', clearImageCache: '画像キャッシュを削除', clearPageCache: 'ページキャッシュを削除', clearAllCache: 'すべてのキャッシュを削除', resetSettings: '設定をリセット', statusInfo: 'ステータス', cacheImageCount: '画像キャッシュ', cachePageCount: 'ページキャッシュ', preloadStatus: 'プリロード状態', close: '閉じる', language: '言語', lang_auto: 'ブラウザに従う', lang_en: 'English', lang_zhCN: '简体中文', lang_ja: '日本語', confirmReset: 'すべての設定をリセットしますか?', msgScriptEnabled: '有効にしました。ページを更新してください。', msgScriptDisabled: '無効にしました。', msgSettingsSaved: '設定を保存しました', msgImgCacheCleared: '画像キャッシュを削除しました', msgPageCacheCleared: 'ページキャッシュを削除しました', msgAllCleared: 'すべてのキャッシュを削除しました', msgSettingsReset: '設定をリセットしました。ページを更新してください', loadFailed: '読み込みに失敗しました。再試行してください', status_waiting: '待機', status_loading: '読み込み中', status_completed: '完了', status_failed: '失敗', status_unknown: '不明', } }; function resolveDefaultLang() { const n = (navigator.language || navigator.userLanguage || 'en').toLowerCase(); if (n.startsWith('zh')) return 'zh-CN'; if (n.startsWith('ja')) return 'ja'; return 'en'; } function getCurrentLang() { return langSetting === 'auto' ? resolveDefaultLang() : langSetting; } function t(key) { const lang = getCurrentLang(); const dict = I18N[lang] || I18N['en']; return (dict && dict[key]) || I18N['en'][key] || key; } // 保持 document-start,但不在此阶段阻断事件,避免干扰后续绑定 // 初始化 function init() { // 添加样式(总是需要,包括菜单样式) addStyles(); // 添加管理菜单 addControlMenu(); // 如果脚本被禁用,只显示菜单,不执行主要功能 if (!scriptEnabled) { console.log('脚本已禁用,仅显示控制菜单'); return; } // 解析当前页面信息 parseCurrentPage(); // 用当前 DOM 为当前页建立初始化数据(含 next/prev 实时 URL) seedCurrentPageData(); // 绑定事件 bindEvents(); // 开始预加载 startPreloading(); // 添加加载指示器 addLoadingIndicator(); // 添加预加载状态显示 if (showPreloadStatus) { addPreloadStatusDisplay(); } } // 解析当前页面信息 function parseCurrentPage() { const url = window.location.href; const match = url.match(/\/s\/([^\/]+)\/(\d+)-(\d+)/); if (match) { currentPage = parseInt(match[3]); } // 从 DOM 解析当前/总页 const spans = document.querySelectorAll('.sn span'); if (spans && spans.length >= 2) { const cur = parseInt(spans[0].textContent.trim()); const tot = parseInt(spans[1].textContent.trim()); if (!Number.isNaN(cur)) currentPage = cur; if (!Number.isNaN(tot)) totalPages = tot; } // 建立 URL 映射 pageUrlCache.set(currentPage, window.location.href); const nextA = document.getElementById('next'); const prevA = document.getElementById('prev'); if (nextA && nextA.href) pageUrlCache.set(currentPage + 1, nextA.href); if (prevA && prevA.href) pageUrlCache.set(currentPage - 1, prevA.href); console.log(`当前页: ${currentPage}/${totalPages}`); } // 用当前 DOM 初始化当前页数据,确保实时 next/prev function seedCurrentPageData() { const img = document.getElementById('img'); if (!img) return; const nextA = document.getElementById('next'); const prevA = document.getElementById('prev'); const imageData = { src: img.src, width: img.style.width, height: img.style.height, pageNum: currentPage, nextUrl: nextA && nextA.href ? nextA.href : undefined, prevUrl: prevA && prevA.href ? prevA.href : undefined, }; pageDataCache.set(currentPage, imageData); if (imageData.nextUrl) pageUrlCache.set(currentPage + 1, imageData.nextUrl); if (imageData.prevUrl) pageUrlCache.set(currentPage - 1, imageData.prevUrl); } // 绑定事件 function bindEvents() { const img = document.getElementById('img'); const imgContainer = document.getElementById('i3'); if (img && imgContainer) { // 用中性容器替换外层 <a>,彻底打断默认跳转与站内 onclick const link = imgContainer.querySelector('a'); if (link && link.contains(img)) { const holder = document.createElement('div'); holder.id = 'img-holder'; holder.style.cursor = 'pointer'; holder.style.display = 'inline-block'; link.parentNode.replaceChild(holder, link); holder.appendChild(img); // 添加局部加载层 const loader = document.createElement('div'); loader.className = 'eh-img-loader'; loader.innerHTML = '<div class="eh-spinner"></div>'; holder.appendChild(loader); holder.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); const rect = holder.getBoundingClientRect(); const x = e.clientX - rect.left; if (x < rect.width / 2) { goToPrevPage(); } else { goToNextPage(); } }, { capture: true }); } } // 在冒泡阶段拦截,并作为后备触发我们的逻辑 document.addEventListener('click', function(e) { const target = e.target; if (!target) return; const inNext = target.closest('a#next'); const inPrev = target.closest('a#prev'); const inImgArea = target.closest('#i3'); if (inNext) { e.preventDefault(); e.stopPropagation(); goToNextPage(); return; } if (inPrev) { e.preventDefault(); e.stopPropagation(); goToPrevPage(); return; } if (inImgArea) { e.preventDefault(); e.stopPropagation(); const rect = inImgArea.getBoundingClientRect(); const x = e.clientX - rect.left; if (x < rect.width / 2) { goToPrevPage(); } else { goToNextPage(); } return; } }, false); // 绑定导航按钮 bindNavigationButtons(); // 绑定键盘事件(捕获阶段,阻止站点快捷键) document.addEventListener('keydown', handleKeyboard, true); } // 绑定导航按钮 function bindNavigationButtons() { const prevBtn = document.getElementById('prev'); const nextBtn = document.getElementById('next'); if (prevBtn) { // 不再覆盖 href,保留真实链接,仅拦截点击 prevBtn.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); goToPrevPage(); }, { capture: true }); } if (nextBtn) { // 不再覆盖 href,保留真实链接,仅拦截点击 nextBtn.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); goToNextPage(); }, { capture: true }); } } // 键盘事件处理 function handleKeyboard(e) { // 阻断站点注册的快捷键 const code = e.code || e.key; if (!code) return; // 忽略按住不放产生的重复事件,避免一次按键翻多页 if (e.repeat) { e.preventDefault(); e.stopPropagation(); return; } if (isLoading) { e.preventDefault(); e.stopPropagation(); return; } if (code === 'ArrowLeft' || code === 'KeyA') { e.preventDefault(); e.stopPropagation(); goToPrevPage(); } else if (code === 'ArrowRight' || code === 'KeyD' || code === 'Space') { e.preventDefault(); e.stopPropagation(); goToNextPage(); } } // 跳转到下一页(优先使用当前页解析得到的 next 实时链接) async function goToNextPage() { if (isLoading || currentPage >= totalPages) return; setLoading(true); try { let nextPage = currentPage + 1; const curData = pageDataCache.get(currentPage); if (curData && curData.nextUrl) { const p = extractPageNum(curData.nextUrl); if (Number.isFinite(p)) nextPage = p; pageUrlCache.set(nextPage, curData.nextUrl); } const imageData = await getPageImage(nextPage); if (imageData) { updateImage(imageData); currentPage = nextPage; updatePageInfo(); updateNavigationButtons(); // 继续预加载 preloadPages(); // 更新状态显示 updatePreloadStatusDisplay(); } } catch (error) { console.error('加载下一页失败:', error); showError(t('loadFailed')); } finally { setLoading(false); } } // 跳转到上一页(优先使用当前页解析得到的 prev 实时链接) async function goToPrevPage() { if (isLoading || currentPage <= 1) return; setLoading(true); try { let prevPage = currentPage - 1; const curData = pageDataCache.get(currentPage); if (curData && curData.prevUrl) { const p = extractPageNum(curData.prevUrl); if (Number.isFinite(p)) prevPage = p; pageUrlCache.set(prevPage, curData.prevUrl); } const imageData = await getPageImage(prevPage); if (imageData) { updateImage(imageData); currentPage = prevPage; updatePageInfo(); updateNavigationButtons(); // 继续预加载(向前翻页后同样触发) preloadPages(); // 更新状态显示 updatePreloadStatusDisplay(); } } catch (error) { console.error('加载上一页失败:', error); showError(t('loadFailed')); } finally { setLoading(false); } } // 获取指定页面的图片信息 async function getPageImage(pageNum, retryCount = 0) { if (pageDataCache.has(pageNum)) { // 如果已缓存,更新状态为完成 updatePreloadStatus(pageNum, 'completed'); return pageDataCache.get(pageNum); } // 设置状态为加载中 updatePreloadStatus(pageNum, 'loading'); try { const url = getPageUrl(pageNum); const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const img = doc.getElementById('img'); if (!img) { throw new Error('未找到图片元素'); } let imageData = { src: img.src, width: img.style.width, height: img.style.height, pageNum: pageNum }; // 完善 URL 映射与总页数,并记录当前页的 prev/next 实时链接 try { const nextA2 = doc.getElementById('next'); const prevA2 = doc.getElementById('prev'); if (nextA2 && nextA2.href) { pageUrlCache.set(pageNum + 1, nextA2.href); imageData.nextUrl = nextA2.href; } if (prevA2 && prevA2.href) { pageUrlCache.set(pageNum - 1, prevA2.href); imageData.prevUrl = prevA2.href; } const spans2 = doc.querySelectorAll('.sn span'); if (spans2.length >= 2) { const tot2 = parseInt(spans2[1].textContent.trim()); if (!Number.isNaN(tot2) && tot2 > totalPages) { totalPages = tot2; // 实时更新页面显示 setTimeout(updatePageInfo, 0); } } } catch (_) {} // 缓存数据 pageDataCache.set(pageNum, imageData); // 预加载图片 preloadImage(imageData.src); // 更新状态为完成 updatePreloadStatus(pageNum, 'completed'); return imageData; } catch (error) { console.error(`获取页面 ${pageNum} 失败:`, error); if (retryCount < CONFIG.maxRetries) { console.log(`重试获取页面 ${pageNum} (${retryCount + 1}/${CONFIG.maxRetries})`); await new Promise(resolve => setTimeout(resolve, CONFIG.retryDelay)); return getPageImage(pageNum, retryCount + 1); } // 更新状态为失败 updatePreloadStatus(pageNum, 'failed'); throw error; } } // 预加载图片 function preloadImage(src) { if (imageCache.has(src)) return; const img = new Image(); img.onload = () => { imageCache.set(src, img); console.log('图片预加载完成:', src); }; img.onerror = () => { console.error('图片预加载失败:', src); }; img.src = src; } // 获取页面URL function getPageUrl(pageNum) { if (pageUrlCache.has(pageNum)) return pageUrlCache.get(pageNum); // 临近页尝试使用 DOM 中的真实链接 if (pageNum === currentPage + 1) { const nextA = document.getElementById('next'); if (nextA && nextA.href) { pageUrlCache.set(pageNum, nextA.href); return nextA.href; } } if (pageNum === currentPage - 1) { const prevA = document.getElementById('prev'); if (prevA && prevA.href) { pageUrlCache.set(pageNum, prevA.href); return prevA.href; } } // 回退:基于当前 URL 猜测(可能无效) const m = window.location.href.match(/^(.*-)(\d+)([^\d]*)$/); if (m) { const guess = `${m[1]}${pageNum}${m[3] || ''}`; return guess; } return window.location.href; } // 辅助:从链接中提取页码 function extractPageNum(url) { const m = url && url.match(/\/s\/[^\/]+\/(\d+)-(\d+)/); if (m) return parseInt(m[2]); const m2 = url && url.match(/-(\d+)(?:[^\d]*)$/); return m2 ? parseInt(m2[1]) : NaN; } // 更新图片 function updateImage(imageData) { const img = document.getElementById('img'); if (img) { // 过渡开始:隐藏旧图,显示局部加载动画 beginImageTransition(); const applyNewSrc = () => { img.src = imageData.src; // 让 CSS 接管尺寸控制,避免因不同页的 inline 宽高导致布局跳变 img.style.width = ''; img.style.height = ''; // 新图加载完成后淡入 if (img.complete) { endImageTransition(); } else { img.onload = () => endImageTransition(); img.onerror = () => endImageTransition(true); } // 更新图片信息 updateImageInfo(imageData); }; // 若已预加载,直接应用 if (imageCache.has(imageData.src)) { const preImg = imageCache.get(imageData.src); if (preImg && preImg.complete) { applyNewSrc(); } else { // 保险:等待预加载完成再应用 preImg.onload = () => applyNewSrc(); preImg.onerror = () => applyNewSrc(); } } else { applyNewSrc(); } } } // 更新图片信息 function updateImageInfo(imageData) { const infoElements = document.querySelectorAll('#i2 > div:last-child, #i4 > div:first-child'); // 从图片URL提取文件名和尺寸信息 const urlParts = imageData.src.split('/'); const filename = urlParts[urlParts.length - 1].split('?')[0]; infoElements.forEach(element => { if (element.textContent.includes('::')) { // 保持原有格式,只更新文件名 const parts = element.textContent.split('::'); if (parts.length >= 2) { element.textContent = `${filename} :: ${parts[1].trim()} :: ${parts[2] ? parts[2].trim() : ''}`; } } }); } // 更新页面信息 function updatePageInfo() { // 更新原页面的所有页码显示(顶部和底部) const pageSpans = document.querySelectorAll('.sn span'); for (let i = 0; i < pageSpans.length; i += 2) { if (pageSpans[i]) { pageSpans[i].textContent = currentPage; } if (pageSpans[i + 1] && totalPages) { pageSpans[i + 1].textContent = totalPages; } } // 更新自定义工具条的页码显示 const cur = document.getElementById('ehx-cur'); const tot = document.getElementById('ehx-total'); if (cur) cur.textContent = currentPage; if (tot && totalPages) tot.textContent = totalPages; // 是否更新浏览器地址栏(不刷新页面) if (CONFIG.updateUrl) { const newUrl = pageUrlCache.get(currentPage) || getPageUrl(currentPage); window.history.replaceState(null, '', newUrl); } } // 更新导航按钮状态 function updateNavigationButtons() { // 更新原页面的导航按钮 const prevBtn = document.getElementById('prev'); const nextBtn = document.getElementById('next'); if (prevBtn) { if (currentPage <= 1) { prevBtn.style.opacity = '0.5'; prevBtn.style.cursor = 'not-allowed'; } else { prevBtn.style.opacity = '1'; prevBtn.style.cursor = 'pointer'; } } if (nextBtn) { if (currentPage >= totalPages) { nextBtn.style.opacity = '0.5'; nextBtn.style.cursor = 'not-allowed'; } else { nextBtn.style.opacity = '1'; nextBtn.style.cursor = 'pointer'; } } } // 开始预加载 function startPreloading() { preloadPages(); } // 预加载页面 async function preloadPages() { // 顺序向前预加载,以确保 token 链正确 for (let step = 1; step <= CONFIG.preloadCount; step++) { const p = currentPage + step; if (p > totalPages) break; if (pageDataCache.has(p)) continue; // 设置等待状态 updatePreloadStatus(p, 'waiting'); try { // 小延迟避免阻塞 // eslint-disable-next-line no-await-in-loop await new Promise(r => setTimeout(r, CONFIG.preloadStepDelay)); // eslint-disable-next-line no-await-in-loop await getPageImage(p); } catch (e) { console.log(`预加载页面 ${p} 失败:`, e && e.message ? e.message : e); updatePreloadStatus(p, 'failed'); break; } } // 回看一页 const back = currentPage - 1; if (back >= 1 && !pageDataCache.has(back)) { updatePreloadStatus(back, 'waiting'); getPageImage(back).catch(() => { updatePreloadStatus(back, 'failed'); }); } } // 设置加载状态 function setLoading(loading) { isLoading = loading; const indicator = document.getElementById('loading-indicator'); if (indicator) { indicator.style.display = loading ? 'block' : 'none'; } // 禁用/启用导航按钮 const buttons = document.querySelectorAll('#prev, #next'); buttons.forEach(btn => { if (loading) { btn.style.pointerEvents = 'none'; btn.style.opacity = '0.5'; } else { btn.style.pointerEvents = 'auto'; updateNavigationButtons(); } }); } // 开始图片切换的过渡,显示局部 loading function beginImageTransition() { const img = document.getElementById('img'); if (!img) return; const holder = document.getElementById('img-holder'); const isHeightMode = document.body.classList.contains('ehx-fit-height'); if (holder && !isHeightMode) { // 只在宽度模式下固定高度,避免切换瞬间塌陷/跳变 const rect = holder.getBoundingClientRect(); holder.style.height = rect.height + 'px'; } img.style.opacity = '0'; if (holder) holder.classList.add('loading'); // 预留图片信息区域高度,避免下方元素跳动 reserveInfoAreas(); } // 结束图片切换的过渡,隐藏局部 loading function endImageTransition(isError = false) { const img = document.getElementById('img'); if (!img) return; const holder = document.getElementById('img-holder'); const isHeightMode = document.body.classList.contains('ehx-fit-height'); // 根据新图自然高度更新容器高度,再释放 if (holder) { const release = () => { requestAnimationFrame(() => { if (!isHeightMode) { holder.style.height = ''; } holder.classList.remove('loading'); // 延后释放信息区域的 min-height,避免移动端闪烁 setTimeout(releaseInfoAreas, 0); }); }; if (!isHeightMode) { // 只在宽度模式下做高度过渡 const tmpImg = imageCache.get(img.src) || img; const naturalH = tmpImg.naturalHeight && tmpImg.naturalWidth ? (holder.clientWidth * tmpImg.naturalHeight / tmpImg.naturalWidth) : holder.clientHeight; if (naturalH && Number.isFinite(naturalH)) { holder.style.height = Math.round(naturalH) + 'px'; setTimeout(release, CONFIG.imageTransitionMs); } else { release(); } } else { // 高度模式下直接释放 release(); } } img.style.opacity = '1'; } // 预留信息区域高度 function reserveInfoAreas() { const infoTop = document.querySelector('#i2 > div:last-child'); const infoBottom = document.querySelector('#i4 > div:first-child'); freezeElementHeight(infoTop); freezeElementHeight(infoBottom); } // 释放信息区域高度 function releaseInfoAreas() { const infoTop = document.querySelector('#i2 > div:last-child'); const infoBottom = document.querySelector('#i4 > div:first-child'); releaseElementHeight(infoTop); releaseElementHeight(infoBottom); } // 冻结元素高度 function freezeElementHeight(el) { if (!el) return; const rect = el.getBoundingClientRect(); const h = Math.round(rect.height); if (h > 0) { // 仅设置最小高度,避免强制 height/overflow 造成移动端重绘闪烁 el.style.minHeight = h + 'px'; } } // 释放元素高度 function releaseElementHeight(el) { if (!el) return; el.style.minHeight = ''; } // 显示错误信息 function showError(message) { const errorDiv = document.createElement('div'); errorDiv.id = 'error-message'; errorDiv.textContent = message; errorDiv.className = 'stuffbox'; errorDiv.style.cssText = ` position: fixed; top: 20px; right: 20px; padding: 10px 15px; z-index: 10000; font-size: 14px; `; document.body.appendChild(errorDiv); setTimeout(() => { if (errorDiv.parentNode) { errorDiv.parentNode.removeChild(errorDiv); } }, CONFIG.errorDurationMs); } // 添加样式(移除自定义配色与文字色,改为继承站点样式) function addStyles() { const style = document.createElement('style'); style.textContent = ` /* 页面框架:仅结构与布局,颜色继承站点 */ .ehx-reader-bar { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; backdrop-filter: saturate(120%) blur(3px); } .ehx-reader-bar .group { display: inline-flex; align-items: center; gap: 6px; } .ehx-btn { appearance: none; border: 1px solid currentColor; background: transparent; color: inherit; padding: 6px 10px; border-radius: 6px; cursor: pointer; } .ehx-btn:disabled { opacity: .5; cursor: not-allowed; } .ehx-btn:hover { filter: brightness(1.02); } .ehx-btn.active { outline: 2px solid currentColor; } .ehx-sep { width: 1px; height: 24px; background: currentColor; opacity: .2; } .ehx-counter { user-select: none; min-width: 84px; text-align: center; } /* 主容器:跟随窗口宽度居中 */ .ehx-container { margin: 0 auto; padding: 6px 10px; max-width: min(96vw, 1200px); } .ehx-image-wrap { display: flex; justify-content: center; align-items: flex-start; } /* 适应高度模式:整个显示区域适应浏览器视口 */ body.ehx-fit-height .ehx-container { height: var(--ehx-available-height, 400px); display: flex; flex-direction: column; } body.ehx-fit-height .ehx-image-wrap { flex: 1; align-items: center; min-height: 0; } body.ehx-fit-height #img-holder { height: 100%; max-height: 100%; width: auto; display: flex; align-items: center; justify-content: center; } body.ehx-fit-height #img { max-height: 100%; max-width: 100%; width: auto; height: auto; object-fit: contain; } #loading-indicator { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); padding: 15px 25px; z-index: 10000; font-size: 16px; display: none; } .eh-nav-hint { position: fixed; bottom: 20px; right: 20px; padding: 10px; font-size: 12px; z-index: 9999; } #img { transition: opacity 0.18s ease; max-width: 100%; height: auto; display: block; } .loading #img { opacity: 0.7; } /* 局部加载遮罩 */ #img-holder { position: relative; display: inline-block; max-width: 100%; } #img-holder .eh-img-loader { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; } #img-holder.loading .eh-img-loader { display: flex; } .eh-spinner { width: 32px; height: 32px; border-radius: 50%; border: 3px solid currentColor; border-top-color: transparent; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* 容器自适应与高度平滑过渡,减少抖动 */ #i3 { display: flex; justify-content: center; } #img-holder { transition: height 0.2s ease; } body.ehx-fit-height #img-holder { transition: none; } /* 控制菜单样式(定位 + 结构,外观由站点类接管) */ .ehx-control-menu { position: fixed; top: 10px; right: 10px; padding: 12px; z-index: 10001; font-size: 12px; min-width: 280px; display: none; } .ehx-control-menu.show { display: block; } .ehx-menu-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid currentColor; font-weight: bold; } .ehx-menu-close { background: none; border: none; font-size: 16px; cursor: pointer; padding: 0; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; } .ehx-menu-section { margin-bottom: 12px; } .ehx-menu-section h4 { margin: 0 0 6px 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; } .ehx-menu-item { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; padding: 4px 0; } .ehx-menu-item label { font-size: 11px; cursor: pointer; } .ehx-toggle { position: relative; width: 40px; height: 20px; border: 1px solid currentColor; border-radius: 10px; cursor: pointer; } .ehx-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; background: currentColor; border-radius: 50%; transition: transform 0.3s; } .ehx-toggle.active::after { transform: translateX(20px); } .ehx-menu-input { padding: 4px 6px; border: 1px solid currentColor; border-radius: 4px; font-size: 11px; width: 120px; min-height: 26px; text-align: center; text-align-last: center; background: transparent; color: inherit; } .ehx-menu-btn { padding: 6px 12px; border: 1px solid currentColor; border-radius: 4px; cursor: pointer; font-size: 11px; margin: 2px; background: transparent; color: inherit; } /* 预加载状态显示(外观交给站点类) */ .ehx-preload-status { position: fixed; bottom: 10px; left: 10px; padding: 10px; z-index: 10001; font-size: 11px; max-width: 300px; max-height: 200px; overflow: hidden; display: none; } .ehx-preload-status.show { display: block; } .ehx-status-header { font-weight: bold; margin-bottom: 8px; text-align: center; padding-bottom: 4px; border-bottom: 1px solid currentColor; } .ehx-status-list { max-height: 160px; overflow-y: auto; padding-right: 4px; scrollbar-width: thin; } .ehx-status-item { display: flex; justify-content: space-between; align-items: center; padding: 2px 0; border-bottom: 1px solid currentColor; opacity: .6; } .ehx-status-item:last-child { border-bottom: none; } .ehx-status-page { font-weight: bold; } .ehx-status-state { font-size: 10px; font-weight: bold; text-transform: uppercase; } `; document.head.appendChild(style); } // 添加加载指示器 function addLoadingIndicator() { // 顶部工具条(简洁,参考结构但不抄配色) const topBar = document.createElement('div'); topBar.className = 'ehx-reader-bar stuffbox'; topBar.innerHTML = ` <div class="group"> <span class="ehx-counter"><span id="ehx-cur">${currentPage}</span> / <span id="ehx-total">${totalPages}</span></span> </div> <div class="group"> <button class="ehx-btn" id="ehx-fit-width">${t('fitWidth')}</button> <button class="ehx-btn" id="ehx-fit-height">${t('fitHeight')}</button> <button class="ehx-btn" id="ehx-menu-open" title="${t('settings')}">⚙️</button> </div> `; document.body.insertBefore(topBar, document.body.firstChild); // 容器包裹(居中与内边距) const container = document.createElement('div'); container.className = 'ehx-container'; const i3 = document.getElementById('i3'); if (i3 && i3.parentNode) { i3.parentNode.insertBefore(container, i3); container.appendChild(i3); i3.classList.add('ehx-image-wrap'); } // 全局加载指示器 const indicator = document.createElement('div'); indicator.id = 'loading-indicator'; indicator.className = 'stuffbox'; indicator.textContent = t('loading'); document.body.appendChild(indicator); // 图像局部加载容器 const img = document.getElementById('img'); const imgContainer = document.getElementById('i3'); if (img && imgContainer) { // 若尚未包裹,建立 holder let holder = document.getElementById('img-holder'); if (!holder) { holder = document.createElement('div'); holder.id = 'img-holder'; holder.style.display = 'inline-block'; img.parentNode.insertBefore(holder, img); holder.appendChild(img); const loader = document.createElement('div'); loader.className = 'eh-img-loader'; loader.innerHTML = '<div class="eh-spinner"></div>'; holder.appendChild(loader); } } // 添加操作提示 const hint = document.createElement('div'); hint.className = 'eh-nav-hint stuffbox'; hint.innerHTML = ` <div>${t('navHintTitle')}</div> <div>${t('navHintLeft')}</div> <div>${t('navHintRight')}</div> <div>${t('navHintClick')}</div> `; document.body.appendChild(hint); // N 秒后隐藏提示 setTimeout(() => { hint.style.transition = 'opacity 1s ease'; hint.style.opacity = '0'; setTimeout(() => { if (hint.parentNode) { hint.parentNode.removeChild(hint); } }, CONFIG.hintFadeMs); }, CONFIG.hintAutoHideMs); // 工具条事件 const btnFitW = document.getElementById('ehx-fit-width'); const btnFitH = document.getElementById('ehx-fit-height'); const btnMenu = document.getElementById('ehx-menu-open'); if (btnFitW) btnFitW.onclick = () => setFitMode('width'); if (btnFitH) btnFitH.onclick = () => setFitMode('height'); if (btnMenu) btnMenu.onclick = (e) => { e.preventDefault(); e.stopPropagation(); const menu = document.querySelector('.ehx-control-menu'); if (menu) { const willShow = !menu.classList.contains('show'); menu.classList.toggle('show'); if (willShow) updateMenuInfo(); } }; // 初始应用适配模式 applyFitMode(); updateFitModeButtons(); } // 页面加载完成后初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // 适应模式 function setFitMode(mode) { fitMode = mode === 'height' ? 'height' : 'width'; localStorage.setItem('ehx_fit_mode', fitMode); applyFitMode(); updateFitModeButtons(); } function updateFitModeButtons() { const btnFitW = document.getElementById('ehx-fit-width'); const btnFitH = document.getElementById('ehx-fit-height'); if (btnFitW && btnFitH) { btnFitW.classList.toggle('active', fitMode === 'width'); btnFitH.classList.toggle('active', fitMode === 'height'); } } function applyFitMode() { const holder = document.getElementById('img-holder'); const img = document.getElementById('img'); if (!holder || !img) return; if (fitMode === 'height') { // 高度适应模式:让整个容器适应浏览器高度 document.body.classList.add('ehx-fit-height'); const topBar = document.querySelector('.ehx-reader-bar'); const topH = topBar ? topBar.getBoundingClientRect().height : 0; const avail = Math.max(300, window.innerHeight - topH - 12); document.documentElement.style.setProperty('--ehx-available-height', avail + 'px'); // 清除宽度模式的内联样式 holder.style.maxHeight = ''; holder.style.width = ''; holder.style.height = ''; img.style.maxWidth = ''; img.style.maxHeight = ''; } else { // 宽度适应模式:图片宽度撑满容器 document.body.classList.remove('ehx-fit-height'); document.documentElement.style.removeProperty('--ehx-available-height'); // 设置宽度模式样式 holder.style.maxHeight = ''; holder.style.width = '100%'; holder.style.height = ''; img.style.maxWidth = '100%'; img.style.maxHeight = 'none'; } } // 窗口尺寸变化节流,减少高频调用导致的抖动 let resizeThrottleId = null; window.addEventListener('resize', () => { if (resizeThrottleId) clearTimeout(resizeThrottleId); resizeThrottleId = setTimeout(() => { resizeThrottleId = null; applyFitMode(); }, CONFIG.resizeThrottleMs); }); // ==================== 新增功能:控制菜单和预加载状态 ==================== // 添加控制菜单 function addControlMenu() { // 控制菜单(移除悬浮触发按钮,改由工具条按钮控制) const menu = document.createElement('div'); menu.className = 'ehx-control-menu stuffbox'; menu.innerHTML = ` <div class="ehx-menu-header"> <span>${t('menuTitle')}</span> <button class="ehx-menu-close" title="${t('close')}">×</button> </div> <div class="ehx-menu-section"> <h4>${t('basicSettings')}</h4> <div class="ehx-menu-item"> <label>${t('enableScript')}</label> <div class="ehx-toggle ${scriptEnabled ? 'active' : ''}" data-setting="script-enabled"></div> </div> <div class="ehx-menu-item"> <label>${t('showPreloadStatus')}</label> <div class="ehx-toggle ${showPreloadStatus ? 'active' : ''}" data-setting="show-preload-status"></div> </div> <div class="ehx-menu-item"> <label>${t('updateUrl')}</label> <div class="ehx-toggle ${CONFIG.updateUrl ? 'active' : ''}" data-setting="update-url"></div> </div> <div class="ehx-menu-item"> <label>${t('language')}</label> <select class="ehx-menu-input" data-setting="lang"> <option value="auto" ${langSetting==='auto' ? 'selected' : ''}>${t('lang_auto')}</option> <option value="en" ${getCurrentLang()==='en' && langSetting!=='auto' ? 'selected' : ''}>${t('lang_en')}</option> <option value="zh-CN" ${getCurrentLang()==='zh-CN' && langSetting!=='auto' ? 'selected' : ''}>${t('lang_zhCN')}</option> <option value="ja" ${getCurrentLang()==='ja' && langSetting!=='auto' ? 'selected' : ''}>${t('lang_ja')}</option> </select> </div> </div> <div class="ehx-menu-section"> <h4>${t('preloadSettings')}</h4> <div class="ehx-menu-item"> <label>${t('preloadCount')}</label> <input type="number" class="ehx-menu-input" min="1" max="10" value="${CONFIG.preloadCount}" data-setting="preload-count"> </div> <div class="ehx-menu-item"> <label>${t('maxRetries')}</label> <input type="number" class="ehx-menu-input" min="1" max="10" value="${CONFIG.maxRetries}" data-setting="max-retries"> </div> <div class="ehx-menu-item"> <label>${t('retryDelayMs')}</label> <input type="number" class="ehx-menu-input" min="500" max="5000" step="500" value="${CONFIG.retryDelay}" data-setting="retry-delay"> </div> </div> <div class="ehx-menu-section"> <h4>${t('cacheManagement')}</h4> <div style="display: flex; flex-wrap: wrap; gap: 4px;"> <button class="ehx-menu-btn" data-action="clear-cache">${t('clearImageCache')}</button> <button class="ehx-menu-btn" data-action="clear-page-cache">${t('clearPageCache')}</button> <button class="ehx-menu-btn" data-action="clear-all-cache">${t('clearAllCache')}</button> <button class="ehx-menu-btn" data-action="reset-settings">${t('resetSettings')}</button> </div> </div> <div class="ehx-menu-section"> <h4>${t('statusInfo')}</h4> <div style="font-size: 10px; line-height: 1.4;"> <div>${t('cacheImageCount')}: <span id="ehx-cache-count">${imageCache.size}</span></div> <div>${t('cachePageCount')}: <span id="ehx-page-cache-count">${pageDataCache.size}</span></div> <div>${t('preloadStatus')}: <span id="ehx-preload-count">${preloadStatus.size}</span></div> </div> </div> `; document.body.appendChild(menu); menu.querySelector('.ehx-menu-close').addEventListener('click', () => { menu.classList.remove('show'); }); // 点击菜单外部关闭 document.addEventListener('click', (e) => { if (!menu.contains(e.target)) { menu.classList.remove('show'); } }); // 绑定设置控制事件 bindMenuControls(menu); } // 绑定菜单控制事件 function bindMenuControls(menu) { // 开关控制 menu.querySelectorAll('.ehx-toggle').forEach(toggle => { toggle.addEventListener('click', () => { const setting = toggle.dataset.setting; const isActive = toggle.classList.contains('active'); toggle.classList.toggle('active'); switch(setting) { case 'script-enabled': scriptEnabled = !isActive; localStorage.setItem('ehx_script_enabled', scriptEnabled); showMessage(scriptEnabled ? t('msgScriptEnabled') : t('msgScriptDisabled')); break; case 'show-preload-status': showPreloadStatus = !isActive; localStorage.setItem('ehx_show_preload_status', showPreloadStatus); if (showPreloadStatus) { addPreloadStatusDisplay(); } else { const statusDisplay = document.querySelector('.ehx-preload-status'); if (statusDisplay) statusDisplay.remove(); } break; case 'update-url': CONFIG.updateUrl = !isActive; localStorage.setItem('ehx_update_url', CONFIG.updateUrl); break; } }); }); // 输入框控制 menu.querySelectorAll('.ehx-menu-input').forEach(input => { input.addEventListener('change', () => { const setting = input.dataset.setting; const value = setting === 'lang' ? String(input.value) : (parseInt(input.value) || 1); switch(setting) { case 'preload-count': CONFIG.preloadCount = Math.max(1, Math.min(10, value)); localStorage.setItem('ehx_preload_count', CONFIG.preloadCount); input.value = CONFIG.preloadCount; break; case 'max-retries': CONFIG.maxRetries = Math.max(1, Math.min(10, value)); localStorage.setItem('ehx_max_retries', CONFIG.maxRetries); input.value = CONFIG.maxRetries; break; case 'retry-delay': CONFIG.retryDelay = Math.max(500, Math.min(5000, value)); localStorage.setItem('ehx_retry_delay', CONFIG.retryDelay); input.value = CONFIG.retryDelay; break; case 'lang': langSetting = value; localStorage.setItem('ehx_lang', langSetting); showMessage(t('msgSettingsSaved')); applyLanguage(); return; } showMessage(t('msgSettingsSaved')); }); }); // 按钮控制 menu.querySelectorAll('.ehx-menu-btn').forEach(btn => { btn.addEventListener('click', () => { const action = btn.dataset.action; switch(action) { case 'clear-cache': imageCache.clear(); showMessage(t('msgImgCacheCleared')); break; case 'clear-page-cache': pageDataCache.clear(); showMessage(t('msgPageCacheCleared')); break; case 'clear-all-cache': imageCache.clear(); pageDataCache.clear(); pageUrlCache.clear(); preloadStatus.clear(); showMessage(t('msgAllCleared')); updatePreloadStatusDisplay(); break; case 'reset-settings': if (confirm(t('confirmReset'))) { localStorage.removeItem('ehx_script_enabled'); localStorage.removeItem('ehx_show_preload_status'); localStorage.removeItem('ehx_fit_mode'); localStorage.removeItem('ehx_update_url'); localStorage.removeItem('ehx_preload_count'); localStorage.removeItem('ehx_max_retries'); localStorage.removeItem('ehx_retry_delay'); localStorage.removeItem('ehx_lang'); showMessage(t('msgSettingsReset')); } break; } updateMenuInfo(); }); }); } // 更新菜单信息 function updateMenuInfo() { const cacheCount = document.getElementById('ehx-cache-count'); const pageCacheCount = document.getElementById('ehx-page-cache-count'); const preloadCount = document.getElementById('ehx-preload-count'); if (cacheCount) cacheCount.textContent = imageCache.size; if (pageCacheCount) pageCacheCount.textContent = pageDataCache.size; if (preloadCount) preloadCount.textContent = preloadStatus.size; } // 添加预加载状态显示 function addPreloadStatusDisplay() { // 移除已存在的显示 const existing = document.querySelector('.ehx-preload-status'); if (existing) existing.remove(); const statusDisplay = document.createElement('div'); statusDisplay.className = 'ehx-preload-status show stuffbox'; statusDisplay.innerHTML = ` <div class="ehx-status-header">${t('preloadStatus')}</div> <div class="ehx-status-list"></div> `; document.body.appendChild(statusDisplay); // 添加点击头部切换显示/隐藏功能 statusDisplay.querySelector('.ehx-status-header').addEventListener('click', () => { const list = statusDisplay.querySelector('.ehx-status-list'); list.style.display = list.style.display === 'none' ? 'block' : 'none'; }); updatePreloadStatusDisplay(); } // 更新预加载状态 function updatePreloadStatus(pageNum, status) { preloadStatus.set(pageNum, status); updatePreloadStatusDisplay(); } // 更新预加载状态显示 function updatePreloadStatusDisplay() { if (pendingPreloadRender) return; pendingPreloadRender = true; requestAnimationFrame(() => { pendingPreloadRender = false; const statusDisplay = document.querySelector('.ehx-preload-status'); if (!statusDisplay) return; const statusList = statusDisplay.querySelector('.ehx-status-list'); if (!statusList) return; // 获取当前页面附近的状态 const relevantPages = []; for (let i = Math.max(1, currentPage - 2); i <= Math.min(totalPages, currentPage + CONFIG.preloadCount + 2); i++) { relevantPages.push(i); } statusList.innerHTML = relevantPages.map(pageNum => { const status = preloadStatus.get(pageNum) || (pageDataCache.has(pageNum) ? 'completed' : 'unknown'); const isCurrent = pageNum === currentPage; const statusText = getStatusText(status); const statusClass = `ehx-status-${status}`; return ` <div class="ehx-status-item ${isCurrent ? 'current' : ''}"> <span class="ehx-status-page">${isCurrent ? `► ${pageNum}` : pageNum}</span> <span class="ehx-status-state ${statusClass}">${statusText}</span> </div> `; }).join(''); }); } // 获取状态文本 function getStatusText(status) { switch(status) { case 'waiting': return t('status_waiting'); case 'loading': return t('status_loading'); case 'completed': return t('status_completed'); case 'failed': return t('status_failed'); default: return t('status_unknown'); } } // 应用当前语言到 UI function applyLanguage() { // 顶部按钮与标题 const btnW = document.getElementById('ehx-fit-width'); const btnH = document.getElementById('ehx-fit-height'); const btnMenu = document.getElementById('ehx-menu-open'); const indicator = document.getElementById('loading-indicator'); if (btnW) btnW.textContent = t('fitWidth'); if (btnH) btnH.textContent = t('fitHeight'); if (btnMenu) btnMenu.title = t('settings'); if (indicator) indicator.textContent = t('loading'); // 预加载状态标题 const statusHeader = document.querySelector('.ehx-preload-status .ehx-status-header'); if (statusHeader) statusHeader.textContent = t('preloadStatus'); // 重新渲染菜单(保留显示状态) const oldMenu = document.querySelector('.ehx-control-menu'); const wasShown = oldMenu && oldMenu.classList.contains('show'); if (oldMenu) oldMenu.remove(); addControlMenu(); const newMenu = document.querySelector('.ehx-control-menu'); if (newMenu && wasShown) newMenu.classList.add('show'); // 刷新预加载状态里的文本 updatePreloadStatusDisplay(); } })();