ExHentai Reader Pro

E站 阅读加强 自适应点对点缩放+预载图片 v4 — 嵌入原网页,保留原有样式,向下滚动自动加载,失败自动重试,阅读记忆

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();

})();