// ==UserScript==
// @name Manhuagui 阅读增强 · 全量加载/沉浸式双页并排阅读/自动跨页显示
// @namespace http://tampermonkey.net/
// @version 4.3.1
// @description 漫画柜双页浏览,全屏显示,从右到左显示。横图跨页;点击半屏翻页;记忆进度;
// @author akira0245
// @match https://www.manhuagui.com/comic/*/*.html
// @match https://tw.manhuagui.com/comic/*/*.html
// @match https://cn.manhuagui.com/comic/*/*.html
// @icon https://www.google.com/s2/favicons?sz=64&domain=manhuagui.com
// @run-at document-idle
// @grant none
// @license GPLv3
// ==/UserScript==
(function () {
'use strict';
// ===================== 开发者常量(仅此处修改) =====================
const CONCURRENCY = 3; // 并发上限
const TIMEOUT_SEC = 12; // 单张图片超时(秒)
// 默认阅读外观/行为(可在设置中修改并持久化)
const DEFAULTS = {
// 交互
clickNav: 'lr', // 点击翻页:off / lr(左右半屏) / ud(上下半屏)
autoHideToolbar: false, // 自动隐藏菜单(右上角唤醒/短暂移动显示)
snapWheel: false, // 一屏滚动:滚轮一次跨页
snapSmooth: true, // 一屏滚动时平滑滚动
showSpreadProgress: true, // 底部跨页进度
// 布局
crossAspect: 1.0, // 跨页判定阈值:w/h >= 阈值 => 跨页(默认 1.0)
containerMaxW: 2200, // 阅读容器最大宽(px)
pageGapH: 16, // 双页左右间距(px)
spreadGapV: 16, // 行间上下间距(px)
pageHeight: '100vh', // 单页高度(支持 calc)
// 外观
bodyBg: '#0a0a0a', // 页面背景
galleryBg: '#0d0d0d', // 阅读区背景
showPN: true, // 显示页码
shadow: false, // 图片阴影
// 视图模式
double: true, // 双页模式
rtl: true, // 右到左
firstSingle: true // 首页单页
};
const LS_CFG = 'tmkReaderCfg.v43';
const LS_POS_PREFIX = 'tmkPos:'; // 记忆阅读位置 key 前缀
// ===================== 工具/检测 =====================
const pageSelect = document.querySelector('#pageSelect');
if (!pageSelect) return;
const firstImg = document.querySelector('#mangaBox img.mangaFile, #mangaFile, #mangaMoreBox img[data-tag="mangaFile"]');
if (!firstImg) return;
const firstURL = new URL(firstImg.src, location.href);
const fallbackOrigin = firstURL.origin;
const fallbackPath = firstURL.pathname.replace(/\/[^/]+$/, '/');
function waitFor(cond, timeout = 8000, interval = 60) {
return new Promise((resolve) => {
const t0 = Date.now();
const timer = setInterval(() => {
if (cond()) { clearInterval(timer); resolve(true); }
else if (Date.now() - t0 > timeout) { clearInterval(timer); resolve(false); }
}, interval);
});
}
function getTotalPages() {
const opts = Array.from(pageSelect.options);
return opts.length ? parseInt(opts[opts.length - 1].value, 10) : 0;
}
function loadCfg() {
try { const raw = localStorage.getItem(LS_CFG); return raw ? { ...DEFAULTS, ...JSON.parse(raw) } : { ...DEFAULTS }; }
catch { return { ...DEFAULTS }; }
}
function saveCfg(cfg) { localStorage.setItem(LS_CFG, JSON.stringify(cfg)); }
function chapterKey() { return LS_POS_PREFIX + location.pathname; }
function savePos(pageIdx) { try { localStorage.setItem(chapterKey(), String(pageIdx)); } catch {} }
function loadPos() {
try { const n = parseInt(localStorage.getItem(chapterKey()) || '', 10); return Number.isFinite(n) ? n : null; }
catch { return null; }
}
// ===================== SMH/pVars 读取真实URL =====================
function tryExtractConfFromPVars() {
const pv = window.pVars;
if (!pv || !pv.manga) return null;
const m = pv.manga;
const path = m.path || m.PATH || m.p || null;
const sl = m.sl || m.SL || m.sign || null;
let files = m.files || m.images || m.fs || null;
const normalizeFiles = (val) => {
if (!val) return null;
if (Array.isArray(val)) return val.slice();
if (typeof val === 'string') {
try { const arr = JSON.parse(val); if (Array.isArray(arr)) return arr; } catch {}
let dec = null;
if (window.LZString?.decompressFromBase64) { try { dec = LZString.decompressFromBase64(val); } catch {} }
if (!dec && window.M?.W?.Z) { try { dec = window.M.W.Z(val); } catch {} }
if (dec && typeof dec === 'string') {
try { const arr = JSON.parse(dec); if (Array.isArray(arr)) return arr; } catch {}
let arr = dec.split('|'); if (arr.length <= 1) arr = dec.split(',');
arr = arr.map(s => s.trim()).filter(Boolean);
if (arr.length) return arr;
}
}
return null;
};
const list = normalizeFiles(files);
return { path: path || null, sl: sl || null, files: list };
}
async function collectUrlsByGoPage(total) {
const urls = new Array(total + 1);
const hasSMH = !!(window.SMH && window.SMH.utils && typeof window.SMH.utils.goPage === 'function');
if (!hasSMH) return null;
const conf = tryExtractConfFromPVars() || {};
const origin = fallbackOrigin;
const path = conf.path || fallbackPath;
const sl = conf.sl || {};
const waitCurFileChange = (prev, timeout = 1500) => new Promise((resolve) => {
const t0 = Date.now();
const timer = setInterval(() => {
if (window.pVars?.curFile && window.pVars.curFile !== prev) { clearInterval(timer); resolve(true); }
else if (Date.now() - t0 > timeout) { clearInterval(timer); resolve(false); }
}, 30);
});
const readCurrentImgSrc = () => {
const cur = document.querySelector('#mangaBox img.mangaFile, #mangaFile');
if (cur?.src) return cur.src;
const more = document.querySelectorAll('#mangaMoreBox img[data-tag="mangaFile"]');
if (more && more.length) return more[more.length - 1].src;
return null;
};
['#mangaBox', '#mangaMoreBox', '.pager', '.sub-btn', '.main-btn', '#imgLoading'].forEach(sel => {
const el = document.querySelector(sel);
if (el) el.style.display = 'none';
});
for (let i = 1; i <= total; i++) {
const prev = window.pVars?.curFile || '';
try { window.SMH.utils.goPage(i); }
catch {
pageSelect.value = String(i);
pageSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
await waitCurFileChange(prev, 1500);
const curFile = window.pVars?.curFile;
if (curFile && (sl.e || sl.m)) {
const q = new URLSearchParams(); if (sl.e) q.set('e', sl.e); if (sl.m) q.set('m', sl.m);
urls[i] = `${origin}${path}${curFile}?${q.toString()}`;
} else {
urls[i] = readCurrentImgSrc() || '';
}
}
return urls;
}
// ===================== 主流程 =====================
(async function main() {
const ok = await waitFor(() => window.SMH && window.pVars && document.querySelector('#mangaFile'));
if (!ok) return;
const total = getTotalPages();
if (!total) return;
let conf = tryExtractConfFromPVars();
let pageUrls = null;
if (conf && conf.files && conf.sl) {
const origin = fallbackOrigin;
const path = conf.path || fallbackPath;
const q = new URLSearchParams();
if (conf.sl.e) q.set('e', conf.sl.e);
if (conf.sl.m) q.set('m', conf.sl.m);
pageUrls = conf.files.map(name => `${origin}${path}${name}?${q.toString()}`);
pageUrls.unshift('');
if (pageUrls.length - 1 !== total) pageUrls = null;
}
if (!pageUrls) {
pageUrls = await collectUrlsByGoPage(total);
if (!pageUrls) return;
}
const cfg = loadCfg();
buildReaderUI(pageUrls, total, cfg);
})();
// ===================== 阅读 UI + 加载器 =====================
function buildReaderUI(urls, totalPages, cfg) {
// 样式(现代毛玻璃 + 自动隐藏 + 进度条 + 100vh + 贴中间 + 设置分组 + tooltip)
const css = `
:root{
--page-height: ${cfg.pageHeight};
--spread-gap-v: ${cfg.spreadGapV}px;
--page-gap-h: ${cfg.pageGapH}px;
--max-container-width: ${cfg.containerMaxW}px;
--gallery-bg: ${cfg.galleryBg};
}
body { background: ${cfg.bodyBg}; }
.tbCenter {
border: none;
}
.tmk-toolbar {
position: fixed; right: 16px; top: 16px; z-index: 99999;
color: #fff; font-size: 13px;
padding: 10px 12px; border-radius: 12px; line-height: 1.6;
background: rgba(24,24,24,.55);
border: 1px solid rgba(255,255,255,.08);
backdrop-filter: blur(10px);
box-shadow: 0 10px 28px rgba(0,0,0,.35);
transition: opacity .25s ease, transform .25s ease;
}
.tmk-toolbar.auto-hide { opacity: 0; transform: translateY(-8px); pointer-events: none; }
.tmk-toolbar.auto-hide.show { opacity: 1; transform: translateY(0); pointer-events: auto; }
.tmk-reveal { position: fixed; right: 0; top: 0; width: 70px; height: 70px; z-index: 99998; }
.tmk-toolbar .row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.tmk-toolbar .btn { color:#fff; background: linear-gradient(180deg,#515151,#444);
border: 1px solid rgba(255,255,255,.12); padding: 6px 10px; border-radius: 8px; cursor:pointer; }
.tmk-toolbar .btn:hover { filter: brightness(1.08); }
.tmk-toolbar .sep { width:1px; height:18px; background: rgba(255,255,255,.15); margin: 0 6px; }
.tmk-toolbar label { display:flex; align-items:center; gap:6px; user-select:none; cursor:pointer; }
.tmk-gallery {
width: min(98vw, var(--max-container-width));
margin: 0 auto; padding: 8px 8px 120px;
background: var(--gallery-bg);
transition: background .2s ease;
}
.tmk-gallery .tmk-spread { width: 100%; margin-bottom: var(--spread-gap-v); }
.tmk-gallery img { display: block; height: var(--page-height); width: auto; object-fit: contain; background: transparent; }
.tmk-shadow img { box-shadow: 0 8px 24px rgba(0,0,0,.45); transition: box-shadow .2s ease; }
.tmk-gallery.hide-pn .pn { display:none; }
/* 单页模式:一行一页,居中 */
.tmk-gallery.single .tmk-spread.single { display:flex; justify-content:center; }
.tmk-gallery.single .tmk-spread.single .page-slot { position: relative; height: var(--page-height); }
/* 双页:pair 组整体居中,“加载前半屏占位,加载后按图宽收缩” */
.tmk-gallery.double .tmk-spread.pair { display:flex; gap: var(--page-gap-h); justify-content:center; }
.tmk-gallery.double .tmk-spread.pair.rtl { flex-direction: row-reverse; }
.tmk-gallery.double .tmk-spread.pair .page-slot {
position: relative; height: var(--page-height);
flex: 0 0 auto;
width: calc((100% - var(--page-gap-h)) / 2);
max-width: calc((100% - var(--page-gap-h)) / 2);
overflow: hidden;
}
.tmk-gallery.double .tmk-spread.pair .page-slot.loaded { width: auto; max-width: none; }
/* 双页中的单页(half slot),靠边(通过row-reverse控制靠右) */
.tmk-gallery.double .tmk-spread.single-only { display:flex; justify-content:center; }
.tmk-gallery.double .tmk-spread.single-only.rtl { flex-direction: row-reverse; }
.tmk-gallery.double .tmk-spread.single-only .page-slot {
position: relative; height: var(--page-height);
flex: 0 0 auto;
width: calc((100% - var(--page-gap-h)) / 2);
max-width: calc((100% - var(--page-gap-h)) / 2);
overflow: hidden;
}
.tmk-gallery.double .tmk-spread.single-only .page-slot.loaded {
width: calc((100% - var(--page-gap-h)) / 2);
max-width: calc((100% - var(--page-gap-h)) / 2);
}
/* 跨页:整行居中 */
.tmk-gallery.double .tmk-spread.full { display:flex; justify-content:center; }
.tmk-gallery.double .tmk-spread.full .page-slot { position: relative; height: var(--page-height); width: 100%; max-width: 100%; }
/* 占位符骨架 */
.page-slot .ph {
position:absolute; inset:0; display:flex; align-items:center; justify-content:center; flex-direction:column;
color:#9aa; font-size:13px; background: linear-gradient(180deg,#0f0f0f,#0b0b0b);
}
.page-slot.loaded .ph { display:none; }
.ph .ring {
width: 28px; height: 28px; border: 3px solid rgba(255,255,255,0.2); border-top-color:#bbb;
border-radius:50%; animation: tmk-spin 0.9s linear infinite; margin-bottom:8px;
}
@keyframes tmk-spin { to { transform: rotate(360deg); } }
/* 页码徽章 */
.pn {
position:absolute; left:50%; transform: translateX(-50%);
bottom: 6px; padding: 2px 8px; font-size: 12px; color:#eee;
background: rgba(0,0,0,.45); border-radius: 12px; pointer-events:none;
}
/* 底部跨页进度 */
.tmk-bottom-progress {
position: fixed; left: 50%; transform: translateX(-50%);
bottom: 14px; z-index: 99990;
color:#eee; font-size: 12px; padding: 4px 10px;
background: rgba(20,20,20,.55);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,.08);
border-radius: 999px;
box-shadow: 0 6px 18px rgba(0,0,0,.35);
}
.tmk-bottom-progress.hidden { display:none; }
/* 下一章提示按钮 */
.tmk-next-chapter {
position: fixed; right: 24px; bottom: 20px; z-index: 99991;
color:#fff; font-size: 14px; padding: 8px 12px;
background: linear-gradient(180deg,#4e8a3f,#3f6d32);
border: 1px solid rgba(255,255,255,.12); border-radius: 999px;
box-shadow: 0 6px 18px rgba(0,0,0,.35);
cursor: pointer; display:none;
}
.tmk-next-chapter.show { display:block; }
/* 设置面板 */
.tmk-modal { position: fixed; inset:0; background: rgba(0,0,0,.35); z-index: 100000; display:none; align-items:center; justify-content:center; }
.tmk-modal.show { display:flex; }
.tmk-dialog {
width: min(92vw, 720px); max-height: 88vh; overflow:auto;
background: rgba(26,26,26,.7); color:#eee; border-radius:12px;
box-shadow: 0 10px 28px rgba(0,0,0,.4);
border: 1px solid rgba(255,255,255,.08);
backdrop-filter: blur(16px);
padding: 16px 18px 12px;
}
.tmk-dialog h3 { margin: 6px 0 6px; font-size: 16px; opacity: .9; }
.tmk-section { margin: 10px 0 12px; border: 1px solid rgba(255,255,255,.08); border-radius: 10px; padding: 10px 12px; background: rgba(14,14,14,.35); }
.tmk-section-title { font-size: 13px; opacity: .85; margin: 0 0 6px 0; }
.tmk-dialog .row { display:flex; align-items:center; gap:10px; margin: 8px 0; flex-wrap:wrap; }
.tmk-dialog label { min-width: 180px; display:flex; align-items:center; gap:6px; }
.tmk-dialog input[type="number"], .tmk-dialog input[type="text"], .tmk-dialog select {
padding: 6px 8px; border-radius: 8px; border: 1px solid #444; background:#111; color:#eee; width: 180px;
}
.tmk-dialog input[type="color"] { width: 44px; height: 28px; border: none; background: transparent; }
.tmk-dialog .actions { display:flex; justify-content:flex-end; gap:8px; margin-top: 12px; }
.tmk-dialog .btn { color:#fff; background: linear-gradient(180deg,#515151,#444); border:1px solid rgba(255,255,255,.12);
padding: 6px 12px; border-radius:8px; cursor:pointer; }
.tmk-dialog .btn:hover { filter: brightness(1.08); }
.tmk-dialog .btn.warn { background: linear-gradient(180deg,#8a3838,#6a2b2b); }
.tmk-dialog .btn.warn:hover { filter: brightness(1.08); }
/* help 提示 */
.help { display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; font-size:12px;
border-radius:50%; background: rgba(255,255,255,.15); color:#fff; cursor: help; position:relative; }
.help:hover::after {
content: attr(data-tip); white-space: normal; position:absolute; left: 50%; transform: translateX(-50%);
bottom: 130%; min-width: 220px; max-width: 420px; background: rgba(20,20,20,.95); color:#eee;
padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,.08);
box-shadow: 0 10px 28px rgba(0,0,0,.35); text-align: left; line-height: 1.4; z-index: 1;
}
/* 关掉原站元素 */
#mangaBox, #mangaMoreBox, #loading, .pager, .sub-btn, .main-btn { display: none !important; }
`;
const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style);
// 工具条
const toolbar = document.createElement('div');
toolbar.className = 'tmk-toolbar';
toolbar.innerHTML = `
<div class="row">
<label><input type="checkbox" id="tmk-double" ${cfg.double ? 'checked' : ''}> 双页</label>
<label><input type="checkbox" id="tmk-rtl" ${cfg.rtl ? 'checked' : ''}> 右到左</label>
<label><input type="checkbox" id="tmk-first-single" ${cfg.firstSingle ? 'checked' : ''}> 首页单页</label>
<div class="sep"></div>
<button class="btn" id="tmk-prev">上一跨页</button>
<button class="btn" id="tmk-next">下一跨页</button>
<div class="sep"></div>
<button class="btn" id="tmk-settings">设置</button>
<div id="tmk-progress" style="margin-left:6px;opacity:.85;">加载 0/${totalPages}</div>
</div>
`;
document.body.appendChild(toolbar);
// 自动隐藏唤醒区
const reveal = document.createElement('div');
reveal.className = 'tmk-reveal';
document.body.appendChild(reveal);
// 设置面板(分组+帮助+四个按钮)
const modal = document.createElement('div');
modal.className = 'tmk-modal';
modal.innerHTML = `
<div class="tmk-dialog">
<h3>阅读设置</h3>
<div class="tmk-section">
<div class="tmk-section-title">交互</div>
<div class="row">
<label>点击翻页 <span class="help" data-tip="点击屏幕的半区来翻页。左右半屏:左=上一跨页,右=下一跨页。上下半屏:上=上一跨页,下=下一跨页。">?</span></label>
<select id="cfg-click-nav">
<option value="off" ${cfg.clickNav==='off'?'selected':''}>禁用</option>
<option value="lr" ${cfg.clickNav==='lr'?'selected':''}>左右半屏</option>
<option value="ud" ${cfg.clickNav==='ud'?'selected':''}>上下半屏</option>
</select>
</div>
<div class="row">
<label>自动隐藏菜单栏 <span class="help" data-tip="开启后,右上角菜单仅在鼠标移动或将鼠标移到右上角唤醒区时显示。">?</span></label>
<input type="checkbox" id="cfg-autohide" ${cfg.autoHideToolbar?'checked':''}>
</div>
<div class="row">
<label>一屏滚动 <span class="help" data-tip="开启后,滚轮一次滚动将按“跨页”为单位进行。">?</span></label>
<input type="checkbox" id="cfg-snap" ${cfg.snapWheel?'checked':''}>
</div>
<div class="row">
<label>一屏滚动时平滑滚动 <span class="help" data-tip="与“一屏滚动”配合:若开启,将平滑滚动到下一跨页;关闭则瞬间跳转。">?</span></label>
<input type="checkbox" id="cfg-snap-smooth" ${cfg.snapSmooth?'checked':''}>
</div>
<div class="row">
<label>底部跨页进度 <span class="help" data-tip="在底部显示当前跨页序号/总跨页数。">?</span></label>
<input type="checkbox" id="cfg-sp" ${cfg.showSpreadProgress?'checked':''}>
</div>
</div>
<div class="tmk-section">
<div class="tmk-section-title">布局</div>
<div class="row">
<label>跨页判定阈值(w/h) <span class="help" data-tip="当图片宽高比≥此阈值时视为“横图”,在双页模式下占据整行(跨页)。默认1.0即横图触发。">?</span></label>
<input type="number" id="cfg-cross" min="1.0" max="2.0" step="0.01" value="${cfg.crossAspect}">
</div>
<div class="row">
<label>双页左右间距(px)</label><input type="number" id="cfg-gap-h" min="0" max="64" step="1" value="${cfg.pageGapH}">
</div>
<div class="row">
<label>行间上下间距(px)</label><input type="number" id="cfg-gap-v" min="0" max="64" step="1" value="${cfg.spreadGapV}">
</div>
<div class="row">
<label>单页高度(CSS) <span class="help" data-tip="单个页面的显示高度。例如 100vh 表示屏幕高;也可写 calc(100vh - 64px)。">?</span></label>
<input type="text" id="cfg-page-height" value="${cfg.pageHeight}">
</div>
<div class="row">
<label>最大容器宽(px) <span class="help" data-tip="阅读区域的最大宽度上限。可根据屏幕调整。">?</span></label>
<input type="number" id="cfg-maxw" min="800" max="5000" step="10" value="${cfg.containerMaxW}">
</div>
</div>
<div class="tmk-section">
<div class="tmk-section-title">外观</div>
<div class="row"><label>显示页码</label><input type="checkbox" id="cfg-pn" ${cfg.showPN ? 'checked' : ''}></div>
<div class="row"><label>图片阴影</label><input type="checkbox" id="cfg-shadow" ${cfg.shadow ? 'checked' : ''}></div>
<div class="row">
<label>页面背景色</label><input type="color" id="cfg-body-bg" value="${toColor(cfg.bodyBg)}"><span>${cfg.bodyBg}</span>
</div>
<div class="row">
<label>容器背景色 <span class="help" data-tip="阅读容器(图片区域)背景色。">?</span></label><input type="color" id="cfg-gallery-bg" value="${toColor(cfg.galleryBg)}"><span>${cfg.galleryBg}</span>
</div>
</div>
<div class="tmk-section">
<div class="tmk-section-title">视图模式</div>
<div class="row">
<label><input type="checkbox" id="tmk-double" ${cfg.double ? 'checked' : ''}> 双页</label>
<label><input type="checkbox" id="tmk-rtl" ${cfg.rtl ? 'checked' : ''}> 右到左</label>
<label><input type="checkbox" id="tmk-first-single" ${cfg.firstSingle ? 'checked' : ''}> 首页单页</label>
</div>
</div>
<div class="actions">
<button class="btn warn" id="cfg-reset">重置</button>
<button class="btn" id="cfg-cancel">取消</button>
<button class="btn" id="cfg-apply">应用</button>
<button class="btn" id="cfg-save-close">保存并关闭</button>
</div>
</div>
`;
document.body.appendChild(modal);
function toColor(v){const s=String(v||'').trim();return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)?s:'#000000';}
// 底部跨页进度
const spreadProg = document.createElement('div');
spreadProg.className = 'tmk-bottom-progress' + (cfg.showSpreadProgress?'':' hidden');
spreadProg.textContent = '';
document.body.appendChild(spreadProg);
// “下一章”按钮
const nextChapterBtn = document.createElement('div');
nextChapterBtn.className = 'tmk-next-chapter';
nextChapterBtn.textContent = '下一章 ▶';
nextChapterBtn.addEventListener('click', goNextChapter);
document.body.appendChild(nextChapterBtn);
// 容器
const gallery = document.createElement('div');
gallery.className = 'tmk-gallery';
if (cfg.shadow) gallery.classList.add('tmk-shadow');
if (!cfg.showPN) gallery.classList.add('hide-pn');
document.querySelector('#tbBox')?.appendChild(gallery) || document.body.appendChild(gallery);
// 状态
let isDouble = cfg.double;
let isRTL = cfg.rtl;
let firstSingle = cfg.firstSingle;
let active = 0;
let loadedCount = 0;
const state = new Array(totalPages + 1).fill(0); // 0 pending, 1 loading, 2 done
const attempts = new Array(totalPages + 1).fill(0);
const nextTime = new Array(totalPages + 1).fill(0);
const dims = new Array(totalPages + 1).fill(null);
const isWide = new Array(totalPages + 1).fill(null);
let spreadsCache = [];
let lastLayoutRows = [];
// 工具条按钮
const progressEl = document.querySelector('#tmk-progress');
const btnPrev = toolbar.querySelector('#tmk-prev');
const btnNext = toolbar.querySelector('#tmk-next');
const btnSettings = toolbar.querySelector('#tmk-settings');
btnPrev.addEventListener('click', () => scrollToSpread(nearestSpread() - 1, true));
btnNext.addEventListener('click', () => scrollToSpread(nearestSpread() + 1, true));
btnSettings.addEventListener('click', () => {
if (cfg.autoHideToolbar) toolbar.classList.add('show');
modal.classList.add('show');
});
// 右上角工具栏复选框事件监听
const toolbarDouble = toolbar.querySelector('#tmk-double');
const toolbarRTL = toolbar.querySelector('#tmk-rtl');
const toolbarFirst = toolbar.querySelector('#tmk-first-single');
toolbarDouble.addEventListener('change', () => {
isDouble = toolbarDouble.checked;
cfg.double = isDouble;
saveCfg(cfg);
// 同步设置面板
modal.querySelector('#tmk-double').checked = isDouble;
render(true);
});
toolbarRTL.addEventListener('change', () => {
isRTL = toolbarRTL.checked;
cfg.rtl = isRTL;
saveCfg(cfg);
// 同步设置面板
modal.querySelector('#tmk-rtl').checked = isRTL;
render(true);
});
toolbarFirst.addEventListener('change', () => {
firstSingle = toolbarFirst.checked;
cfg.firstSingle = firstSingle;
saveCfg(cfg);
// 同步设置面板
modal.querySelector('#tmk-first-single').checked = firstSingle;
render(true);
});
// 自动隐藏:唤醒逻辑
function toolbarShow(show){
if (!cfg.autoHideToolbar) return;
toolbar.classList.toggle('show', !!show);
}
function applyAutohide(){
toolbar.classList.toggle('auto-hide', cfg.autoHideToolbar);
if (!cfg.autoHideToolbar) toolbar.classList.add('show');
}
applyAutohide();
let hoverTimer = null, lastMove = 0;
const showFor = (ms)=>{ toolbarShow(true); clearTimeout(hoverTimer); hoverTimer = setTimeout(()=>toolbarShow(false), ms); };
reveal.addEventListener('mouseenter', ()=> toolbarShow(true));
reveal.addEventListener('mouseleave', ()=> toolbarShow(false));
toolbar.addEventListener('mouseenter', ()=> toolbarShow(true));
toolbar.addEventListener('mouseleave', ()=> toolbarShow(false));
window.addEventListener('mousemove', ()=>{
if (!cfg.autoHideToolbar) return;
if (Date.now() - lastMove > 800) toolbarShow(true);
lastMove = Date.now(); showFor(1500);
});
// 设置面板逻辑
const ui = {
clickNav: modal.querySelector('#cfg-click-nav'),
autohide: modal.querySelector('#cfg-autohide'),
snap: modal.querySelector('#cfg-snap'),
snapSmooth: modal.querySelector('#cfg-snap-smooth'),
gapH: modal.querySelector('#cfg-gap-h'),
gapV: modal.querySelector('#cfg-gap-v'),
pageH: modal.querySelector('#cfg-page-height'),
maxW: modal.querySelector('#cfg-maxw'),
cross: modal.querySelector('#cfg-cross'),
pn: modal.querySelector('#cfg-pn'),
shadow: modal.querySelector('#cfg-shadow'),
bodyBg: modal.querySelector('#cfg-body-bg'),
galleryBg: modal.querySelector('#cfg-gallery-bg'),
sp: modal.querySelector('#cfg-sp'),
cancel: modal.querySelector('#cfg-cancel'),
reset: modal.querySelector('#cfg-reset'),
apply: modal.querySelector('#cfg-apply'),
saveClose: modal.querySelector('#cfg-save-close'),
chkDouble: modal.querySelector('#tmk-double'),
chkRTL: modal.querySelector('#tmk-rtl'),
chkFirst: modal.querySelector('#tmk-first-single')
};
modal.addEventListener('click', (e)=>{ if(e.target===modal) modal.classList.remove('show'); });
ui.reset.addEventListener('click', ()=>{
// 重置输入框到默认值,但不立即应用
ui.clickNav.value = DEFAULTS.clickNav;
ui.autohide.checked = DEFAULTS.autoHideToolbar;
ui.snap.checked = DEFAULTS.snapWheel;
ui.snapSmooth.checked = DEFAULTS.snapSmooth;
ui.gapH.value = DEFAULTS.pageGapH;
ui.gapV.value = DEFAULTS.spreadGapV;
ui.pageH.value = DEFAULTS.pageHeight;
ui.maxW.value = DEFAULTS.containerMaxW;
ui.cross.value = DEFAULTS.crossAspect;
ui.pn.checked = DEFAULTS.showPN;
ui.shadow.checked = DEFAULTS.shadow;
ui.bodyBg.value = toColor(DEFAULTS.bodyBg);
ui.bodyBg.nextElementSibling.textContent = DEFAULTS.bodyBg;
ui.galleryBg.value = toColor(DEFAULTS.galleryBg);
ui.galleryBg.nextElementSibling.textContent = DEFAULTS.galleryBg;
ui.sp.checked = DEFAULTS.showSpreadProgress;
ui.chkDouble.checked = DEFAULTS.double;
ui.chkRTL.checked = DEFAULTS.rtl;
ui.chkFirst.checked = DEFAULTS.firstSingle;
});
ui.cancel.addEventListener('click', ()=> modal.classList.remove('show'));
function pickFormToCfg() {
return {
...cfg,
clickNav: ui.clickNav.value,
autoHideToolbar: !!ui.autohide.checked,
snapWheel: !!ui.snap.checked,
snapSmooth: !!ui.snapSmooth.checked,
pageGapH: clamp(+ui.gapH.value||0,0,64),
spreadGapV: clamp(+ui.gapV.value||0,0,64),
pageHeight: (ui.pageH.value||'100vh').trim(),
containerMaxW: clamp(+ui.maxW.value||DEFAULTS.containerMaxW,800,5000),
crossAspect: clamp(+ui.cross.value||DEFAULTS.crossAspect,1.0,2.0),
showPN: !!ui.pn.checked,
shadow: !!ui.shadow.checked,
bodyBg: ui.bodyBg.value || DEFAULTS.bodyBg,
galleryBg: ui.galleryBg.value || DEFAULTS.galleryBg,
showSpreadProgress: !!ui.sp.checked,
double: !!ui.chkDouble.checked,
rtl: !!ui.chkRTL.checked,
firstSingle: !!ui.chkFirst.checked
};
}
ui.apply.addEventListener('click', ()=>{
Object.assign(cfg, pickFormToCfg());
applyCfg(); rejudgeWide(); render(true);
showFor(1200); // 提示显示菜单
});
ui.saveClose.addEventListener('click', ()=>{
Object.assign(cfg, pickFormToCfg());
saveCfg(cfg); applyCfg(); rejudgeWide(); render(true);
modal.classList.remove('show');
showFor(1200);
});
function clamp(v,a,b){return Math.min(b,Math.max(a,v));}
// 外观/行为应用
function applyCfg(){
// 根变量刷新
style.textContent = style.textContent.replace(/:root\{[\s\S]*?\}/, () => {
return `:root{
--page-height: ${cfg.pageHeight};
--spread-gap-v: ${cfg.spreadGapV}px;
--page-gap-h: ${cfg.pageGapH}px;
--max-container-width: ${cfg.containerMaxW}px;
--gallery-bg: ${cfg.galleryBg};
}`;
});
document.body.style.background = cfg.bodyBg;
gallery.style.background = cfg.galleryBg;
gallery.classList.toggle('tmk-shadow', cfg.shadow);
gallery.classList.toggle('hide-pn', !cfg.showPN);
spreadProg.classList.toggle('hidden', !cfg.showSpreadProgress);
toolbar.classList.toggle('auto-hide', cfg.autoHideToolbar);
if (!cfg.autoHideToolbar) toolbar.classList.add('show');
// color旁边显示文本
ui.bodyBg.nextElementSibling.textContent = cfg.bodyBg;
ui.galleryBg.nextElementSibling.textContent = cfg.galleryBg;
// 视图模式(复选框同步到主工具条)
const mainDouble = toolbar.querySelector('#tmk-double');
const mainRTL = toolbar.querySelector('#tmk-rtl');
const mainFirst = toolbar.querySelector('#tmk-first-single');
if (mainDouble) mainDouble.checked = cfg.double;
if (mainRTL) mainRTL.checked = cfg.rtl;
if (mainFirst) mainFirst.checked = cfg.firstSingle;
// 一屏滚动绑定
bindSnapWheel(cfg.snapWheel);
}
// ========== 布局 & 渲染 ==========
function computeLayout() {
const rows = [];
if (!isDouble) { for (let i = 1; i <= totalPages; i++) rows.push({ type: 'single', pages: [i] }); return rows; }
let i = 1;
if (i <= totalPages) {
const w = isWide[i] === true;
if (firstSingle && !w) { rows.push({ type: 'single-only', pages: [i] }); i++; }
}
while (i <= totalPages) {
const w = isWide[i] === true;
if (w) { rows.push({ type: 'full', pages: [i] }); i++; continue; }
if (i + 1 <= totalPages && isWide[i + 1] === true) { rows.push({ type: 'single-only', pages: [i] }); i++; continue; }
rows.push({ type: 'pair', pages: [i, i + 1].filter(x => x <= totalPages) }); i += 2;
}
return rows;
}
function getAnchorPageIdx() {
const slots = Array.from(document.querySelectorAll('.page-slot[data-idx]'));
if (!slots.length) return 1;
let best=1, dst=Infinity;
for (const el of slots) { const d = Math.abs(el.getBoundingClientRect().top); if (d < dst) { dst=d; best=parseInt(el.dataset.idx,10); } }
return best;
}
function pageToSpreadIndex(pageIdx) {
for (let s=0; s<lastLayoutRows.length; s++) { if (lastLayoutRows[s].pages.includes(pageIdx)) return s; }
return 0;
}
function render(preserve=false) {
const anchor = preserve ? getAnchorPageIdx() : null;
gallery.classList.toggle('double', isDouble);
gallery.classList.toggle('single', !isDouble);
while (gallery.firstChild) gallery.removeChild(gallery.firstChild);
spreadsCache = [];
lastLayoutRows = computeLayout();
for (const row of lastLayoutRows) {
const spread = document.createElement('div');
spread.className = `tmk-spread ${row.type}`;
if (isDouble && (row.type === 'pair' || row.type === 'single-only') && isRTL) spread.classList.add('rtl');
if (row.type === 'single') {
spread.appendChild(makePageSlot(row.pages[0]));
} else if (row.type === 'full') {
spread.appendChild(makePageSlot(row.pages[0]));
} else if (row.type === 'single-only') {
spread.appendChild(makePageSlot(row.pages[0]));
} else if (row.type === 'pair') {
const [a,b] = row.pages;
spread.appendChild(makePageSlot(a)); if (b) spread.appendChild(makePageSlot(b));
}
gallery.appendChild(spread); spreadsCache.push(spread);
}
if (anchor != null) {
const el = document.querySelector(`.page-slot[data-idx="${anchor}"]`);
if (el) el.scrollIntoView({ block: 'start' });
} else {
const saved = loadPos();
if (saved && saved>=1 && saved<=totalPages) { const si = pageToSpreadIndex(saved); setTimeout(()=> scrollToSpread(si, true), 60); }
}
tick(); // 加载调度
updateSpreadProgress();
checkEndHint();
}
function makePageSlot(i) {
const slot = document.createElement('div');
slot.className = `page-slot ${state[i] === 2 ? 'loaded' : ''}`;
slot.dataset.idx = String(i);
const ph = document.createElement('div');
ph.className = 'ph';
ph.innerHTML = `<div class="ring"></div><div class="txt">加载中…</div>`;
slot.appendChild(ph);
const img = document.createElement('img');
img.alt = `第 ${i} 页`;
img.dataset.idx = String(i);
if (state[i] === 2) img.src = urls[i];
slot.appendChild(img);
if (cfg.showPN) { const pn = document.createElement('div'); pn.className = 'pn'; pn.textContent = i; slot.appendChild(pn); }
return slot;
}
function nearestSpread() {
let nearest = 0, best = Infinity;
spreadsCache.forEach((el,i)=>{ const d = Math.abs(el.getBoundingClientRect().top); if (d < best) {best=d; nearest=i;} });
return nearest;
}
function scrollToSpread(idx, smooth = true) {
if (!spreadsCache.length) return;
const clamped = Math.max(0, Math.min(idx, spreadsCache.length - 1));
spreadsCache[clamped].scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'start' });
setTimeout(()=>{ updateSpreadProgress(); checkEndHint(); }, 60);
}
function updateSpreadProgress(){
const si = nearestSpread();
const row = lastLayoutRows[si];
spreadProg.textContent = `跨页 ${si+1} / ${spreadsCache.length || 0}`;
if (row && row.pages && row.pages.length) savePos(row.pages[0]);
}
function checkEndHint() {
// 当最后一个跨页已在视口内底部区域,显示“下一章”
const last = spreadsCache[spreadsCache.length - 1];
if (!last) { nextChapterBtn.classList.remove('show'); return; }
const rect = last.getBoundingClientRect();
const vh = window.innerHeight;
const nearBottom = rect.top < vh * 0.6 && rect.bottom <= vh + 80;
// or 已经滚到底
const atEnd = (window.scrollY + vh) >= (document.documentElement.scrollHeight - 4);
if (nearBottom || atEnd) nextChapterBtn.classList.add('show');
else nextChapterBtn.classList.remove('show');
}
function goNextChapter() {
if (window.SMH?.nextC) { try { window.SMH.nextC(); return; } catch {} }
const a = document.querySelector('.main-btn .nextC, .pager .next');
if (a) { a.click(); return; }
// 兜底:跳回目录
const list = document.querySelector('#viewList'); if (list) location.href = list.href;
}
// 键盘 & 点击翻页
window.addEventListener('keydown', (e)=>{
if (e.target && /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return;
switch (e.key) {
case 'ArrowLeft': scrollToSpread(nearestSpread()-1, true); e.preventDefault(); break;
case 'ArrowRight': scrollToSpread(nearestSpread()+1, true); e.preventDefault(); break;
case ' ': // 空格
if (e.shiftKey) scrollToSpread(nearestSpread()-1, true);
else scrollToSpread(nearestSpread()+1, true);
e.preventDefault(); break;
case 'Enter': scrollToSpread(nearestSpread()+1, true); e.preventDefault(); break;
case 'Home': scrollToSpread(0, true); e.preventDefault(); break;
case 'End': scrollToSpread(spreadsCache.length-1, true); e.preventDefault(); break;
case 'z': case 'Z':
isRTL = !isRTL; cfg.rtl=isRTL; saveCfg(cfg);
modal.querySelector('#tmk-rtl').checked = isRTL; // 同步设置面板
render(true); break;
case 'f': case 'F':
if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(()=>{});
else document.exitFullscreen().catch(()=>{});
break;
case 'g': case 'G':
const to = prompt(`跳转到页码 (1-${totalPages}):`);
if (!to) break;
const n = parseInt(to,10);
if (Number.isFinite(n) && n>=1 && n<=totalPages) scrollToSpread(pageToSpreadIndex(n), true);
break;
case 'd': case 'D': // 双击替代:快捷键 D 切换单双页
isDouble = !isDouble; cfg.double=isDouble; saveCfg(cfg);
modal.querySelector('#tmk-double').checked = isDouble;
render(true); break;
}
}, { passive:false });
// 双击页面:切换单双页
gallery.addEventListener('dblclick', (e)=>{
isDouble = !isDouble; cfg.double=isDouble; saveCfg(cfg);
modal.querySelector('#tmk-double').checked = isDouble;
render(true);
});
// 点击半屏翻页
gallery.addEventListener('click', (e)=>{
if (modal.classList.contains('show')) return;
if (e.target.closest('.tmk-toolbar')) return;
if (cfg.clickNav === 'off') return;
if ((e.target.closest('a,button,label,select,input,textarea'))) return;
const vw = window.innerWidth, vh = window.innerHeight;
const x = e.clientX, y = e.clientY;
if (cfg.clickNav === 'lr') {
if (x < vw/2) scrollToSpread(nearestSpread()-1, true);
else scrollToSpread(nearestSpread()+1, true);
e.preventDefault();
} else if (cfg.clickNav === 'ud') {
if (y < vh/2) scrollToSpread(nearestSpread()-1, true);
else scrollToSpread(nearestSpread()+1, true);
e.preventDefault();
}
});
// 一屏滚动:滚轮=>按跨页前后;是否平滑由 cfg.snapSmooth 控制
let lastWheel = 0;
function wheelHandler(e){
const now = Date.now();
if (now - lastWheel < 300) { e.preventDefault(); return; } // 节流
lastWheel = now;
if (Math.abs(e.deltaY) < 3) return; // 触控板小抖动忽略
if (e.deltaY > 0) scrollToSpread(nearestSpread()+1, !!cfg.snapSmooth);
else scrollToSpread(nearestSpread()-1, !!cfg.snapSmooth);
e.preventDefault();
}
function bindSnapWheel(on){
window.removeEventListener('wheel', wheelHandler, { passive:false });
if (on) window.addEventListener('wheel', wheelHandler, { passive:false });
}
// ========== 队列加载(顺序优先+限速+超时+指数退避) ==========
function updateProgress(){ if (progressEl) progressEl.textContent = `加载 ${loadedCount}/${totalPages}`; }
function rejudgeWide(){ for (let i=1;i<=totalPages;i++) if (dims[i]) isWide[i] = (dims[i].w/dims[i].h)>=cfg.crossAspect; }
function backoff(attempt){ const base=500; return Math.min(30000, base*Math.pow(2,Math.max(0,attempt-1))) + Math.random()*300; }
function pickNextIndex(){
const now=Date.now();
for (let i=1;i<=totalPages;i++){ if (state[i]===0 && now >= (nextTime[i]||0)) return i; }
return 0;
}
function startLoad(i){
state[i]=1; attempts[i]++; active++;
const url=urls[i]; const toMs=Math.max(3000, TIMEOUT_SEC*1000);
let done=false, timer=null;
const onDone=(ok,w,h)=>{
if (done) return; done=true; active--; clearTimeout(timer);
const slot = document.querySelector(`.page-slot[data-idx="${i}"]`);
if (ok){
if (state[i]!==2){
state[i]=2; loadedCount++; updateProgress();
if (w && h){
dims[i]={w,h};
const was = isWide[i];
const nowFlag = (w/h)>=cfg.crossAspect;
isWide[i]=nowFlag;
if (was!==nowFlag){ render(true); return; }
}
const img = slot?.querySelector('img[data-idx]');
if (img && !img.src) img.src = url;
slot?.classList.add('loaded');
}
} else {
state[i]=0; nextTime[i]=Date.now()+backoff(attempts[i]);
const txt = slot?.querySelector('.ph .txt'); if (txt) txt.textContent = `重试中…(#${attempts[i]})`;
}
tick();
};
const tmp = new Image();
tmp.onload = ()=> onDone(true, tmp.naturalWidth||0, tmp.naturalHeight||0);
tmp.onerror = ()=> onDone(false);
timer = setTimeout(()=> onDone(false), toMs);
tmp.src = url;
}
function tick(){
while (active < CONCURRENCY){
const idx = pickNextIndex();
if (!idx) break;
startLoad(idx);
}
if (loadedCount === totalPages) updateProgress();
}
// 初始应用与渲染
applyCfg();
updateProgress();
render(false);
// 滚动时更新进度/保存位置/章节末提示
window.addEventListener('scroll', ()=> { updateSpreadProgress(); checkEndHint(); }, { passive:true });
// 一屏滚动初始绑定
bindSnapWheel(cfg.snapWheel);
}
})();