E站 阅读加强 自适应点对点缩放+预载图片 v4 — 嵌入原网页,保留原有样式,向下滚动自动加载,失败自动重试,阅读记忆
// ==UserScript==
// @name ExHentai Reader Pro
// @namespace EHReaderPro
// @version 4.4.0
// @description E站 阅读加强 自适应点对点缩放+预载图片 v4 — 嵌入原网页,保留原有样式,向下滚动自动加载,失败自动重试,阅读记忆
// @author EHReader
// @match https://exhentai.org/s/*
// @match https://e-hentai.org/s/*
// @run-at document-end
// @grant none
// ==/UserScript==
(function () {
"use strict";
// =================================================
// 全局配置与状态
// =================================================
const EHR = {
version: "4.4.0",
config: {
mode: "auto", // pixel=1:1像素(可横向滚动) | auto=自适应可视宽
scale: 100, // 缩放百分比
preloadDown: 3, // 向下预载页数
preloadUp: 1, // 向上预载页数(仅滚动到顶触发)
cacheLimit: 500, // 超出此范围的页释放内存
showImgInfo: true, // 是否显示图片信息
},
state: {
gid: "",
startPage: 1,
startKey: "",
activePage: 1,
totalPages: 0,
pageKeys: new Map(),
pageData: new Map(),
loadedPages: new Set(),
loadingPages: new Set(),
container: null,
},
};
// =================================================
// 配置持久化
// =================================================
function loadConfig() {
try {
const saved = JSON.parse(localStorage.getItem("EHReader_config") || "{}");
Object.assign(EHR.config, saved);
} catch (e) {}
}
function saveConfig() {
localStorage.setItem("EHReader_config", JSON.stringify(EHR.config));
}
loadConfig();
// =================================================
// IndexedDB URL缓存(LRU)
// =================================================
const EHCache = (() => {
const DB_NAME = "EHReaderURLCache";
const ST_NAME = "pages";
let _db = null;
async function openDB() {
if (_db) return _db;
return new Promise((res, rej) => {
// v3:records 含 url/info/pageKey 字段,旧版记录兼容(缺字段默认空串)
const req = indexedDB.open(DB_NAME, 3);
req.onupgradeneeded = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains(ST_NAME)) {
const st = db.createObjectStore(ST_NAME, { keyPath: "key" });
st.createIndex("ts", "ts", { unique: false });
}
// v1/v2 → v3:无需改 schema,pageKey 字段随 put 写入即可
};
req.onsuccess = e => { _db = e.target.result; res(_db); };
req.onerror = e => rej(e.target.error);
});
}
async function get(gid, page) {
try {
const db = await openDB();
const key = gid + "_" + page;
return new Promise(res => {
const tx = db.transaction(ST_NAME, "readwrite");
const st = tx.objectStore(ST_NAME);
const req = st.get(key);
req.onsuccess = e => {
const rec = e.target.result;
if (rec) { rec.ts = Date.now(); st.put(rec); }
// 返回 { url, info, pageKey },pageKey 为该页自身的 key(旧缓存兼容空串)
res(rec ? { url: rec.url, info: rec.info || "", pageKey: rec.pageKey || "" } : null);
};
req.onerror = () => res(null);
});
} catch(e) { return null; }
}
async function put(gid, page, url, info, pageKey) {
try {
const db = await openDB();
const key = gid + "_" + page;
return new Promise(res => {
const tx = db.transaction(ST_NAME, "readwrite");
const st = tx.objectStore(ST_NAME);
// url/info/pageKey 一并写入,下次命中时无需额外 fetch
st.put({ key, url, info: info || "", pageKey: pageKey || "", ts: Date.now() });
tx.oncomplete = () => evict(db).then(res);
tx.onerror = () => res();
});
} catch(e) {}
}
async function evict(db) {
const maxRows = Math.max(10, EHR.config.cacheLimit || 500);
return new Promise(res => {
const tx = db.transaction(ST_NAME, "readwrite");
const st = tx.objectStore(ST_NAME);
const idx = st.index("ts");
const all = [];
idx.openCursor().onsuccess = e => {
const cur = e.target.result;
if (cur) { all.push(cur.value.key); cur.continue(); }
else {
const del = all.length - maxRows;
for (let i = 0; i < del; i++) st.delete(all[i]);
res();
}
};
tx.onerror = () => res();
});
}
async function getCount() {
try {
const db = await openDB();
return new Promise(res => {
const tx = db.transaction(ST_NAME, "readonly");
const st = tx.objectStore(ST_NAME);
const req = st.count();
req.onsuccess = e => res(e.target.result);
req.onerror = () => res(0);
});
} catch(e) { return 0; }
}
async function remove(gid, page) {
try {
const db = await openDB();
const key = gid + "_" + page;
return new Promise(res => {
const tx = db.transaction(ST_NAME, "readwrite");
tx.objectStore(ST_NAME).delete(key);
tx.oncomplete = () => res();
tx.onerror = () => res();
});
} catch(e) {}
}
return { get, put, remove, getCount };
})();
// =================================================
// URL 解析
// =================================================
function parseURL(href) {
const m = (href || location.href).match(/\/s\/([^/]+)\/(\d+)-(\d+)/);
if (m) return { key: m[1], gid: m[2], page: Number(m[3]) };
return { key: "", gid: "", page: 1 };
}
const _cur = parseURL();
EHR.state.gid = _cur.gid;
EHR.state.startPage = _cur.page;
EHR.state.startKey = _cur.key;
EHR.state.activePage = _cur.page;
if (_cur.key) EHR.state.pageKeys.set(_cur.page, _cur.key);
// =================================================
// 从 DOM 采集页码→key 映射
// =================================================
function harvestPageKeys() {
document.querySelectorAll("a[href]").forEach(a => {
const m = a.href.match(/\/s\/([^/]+)\/\d+-(\d+)/);
if (m) {
const page = Number(m[2]);
if (!EHR.state.pageKeys.has(page)) EHR.state.pageKeys.set(page, m[1]);
}
});
}
// =================================================
// 检测总页数
// =================================================
function detectTotalPages() {
const sn = document.querySelector(".sn");
if (sn) {
const m = sn.textContent.match(/of\s+(\d+)/i);
if (m) return Number(m[1]);
const spans = sn.querySelectorAll("span");
spans.forEach(s => {
const n = s.textContent.match(/\d+/);
if (n && Number(n[0]) > 1) EHR.state.totalPages = Math.max(EHR.state.totalPages, Number(n[0]));
});
if (EHR.state.totalPages > 1) return EHR.state.totalPages;
}
const scripts = document.querySelectorAll("script:not([src])");
for (const s of scripts) {
const m = s.textContent.match(/var\s+prl\s*=\s*(\d+)/);
if (m && Number(m[1]) < 9999) return Number(m[1]);
}
return 0;
}
function extractTotalPagesFromDoc(doc) {
const scripts = doc.querySelectorAll("script:not([src])");
for (const s of scripts) {
const m = s.textContent.match(/var\s+prl\s*=\s*(\d+)/);
if (m && Number(m[1]) < 9999) return Number(m[1]);
}
return 0;
}
// =================================================
// 构造页面 URL
// =================================================
function buildPageURL(page) {
const key = EHR.state.pageKeys.get(page);
if (!key) return null;
return `${location.origin}/s/${key}/${EHR.state.gid}-${page}`;
}
// =================================================
// HTTP fetch,带重试
// =================================================
async function fetchDoc(url, retries = 2) {
for (let i = 0; i <= retries; i++) {
try {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
const parser = new DOMParser();
return parser.parseFromString(text, "text/html");
} catch (e) {
if (i === retries) return null;
await new Promise(r => setTimeout(r, 800));
}
}
}
// =================================================
// 解析单图页,获取图片URL、文件信息、nl链接、存储相邻页key
// =================================================
async function parsePage(url) {
const doc = await fetchDoc(url);
if (!doc) return null;
// 采集相邻页 key:精确选择器优先,采集不到时降级为全量遍历(兼容改版)
{
const EH_LINK_RE = /\/s\/([^/]+)\/\d+-(\d+)/;
let found = 0;
// 优先精确选择器
for (const sel of ["#i3 a[href]", "#i4 a[href]", "#pnp a[href]", "a[onclick*='nl(']"]) {
doc.querySelectorAll(sel).forEach(a => {
const m = a.href && a.href.match(EH_LINK_RE);
if (m) { found++; const pg = Number(m[2]); if (!EHR.state.pageKeys.has(pg)) EHR.state.pageKeys.set(pg, m[1]); }
});
}
// 精确选择器未采集到时,全量遍历兜底
if (!found) {
doc.querySelectorAll("a[href]").forEach(a => {
const m = a.href.match(EH_LINK_RE);
if (m) { const pg = Number(m[2]); if (!EHR.state.pageKeys.has(pg)) EHR.state.pageKeys.set(pg, m[1]); }
});
}
// key 已写入 pageKeys(相邻页),本页 key 由 resolvePage 在 data.pageKey 里传递
}
// 更新总页数
if (!EHR.state.totalPages) {
const t = extractTotalPagesFromDoc(doc);
if (t) EHR.state.totalPages = t;
}
// 找图片(多选择器兼容改版)
const img = doc.querySelector("#img")
|| doc.querySelector("img[id='img']")
|| doc.querySelector("#i3 img")
|| doc.querySelector(".image-container img")
|| doc.querySelector("img[src*='ehgt.org']")
|| doc.querySelector("img[src*='hath.network']")
|| doc.querySelector("img[src*='exhentai.org/t/']")
|| doc.querySelector("img[src*='e-hentai.org/t/']");
if (!img) return null;
const imgSrc = img.src || img.dataset.src || img.getAttribute("data-src") || "";
if (!imgSrc) return null;
// 提取图片信息文本(原网页 #i2 第二个div 或 #i4 第一个div,格式:filename :: W x H :: size)
const imgInfo = extractImgInfo(doc);
return { url: imgSrc, info: imgInfo };
}
// 从文档中提取图片信息字符串("filename :: W x H :: size")
// 多层容错,兼容网页改版:依次尝试已知选择器 → 全文正则匹配 → img alt
function extractImgInfo(doc) {
// 候选选择器(按优先级):覆盖已知结构及可能的改版
const candidates = [
"#i4 > div:first-child", // 当前主结构
"#i4 div", // i4 下任意 div(改版 first-child 位置变化时)
"#i2 > div:last-child", // 旧版结构
"#i2 div", // i2 下任意 div
".image-info", // 可能的新 class 名
"[data-src] + div", // 图片后紧跟的 div
"#img ~ div", // img 同级 div
];
// 图片信息正则:filename.ext :: WxH :: size(支持 KiB/MiB/KB/MB/B)
const infoRE = /[\w\-. ]+\.\w{2,5}\s*::\s*\d+\s*x\s*\d+\s*::\s*[\d.,]+\s*[KMGkmg]?i?[Bb]/;
for (const sel of candidates) {
try {
const els = doc.querySelectorAll(sel);
for (const el of els) {
const txt = el.textContent.replace(/\s+/g, " ").trim();
if (infoRE.test(txt)) return txt.match(infoRE)[0];
}
} catch(e) {}
}
// 全文正则兜底:扫描整个 body 文本
const bodyTxt = (doc.body || doc.documentElement).textContent.replace(/\s+/g, " ");
const m = bodyTxt.match(infoRE);
if (m) return m[0];
// 最终降级:img alt
const imgEl = doc.querySelector("#img");
if (imgEl && imgEl.alt) return imgEl.alt;
return "";
}
// 从当前页面提取图片信息(第一页,不需要 fetch)复用 extractImgInfo 逻辑
function extractCurrentImgInfo() {
return extractImgInfo(document);
}
// =================================================
// 并发控制 Semaphore(限制同时 fetch 数,防止网卡时请求堆积)
// =================================================
const _sem = (() => {
const MAX = 3;
let _running = 0;
const _queue = [];
function next() {
if (_running >= MAX || !_queue.length) return;
_running++;
const { fn, res, rej } = _queue.shift();
fn().then(v => { _running--; res(v); next(); })
.catch(e => { _running--; rej(e); next(); });
}
return {
run(fn) {
return new Promise((res, rej) => { _queue.push({ fn, res, rej }); next(); });
}
};
})();
// =================================================
// 解析某页数据(IDB缓存命中 → in-flight去重 → fetch,内存上限)
// =================================================
// in-flight 去重:同一页并发调用共享同一个 Promise,避免重复 fetch
const _inflightMap = new Map();
async function resolvePage(page) {
// 1. 内存缓存命中
if (EHR.state.pageData.has(page)) return EHR.state.pageData.get(page);
// 2. in-flight 去重:同一页已在请求中,直接等待该 Promise
if (_inflightMap.has(page)) return _inflightMap.get(page);
// in-flight Promise 必须在 IIFE 启动前注册,防止 await 间隙重入
let _resolvePromise;
_resolvePromise = (async () => {
// 3. 先查 IndexedDB(命中时 url + info 同时返回,零额外 fetch)
const cached = await EHCache.get(EHR.state.gid, page);
if (cached && cached.url) {
// 恢复 pageKey 到 Map,并附加到 data 对象
if (cached.pageKey && !EHR.state.pageKeys.has(page)) {
EHR.state.pageKeys.set(page, cached.pageKey);
}
const data = { url: cached.url, info: cached.info || "", pageKey: cached.pageKey || "" };
EHR.state.pageData.set(page, data);
return data;
}
// IDB 记录 url 无效(过期/损坏)时删除,重新 fetch
if (cached && !cached.url) {
// 将在下一步重新 fetch,不做特殊处理
}
// 4. 需要发现 key 才能构造 URL
if (!EHR.state.pageKeys.has(page)) await discoverKey(page);
const url = buildPageURL(page);
if (!url) return null;
// 5. 受 Semaphore 限流的 HTML fetch
const data = await _sem.run(() => parsePage(url));
if (data) {
// 把本页的 pageKey 附加到 data,供 createPageBlock onload 时写入 IDB
data.pageKey = EHR.state.pageKeys.get(page) || "";
EHR.state.pageData.set(page, data);
// pageData 超出 cacheLimit*2 时清理最远页,防止内存无限增长
const maxCache = (EHR.config.cacheLimit || 500) * 2;
if (EHR.state.pageData.size > maxCache) {
const cur = EHR.state.activePage;
const keys = [...EHR.state.pageData.keys()]
.sort((a, b) => Math.abs(a - cur) - Math.abs(b - cur));
keys.slice(maxCache).forEach(k => EHR.state.pageData.delete(k));
}
}
return data;
})();
_inflightMap.set(page, _resolvePromise);
try {
return await _resolvePromise;
} finally {
_inflightMap.delete(page);
}
}
// discoverKey 全局 in-flight 去重(同一目标页只跑一次,节省配额)
const _discoverInflight = new Map();
async function discoverKey(targetPage) {
if (EHR.state.pageKeys.has(targetPage)) return;
if (_discoverInflight.has(targetPage)) return _discoverInflight.get(targetPage);
if (!EHR.state.pageKeys.size) return;
const p = (async () => {
let nearest = -1, minDist = Infinity;
EHR.state.pageKeys.forEach((_, p) => {
const d = Math.abs(p - targetPage);
if (d < minDist) { minDist = d; nearest = p; }
});
if (nearest === -1) return;
let cur = nearest;
const dir = targetPage > nearest ? 1 : -1;
const steps = Math.abs(targetPage - nearest) + 1;
for (let i = 0; i < steps; i++) {
if (EHR.state.pageKeys.has(targetPage)) break;
const url = buildPageURL(cur);
if (!url) break;
await _sem.run(() => parsePage(url));
cur += dir;
}
})();
_discoverInflight.set(targetPage, p);
try { await p; } finally { _discoverInflight.delete(targetPage); }
}
// =================================================
// 提取"返回图库预览"链接 href
// =================================================
function extractGalleryBackLink() {
// 原网页 #i5 > div.sb > a[href*="/g/"] > img[src*="b.png"]
const imgInSb = document.querySelector("#i5 a[href*='/g/']");
if (imgInSb) return imgInSb.href;
// 降级:含 img/b.png 的 <a>
const bImg = document.querySelector('a[href*="/g/"] img[src*="img/b.png"]');
if (bImg) return bImg.closest("a").href;
return "";
}
// =================================================
// 初始化原页面:隐藏导航/按钮/图库链接等,嵌入容器
// =================================================
function patchOriginalPage() {
// 1. #i2 完整隐藏
const i2 = document.querySelector("#i2");
if (i2) i2.style.display = "none";
// 2. #i4 整体隐藏
const i4 = document.querySelector("#i4");
if (i4) i4.style.display = "none";
// 3. #i5 整体隐藏(返回图库功能由主页悬浮按钮实现)
const i5 = document.querySelector("#i5");
if (i5) i5.style.display = "none";
// 4. #i6 整体隐藏
const i6 = document.querySelector("#i6");
if (i6) i6.style.display = "none";
// 5. #i3:隐藏原来的图片 <a> 包裹,脚本自行渲染
const i3a = document.querySelector("#i3 > a");
if (i3a) i3a.style.display = "none";
const i3img = document.querySelector("#i3 > #img");
if (i3img) i3img.style.display = "none";
// 6. 标题 h1(#i1 内)下方增加 padding-bottom: 6px
const h1 = document.querySelector("#i1 h1");
if (h1) h1.style.paddingBottom = "6px";
// 7. 消除 body 的 padding(原网页有 padding:2px 导致图片左右抖动)
document.body.style.padding = "0";
// 8. body 可正常滚动
document.documentElement.style.overflow = "auto";
document.body.style.overflow = "auto";
}
// =================================================
// 创建阅读器容器:直接插入 #i3 之后,在 #i1 内部
// =================================================
function createReaderContainer() {
const container = document.createElement("div");
container.id = "EHR_container";
// 插入到 #i3 之后(紧跟原图片位置),保持在 #i1 内部
const i3 = document.querySelector("#i3");
if (i3 && i3.parentNode) {
i3.parentNode.insertBefore(container, i3.nextSibling);
} else {
const i1 = document.querySelector("#i1");
if (i1) i1.appendChild(container);
else document.body.appendChild(container);
}
EHR.state.container = container;
return container;
}
// =================================================
// 把当前页的主图移入容器(第一张)
// =================================================
function moveCurrentImage() {
const imgEl = document.querySelector("#i3 #img") ||
document.querySelector("#i3 a > img") ||
document.querySelector("#img");
const src = imgEl ? (imgEl.src || imgEl.dataset.src || "") : "";
const info = extractCurrentImgInfo();
if (src) EHR.state.pageData.set(EHR.state.startPage, { url: src, info });
const box = document.createElement("div");
box.className = "EHR_page";
box.dataset.page = EHR.state.startPage;
// 图片包裹,复用 #i3 的结构概念(直接放 <img>,无额外容器样式)
const img = new Image();
img.referrerPolicy = "origin";
img.src = src;
img.style.display = "none";
img.onload = () => {
img.style.display = "";
applyImageMode(img);
observeBlock(box);
};
img.onerror = () => {
img.alt = "当前页图片加载失败";
img.style.display = "";
};
box.appendChild(img);
// 图片信息行:复用原网页 #i4 > div:first-child 的结构(文本居中,原样式)
if (EHR.config.showImgInfo && info) {
const infoDiv = createImgInfoBar(info);
infoDiv.dataset.infoPage = EHR.state.startPage;
box.appendChild(infoDiv);
}
EHR.state.container.appendChild(box);
EHR.state.loadedPages.add(EHR.state.startPage);
}
// =================================================
// 图片信息行:复用原网页 #i2 > div:last-child 的节点结构(文本居中,完全继承原样式)
// #i4 已被隐藏,但其节点仍可供克隆;优先用 #i2 最后一个 div
// =================================================
function createImgInfoBar(infoText) {
// 优先克隆 #i2 > div:last-child(原网页信息行节点,已有居中样式)
const orig = document.querySelector("#i2 > div:last-child") ||
document.querySelector("#i4 > div:first-child");
let div;
if (orig) {
div = orig.cloneNode(false); // 只克隆节点本身,不含子内容
// 保留原节点的所有 class 和内联样式,确保居中等样式完全继承
} else {
div = document.createElement("div");
}
// 追加 EHR_info_bar 标记 class,用于 refreshInfoBars 选择器
div.className = (div.className ? div.className + " " : "") + "EHR_info_bar";
div.textContent = infoText || "";
div.style.display = EHR.config.showImgInfo ? "" : "none";
return div;
}
function refreshInfoBars() {
document.querySelectorAll(".EHR_info_bar").forEach(bar => {
bar.style.display = EHR.config.showImgInfo ? "" : "none";
});
}
// =================================================
// 从 URL 提取文件名
// =================================================
function getFilename(url) {
try {
return url.split("/").pop().split("?")[0];
} catch (e) {
return url;
}
}
// =================================================
// 加载框 & 失败状态
// =================================================
function createLoadingBox(page) {
const div = document.createElement("div");
div.className = "EHR_loading";
div.innerHTML = `
<div class="EHR_spinner"></div>
<span>正在获取第 ${page} 页...</span>
`;
return div;
}
function setErrorState(loadingDiv, page, retryFn) {
loadingDiv.innerHTML = `
<div class="EHR_err_icon">!</div>
<span>第 ${page} 页加载失败</span>
`;
if (typeof retryFn === "function") {
const btn = document.createElement("button");
btn.className = "EHR_retry_btn";
btn.textContent = "重新加载";
btn.onclick = () => {
btn.remove();
retryFn();
};
loadingDiv.appendChild(btn);
}
}
// =================================================
// 创建页面块
// =================================================
async function createPageBlock(page) {
if (EHR.state.loadedPages.has(page)) return null;
if (EHR.state.loadingPages.has(page)) return null;
EHR.state.loadingPages.add(page);
const data = await resolvePage(page);
EHR.state.loadingPages.delete(page);
if (!data) return null;
// data 就绪后立即把 pageKey 写入 pageKeys Map(缓存/fetch 路径都生效)
if (data.pageKey) EHR.state.pageKeys.set(page, data.pageKey);
// 若此时正在看这一页,立即更新地址栏
if (page === EHR.state.activePage) applyPageUrl(page);
const box = document.createElement("div");
box.className = "EHR_page";
box.dataset.page = page;
EHR.state.loadedPages.add(page);
function startLoad(autoRetries, onImgLoaded) {
if (autoRetries === undefined) autoRetries = 3;
box.querySelectorAll("img:not(.EHR_info_bar img)").forEach(i => i.remove());
box.querySelectorAll(".EHR_loading").forEach(i => i.remove());
const loadingEl = createLoadingBox(page);
box.insertBefore(loadingEl, box.querySelector(".EHR_info_bar") || null);
const img = new Image();
img.referrerPolicy = "origin";
img.style.display = "none";
box.insertBefore(img, loadingEl);
img.onload = () => {
loadingEl.remove();
img.style.display = "";
applyImageMode(img);
observeBlock(box);
// 图片加载成功时把 url + info + pageKey 一起写入 IDB
EHCache.put(EHR.state.gid, page, data.url, data.info, data.pageKey || "").catch(() => {});
// 图片加载完成 = 本页 key 已确定,立即更新地址栏(若当前正在看此页)
if (page === EHR.state.activePage) applyPageUrl(page);
if (typeof onImgLoaded === "function") onImgLoaded();
};
img.onerror = async () => {
img.remove();
if (autoRetries > 0) {
const retryLeft = autoRetries - 1;
const span = loadingEl.querySelector("span");
if (span) span.textContent = `正在重新获取第 ${page} 页... (剩余重试 ${retryLeft + 1} 次)`;
// 清除内存缓存 + IDB 缓存,确保重新 fetch 最新 URL(应对画廊更新/URL 过期)
EHR.state.pageData.delete(page);
await EHCache.remove(EHR.state.gid, page);
const newData = await resolvePage(page);
if (newData) {
data.url = newData.url;
data.info = newData.info;
startLoad(retryLeft);
} else {
setErrorState(loadingEl, page, () => startLoad(3));
}
} else {
// 3 次自动重试全部耗尽,显示"重新加载"按钮供用户手动触发
setErrorState(loadingEl, page, () => startLoad(3));
}
};
img.src = data.url;
}
// 图片信息行(复用原网页样式,居中文本)——先加 info,再触发图片加载
if (EHR.config.showImgInfo && data.info) {
const infoDiv = createImgInfoBar(data.info);
infoDiv.dataset.infoPage = page;
box.appendChild(infoDiv);
}
startLoad(3, () => {
// 旧版 IDB 缓存(v1,info 为空):图片加载后若 data.info 已被 parsePage 填充则补插 infoBar
if (EHR.config.showImgInfo && data.info && !box.querySelector(".EHR_info_bar")) {
const infoDiv = createImgInfoBar(data.info);
infoDiv.dataset.infoPage = page;
box.appendChild(infoDiv);
}
});
return box;
}
// =================================================
// 向下加载(并发发起,按页码顺序插入 DOM,防止顺序错位)
// =================================================
let _loadingNext = false;
async function loadNextPages() {
if (_loadingNext) return;
_loadingNext = true;
try {
const loaded = EHR.state.loadedPages;
if (!loaded.size) return;
let maxLoaded = 0;
loaded.forEach(p => { if (p > maxLoaded) maxLoaded = p; });
const pages = [];
for (let i = 1; i <= EHR.config.preloadDown; i++) {
const page = maxLoaded + i;
if (EHR.state.totalPages && page > EHR.state.totalPages) break;
if (loaded.has(page) || EHR.state.loadingPages.has(page)) continue;
pages.push(page);
}
if (!pages.length) return;
// 并发发起,结果附带页码以便排序
const results = await Promise.all(
pages.map(page => createPageBlock(page).then(block => ({ page, block })))
);
// 按页码升序插入,确保顺序正确
results.sort((a, b) => a.page - b.page);
for (const { block } of results) {
if (block && EHR.state.container) EHR.state.container.appendChild(block);
}
cleanMemory();
} finally {
_loadingNext = false;
}
}
// =================================================
// 向上加载(并发发起,按页码降序 prepend,防止顺序错位)
// =================================================
let _loadingPrev = false;
async function loadPreviousPages() {
if (_loadingPrev) return;
_loadingPrev = true;
try {
const loaded = EHR.state.loadedPages;
if (!loaded.size) return;
let minLoaded = Infinity;
loaded.forEach(p => { if (p < minLoaded) minLoaded = p; });
const pages = [];
for (let i = 1; i <= EHR.config.preloadUp; i++) {
const page = minLoaded - i;
if (page <= 0) break;
if (loaded.has(page) || EHR.state.loadingPages.has(page)) continue;
pages.push(page);
}
if (!pages.length) return;
const results = await Promise.all(
pages.map(page => createPageBlock(page).then(block => ({ page, block })))
);
// 按页码降序 prepend,最小页最终在容器最顶部
results.sort((a, b) => b.page - a.page);
for (const { block } of results) {
if (block && EHR.state.container) EHR.state.container.prepend(block);
}
cleanMemory();
} finally {
_loadingPrev = false;
}
}
// =================================================
// 滚动触发(监听 window,容器已是普通文档流)
// =================================================
let _scrollTimer = null;
let _lastScrollY = 0; // 在 setupScrollLoader 里延迟初始化,避免被浏览器 scroll-restore 污染
function setupScrollLoader() {
function onScroll() {
if (_scrollTimer) return;
_scrollTimer = setTimeout(() => {
_scrollTimer = null;
const curScrollY = window.scrollY;
const scrollingUp = curScrollY < _lastScrollY;
const scrollingDown = curScrollY > _lastScrollY;
_lastScrollY = curScrollY;
const bottom = window.innerHeight + curScrollY;
const height = document.documentElement.scrollHeight;
// 向下滚动且距底部不足 1500px:触发向下加载
if (scrollingDown && height - bottom < 1500) loadNextPages();
// 向上滚动且距顶部不足 80px:触发向上加载(严格阈值)
if (scrollingUp && curScrollY < 80) loadPreviousPages();
}, 200);
}
// 延迟 800ms 再开始监听:等浏览器 scroll-restore 完成,避免误触发向上加载
setTimeout(() => {
_lastScrollY = window.scrollY; // 以恢复后的真实位置为基准
window.addEventListener("scroll", onScroll, { passive: true });
}, 800);
}
// =================================================
// 图片显示引擎
// =================================================
function applyImageMode(img) {
if (!img) return;
if (!img.naturalWidth) {
if (!img.width) { setTimeout(() => applyImageMode(img), 100); return; }
}
const nw = img.naturalWidth || img.width;
const nh = img.naturalHeight || img.height;
const dpr = window.devicePixelRatio || 1;
// 屏幕可用物理像素宽(减去滚动条估算 20px,再折算为 CSS px)
const screenCSSW = (window.innerWidth - 20);
// 脚本缩放倍率(优先级最高,两种模式都叠加)
const ratio = EHR.config.scale / 100;
img.style.removeProperty("width");
img.style.removeProperty("max-width");
img.style.removeProperty("height");
img.style.height = "auto";
img.style.display = "block";
img.style.margin = "0 auto";
img.style.imageRendering = "pixelated";
img.style.imageRendering = "-moz-crisp-edges";
img.style.imageRendering = "crisp-edges";
const container = EHR.state.container;
if (EHR.config.mode === "pixel") {
// 像素100%:图片物理像素 ≤ 屏幕物理像素 → 完整显示 CSS 尺寸(nw/dpr)
// 图片物理像素 > 屏幕物理像素 → 超出部分出横向滚动条
// 浏览器 zoom 时 dpr 和 innerWidth 同步变化,结果始终正确,无需额外监听
const cssW = (nw / dpr) * ratio;
img.style.width = cssW + "px";
img.style.maxWidth = "none";
if (container) container.style.overflowX = "auto";
} else {
// 自适应:图片物理像素 ≤ 屏幕物理像素 → 1:1 完整显示(nw/dpr CSS px)
// 图片物理像素 > 屏幕物理像素 → 缩到屏幕宽(不超出,不出横向滚动条)
// 浏览器 zoom 时 dpr/innerWidth 同步变化,条件判断始终正确
let cssW;
if (nw <= screenCSSW * dpr) {
cssW = (nw / dpr) * ratio; // 图片不超屏幕:完整显示原始尺寸
} else {
cssW = screenCSSW * ratio; // 图片超出屏幕:缩到可视宽
}
img.style.width = cssW + "px";
img.style.maxWidth = (screenCSSW * ratio) + "px";
if (container) container.style.overflowX = "hidden";
}
}
function refreshImages() {
document.querySelectorAll(".EHR_page img").forEach(img => applyImageMode(img));
}
// 窗口大小变化(含浏览器 zoom)时自动重算
function setupResizeWatcher() {
let tid = null;
window.addEventListener("resize", () => {
clearTimeout(tid);
tid = setTimeout(refreshImages, 120);
});
}
// =================================================
// IntersectionObserver:更新当前页 + 地址栏
// =================================================
// 找视口内最靠近顶部的 .EHR_page,返回其 page 编号
function getTopVisiblePage() {
const blocks = document.querySelectorAll(".EHR_page[data-page]");
let best = null, bestScore = Infinity;
blocks.forEach(b => {
const rect = b.getBoundingClientRect();
// 只考虑底部在视口内(bottom > 0)且顶部未超过视口底部(top < innerHeight)的图
if (rect.bottom <= 0 || rect.top >= window.innerHeight) return;
// score:图片顶部距视口顶部的距离
// top >= 0:图片在视口内,score = rect.top(越小越靠顶)
// top < 0:图片上部超出视口,score = rect.top(负数,更优先)
const score = rect.top;
if (score < bestScore) { bestScore = score; best = b; }
});
return best ? Number(best.dataset.page) : null;
}
let _pageUpdateTimer = null;
function applyPageUrl(page) {
const key = EHR.state.pageKeys.get(page);
if (!key || !EHR.state.gid) return;
const newURL = `${location.origin}/s/${key}/${EHR.state.gid}-${page}`;
if (location.href !== newURL) history.replaceState(null, "", newURL);
}
function schedulePageUpdate() {
if (_pageUpdateTimer) return;
_pageUpdateTimer = setTimeout(() => {
_pageUpdateTimer = null;
const page = getTopVisiblePage();
if (!page) return;
EHR.state.activePage = page;
updateProgress();
applyPageUrl(page);
}, 150);
}
function setupObserver() {
// 用 scroll 事件驱动页码同步,不依赖 IntersectionObserver 的批次回调
window.addEventListener("scroll", schedulePageUpdate, { passive: true });
// 初始触发一次,确保刚进入页面时页码正确
setTimeout(schedulePageUpdate, 300);
}
// observeBlock 保留签名兼容性,实际不需要注册 observer
function observeBlock(block) { void block; }
// 位置记忆已移除:浏览器自身会记住滚动位置,刷新后自动恢复
// =================================================
// 内存管理
// =================================================
function cleanMemory() {
const cur = EHR.state.activePage;
const limit = EHR.config.cacheLimit;
const boxMap = new Map();
document.querySelectorAll(".EHR_page[data-page]").forEach(b => {
boxMap.set(Number(b.dataset.page), b);
});
EHR.state.loadedPages.forEach(page => {
// 跳过正在加载中的页,防止干扰进行中的请求
if (EHR.state.loadingPages.has(page)) return;
if (Math.abs(page - cur) > limit) {
const box = boxMap.get(page);
if (!box) return;
const img = box.querySelector("img:not([data-src])");
if (img && img.src && !img.src.startsWith("data:")) {
img.dataset.src = img.src;
img.src = ""; // 比 removeAttribute 更彻底释放内存
img.style.display = "none";
}
}
});
}
function restoreReleasedImages() {
document.querySelectorAll("img[data-src]").forEach(img => {
const rect = img.getBoundingClientRect();
if (rect.top < window.innerHeight * 2) {
img.onload = () => { img.style.display = "block"; applyImageMode(img); };
img.src = img.dataset.src;
img.removeAttribute('data-src');
}
});
}
function setupPerformance() {
let t = null;
function onPerf() {
if (t) return;
t = setTimeout(() => { t = null; restoreReleasedImages(); }, 200);
}
window.addEventListener("scroll", onPerf, { passive: true });
// cleanMemory 改由加载新页后按需触发(见 loadNextPages/loadPreviousPages),
// 不再用 setInterval 定期全量扫描
}
// =================================================
// 进度显示(右上角,始终可见)
// =================================================
let _progressEl = null;
function createProgress() {
_progressEl = document.createElement("div");
_progressEl.id = "EHR_progress";
_progressEl.textContent = `${EHR.state.startPage} / ?`;
document.body.appendChild(_progressEl);
}
function updateProgress() {
if (_progressEl) _progressEl.textContent = `${EHR.state.activePage} / ${EHR.state.totalPages || "?"}`;
}
// =================================================
// 悬浮球
// =================================================
function createFloatBall() {
// SVG 图标:设置齿轮(下方)
const svgSettings = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
// SVG 图标:主页房子(上方)
const svgHome = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`;
// 悬浮球(设置菜单)
const ball = document.createElement("div");
ball.id = "EHR_ball";
ball.innerHTML = svgSettings;
ball.title = "EH Reader 设置";
document.body.appendChild(ball);
ball.addEventListener("mousedown", () => ball.classList.add("EHR_ball_press"));
ball.addEventListener("mouseup", () => ball.classList.remove("EHR_ball_press"));
ball.addEventListener("mouseleave",() => ball.classList.remove("EHR_ball_press"));
ball.onclick = () => togglePanel();
// 主页按钮(返回图库预览)——在悬浮球上方
const homeBtn = document.createElement("div");
homeBtn.id = "EHR_home_btn";
homeBtn.innerHTML = svgHome;
homeBtn.title = "返回图库预览";
document.body.appendChild(homeBtn);
homeBtn.addEventListener("mousedown", () => homeBtn.classList.add("EHR_ball_press"));
homeBtn.addEventListener("mouseup", () => homeBtn.classList.remove("EHR_ball_press"));
homeBtn.addEventListener("mouseleave",() => homeBtn.classList.remove("EHR_ball_press"));
homeBtn.onclick = () => {
const href = extractGalleryBackLink();
if (href) location.href = href;
};
// 对齐 EhSyringe 悬浮球,并将主页按钮始终保持在设置球上方
function alignBall() {
const syringe = document.querySelector("#eh-syringe-popup-button");
let right = 20, bottom = 20, size = 42, fontSize = 20;
if (syringe) {
bottom = parseFloat(syringe.style.bottom) || 12;
right = parseFloat(syringe.style.right) || 12;
size = syringe.offsetHeight || 36;
fontSize = Math.round(size * 0.5);
}
const gap = 8;
// 设置球
ball.style.width = size + "px";
ball.style.height = size + "px";
ball.style.right = right + "px";
ball.style.bottom = (bottom + size + gap) + "px";
ball.style.fontSize = fontSize + "px";
// 主页球(在设置球再上方)
homeBtn.style.width = size + "px";
homeBtn.style.height = size + "px";
homeBtn.style.right = right + "px";
homeBtn.style.bottom = (bottom + (size + gap) * 2) + "px";
homeBtn.style.fontSize = (fontSize + 2) + "px"; // ⌂ 略大一点更易辨认
}
alignBall();
const bodyObs = new MutationObserver(() => {
if (document.querySelector("#eh-syringe-popup-button")) alignBall();
});
bodyObs.observe(document.body, { childList: true, subtree: true });
setTimeout(() => {
const syringe = document.querySelector("#eh-syringe-popup-button");
if (syringe) {
new MutationObserver(alignBall).observe(syringe, { attributes: true, attributeFilter: ["style"] });
}
}, 2000);
}
// =================================================
// 设置面板(优化排版,新增图片信息开关)
// =================================================
function togglePanel() {
const old = document.querySelector("#EHR_panel");
if (old) { old.remove(); return; }
const panel = document.createElement("div");
panel.id = "EHR_panel";
panel.innerHTML = `
<div class="ehr_panel_title">⚙ EH Reader</div>
<div class="ehr_section">
<div class="ehr_section_label">显示模式</div>
<div class="ehr_radio_group">
<label><input type="radio" name="ehrmode" value="auto"> 自适应</label>
<label><input type="radio" name="ehrmode" value="pixel"> 像素100%</label>
</div>
</div>
<div class="ehr_section">
<div class="ehr_section_label">缩放 <span id="ehr_scale_val">${EHR.config.scale}%</span></div>
<input id="ehr_scale" type="range" min="25" max="200" step="5" value="${EHR.config.scale}">
</div>
<div class="ehr_section ehr_row_group">
<div class="ehr_row">
<span class="ehr_row_label">向上预载</span>
<input id="ehr_up" type="number" min="1" max="20" value="${EHR.config.preloadUp}">
</div>
<div class="ehr_row">
<span class="ehr_row_label">向下预载</span>
<input id="ehr_down" type="number" min="1" max="20" value="${EHR.config.preloadDown}">
</div>
<div class="ehr_row">
<span class="ehr_row_label">缓存上限(张)</span>
<input id="ehr_cache_limit" type="number" min="10" max="1000" step="10" value="${EHR.config.cacheLimit || 500}">
</div>
</div>
<div class="ehr_section">
<div class="ehr_section_label">显示图片信息</div>
<label class="ehr_toggle_label">
<input type="checkbox" id="ehr_show_info" ${EHR.config.showImgInfo ? "checked" : ""}>
<span class="ehr_toggle_text">在每张图片下方显示文件名/尺寸/大小</span>
</label>
</div>
<div class="ehr_section ehr_stats">
<span>已加载 <b id="ehr_loaded">-</b> 张</span>
<span>共 <b id="ehr_total">-</b> 页</span>
<span id="ehr_cache_used" style="font-size:11px;color:#888;">缓存查询中…</span>
</div>
<div class="ehr_btns">
<button id="ehr_apply">应用并刷新</button>
<button id="ehr_reset">恢复默认</button>
</div>
`;
document.body.appendChild(panel);
positionPanel(panel);
document.querySelector("#ehr_loaded").textContent = EHR.state.loadedPages.size;
document.querySelector("#ehr_total").textContent = EHR.state.totalPages || "?";
// 显示模式(即时生效)
document.querySelectorAll("[name=ehrmode]").forEach(r => {
if (r.value === EHR.config.mode) r.checked = true;
r.onchange = () => { EHR.config.mode = r.value; saveConfig(); refreshImages(); };
});
// 缩放(即时生效)
document.querySelector("#ehr_scale").oninput = function () {
EHR.config.scale = Number(this.value);
document.querySelector("#ehr_scale_val").textContent = this.value + "%";
saveConfig();
refreshImages();
};
// 图片信息开关(即时生效,无需刷新)
document.querySelector("#ehr_show_info").onchange = function () {
EHR.config.showImgInfo = this.checked;
saveConfig();
refreshInfoBars();
};
// 查询已缓存条目数
EHCache.getCount().then(count => {
const el = document.querySelector("#ehr_cache_used");
if (el) el.textContent = `已缓存 ${count} 张 / 上限 ${EHR.config.cacheLimit || 500} 张`;
});
// 应用
document.querySelector("#ehr_apply").onclick = () => {
EHR.config.preloadDown = Math.max(1, Number(document.querySelector("#ehr_down").value));
EHR.config.preloadUp = Math.max(1, Number(document.querySelector("#ehr_up").value));
EHR.config.cacheLimit = Math.max(10, Number(document.querySelector("#ehr_cache_limit").value));
saveConfig();
location.reload();
};
// 恢复默认
document.querySelector("#ehr_reset").onclick = () => {
localStorage.removeItem("EHReader_config");
location.reload();
};
}
// 面板定位:对齐悬浮球左侧
function positionPanel(panel) {
const ball = document.querySelector("#EHR_ball");
if (ball) {
const ballRight = parseFloat(ball.style.right) || 20;
const ballBottom = parseFloat(ball.style.bottom) || 20;
const ballH = ball.offsetHeight || 42;
panel.style.right = ballRight + "px";
panel.style.bottom = (ballBottom + ballH + 10) + "px";
panel.style.top = "auto";
panel.style.transform = "none";
}
}
// =================================================
// 热键
// =================================================
function setupHotkeys() {
document.addEventListener("keydown", e => {
if (e.target.tagName === "INPUT") return;
if (e.key === "ArrowRight" || e.key === "d") {
const box = document.querySelector(`.EHR_page[data-page="${EHR.state.activePage + 1}"]`);
if (box) box.scrollIntoView({ behavior: "smooth", block: "start" });
}
if (e.key === "ArrowLeft" || e.key === "a") {
const box = document.querySelector(`.EHR_page[data-page="${EHR.state.activePage - 1}"]`);
if (box) box.scrollIntoView({ behavior: "smooth", block: "start" });
}
if (e.key === "Escape") {
const panel = document.querySelector("#EHR_panel");
if (panel) panel.remove();
}
});
}
// =================================================
// CSS
// =================================================
function addCSS() {
const css = `
/* ======== 阅读器容器:在 #i1 内部,紧跟 #i3 ======== */
#EHR_container {
display: block;
width: 100%;
/* pixel 模式时由 JS 动态改为 overflow-x: auto */
overflow-x: hidden;
}
/* ======== 每页块:始终居中,不参与横向溢出决策 ======== */
.EHR_page {
display: block;
width: 100%;
text-align: center;
overflow-x: visible;
}
/* 图片本身 */
.EHR_page > img {
height: auto;
display: block;
margin: 0 auto;
}
/* 图片信息行:继承原网页样式(居中),下方加 14px 间距 */
.EHR_info_bar {
display: block;
text-align: center;
margin-bottom: 14px;
}
/* ======== 加载框 ======== */
.EHR_loading {
height: 280px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 14px;
gap: 10px;
}
.EHR_spinner {
width: 30px; height: 30px;
border-radius: 50%;
border: 3px solid #888;
border-top-color: transparent;
animation: EHRspin 1s linear infinite;
}
@keyframes EHRspin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.EHR_err_icon {
width: 30px; height: 30px;
border-radius: 50%;
border: 3px solid #666;
font-size: 16px;
font-weight: bold;
display: flex; align-items: center; justify-content: center;
}
.EHR_retry_btn {
margin-top: 10px;
padding: 5px 16px;
background: #5c5c5c;
color: #eee;
border: none;
border-radius: 5px;
font-size: 13px;
cursor: pointer;
transition: background .15s;
}
.EHR_retry_btn:hover {
background: #7a7a7a;
}
/* ======== 右上角进度 ======== */
#EHR_progress {
position: fixed;
right: 20px; top: 12px;
background: rgba(0, 0, 0, 0.65);
color: #eee;
padding: 4px 11px;
border-radius: 10px;
z-index: 999999;
font-size: 13px;
pointer-events: none;
}
/* ======== 悬浮球(设置)& 主页按钮 ======== */
#EHR_ball, #EHR_home_btn {
position: fixed;
right: 20px; bottom: 20px;
width: 42px; height: 42px;
background: rgba(40, 40, 40, .55);
color: rgba(238, 238, 238, .8);
border-radius: 50%;
cursor: pointer;
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
box-shadow: 0 2px 8px rgba(0,0,0,.3);
opacity: 0.5;
transition: opacity .2s, background .2s, transform .12s, box-shadow .2s;
box-sizing: border-box;
padding: 0;
line-height: 0;
font-size: 0;
}
/* SVG 图标:强制居中,不继承字体偏移 */
#EHR_ball svg, #EHR_home_btn svg {
display: block;
flex-shrink: 0;
pointer-events: none;
}
#EHR_ball:hover, #EHR_home_btn:hover {
opacity: 1;
background: rgba(60, 60, 60, .95);
box-shadow: 0 3px 12px rgba(0,0,0,.6);
}
#EHR_ball.EHR_ball_press, #EHR_home_btn.EHR_ball_press {
transform: scale(.88);
box-shadow: 0 1px 4px rgba(0,0,0,.4);
}
/* ======== 设置面板 ======== */
#EHR_panel {
position: fixed;
right: 72px;
bottom: 70px;
top: auto;
width: 240px;
padding: 0;
background: rgba(22, 22, 22, 0.97);
color: #e0e0e0;
border-radius: 12px;
z-index: 999999;
font-size: 13px;
line-height: 1.5;
box-shadow: 0 6px 24px rgba(0,0,0,.7);
overflow: hidden;
}
.ehr_panel_title {
font-size: 15px;
font-weight: bold;
text-align: center;
padding: 12px 16px 10px;
background: rgba(255,255,255,.05);
border-bottom: 1px solid #333;
letter-spacing: 1px;
}
.ehr_section {
padding: 9px 14px;
border-bottom: 1px solid #2a2a2a;
}
.ehr_section:last-child { border-bottom: none; }
.ehr_section_label {
font-size: 11px;
color: #888;
text-transform: uppercase;
letter-spacing: .5px;
margin-bottom: 5px;
}
.ehr_radio_group {
display: flex;
gap: 16px;
justify-content: center;
align-items: center;
}
.ehr_radio_group label {
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
line-height: 1;
}
.ehr_radio_group label input[type=radio] {
margin: 0;
vertical-align: middle;
position: relative;
top: 0;
}
#EHR_panel input[type=range] {
width: 100%;
accent-color: #7a7a7a;
}
.ehr_row_group { display: flex; flex-direction: column; gap: 5px; }
.ehr_row {
display: flex;
align-items: center;
justify-content: space-between;
}
.ehr_row_label { font-size: 12px; color: #ccc; }
#EHR_panel input[type=number] {
width: 64px;
background: #2c2c2c;
color: #eee;
border: 1px solid #444;
border-radius: 5px;
padding: 3px 6px;
font-size: 12px;
text-align: left;
}
.ehr_toggle_label {
display: flex;
align-items: flex-start;
justify-content: center;
gap: 6px;
cursor: pointer;
}
.ehr_toggle_text { font-size: 12px; color: #ccc; line-height: 1.4; }
#EHR_panel input[type=checkbox] { margin-top: 2px; accent-color: #9a9a9a; }
.ehr_stats {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
color: #999;
font-size: 12px;
}
.ehr_stats b { color: #ddd; }
.ehr_btns {
display: flex;
gap: 8px;
padding: 10px 14px 12px;
border-top: 1px solid #2a2a2a;
}
#EHR_panel button {
flex: 1;
padding: 6px 0;
border-radius: 7px;
border: none;
background: #3a3a3a;
color: #ddd;
cursor: pointer;
font-size: 12px;
transition: background .15s;
}
#EHR_panel button:hover { background: #505050; color: #fff; }
#EHR_panel #ehr_apply { background: #4a4a6a; }
#EHR_panel #ehr_apply:hover { background: #5a5a8a; }
`;
const style = document.createElement("style");
style.id = "EHR_main_style";
style.textContent = css;
document.head.appendChild(style);
}
// =================================================
// 主入口
// =================================================
async function init() {
console.log("[EHReader] Init v" + EHR.version);
harvestPageKeys();
EHR.state.totalPages = detectTotalPages();
addCSS();
patchOriginalPage(); // 隐藏#i2导航/.sn,#i4全隐,#i5改文字链接,#i6隐藏
createReaderContainer(); // 在#i3后插入容器,在#i1内部
moveCurrentImage(); // 当前页图片插入容器
createFloatBall();
createProgress();
setupObserver(); // scroll 驱动页码 + 地址栏同步
setupScrollLoader(); // scroll 触发向上/向下预加载
setupResizeWatcher(); // 浏览器 zoom/resize 时自动重算图片尺寸
setupPerformance();
setupHotkeys();
// 延迟到浏览器 scroll-restore 完成后再触发首次向下预加载
// (800ms 是 scroll 监听启动时间,900ms 确保顺序正确)
setTimeout(async () => {
await loadNextPages();
updateProgress();
}, 900);
console.log("[EHReader] page:", EHR.state.startPage, "total:", EHR.state.totalPages);
}
init();
})();