Greasy Fork

Greasy Fork is available in English.

漫画人阅读器

覆盖原站,提供左右分栏与垂直两种阅读模式;支持RTL/LTR、封面单页、间距、键盘导航;分栏支持按高度/宽度适配;垂直支持百分比缩放(滑条/按钮/触控捏合/Ctrl+滚轮)。

当前为 2025-10-24 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         漫画人阅读器
// @namespace    https://tampermonkey.net/
// @version      2.0.0
// @description  覆盖原站,提供左右分栏与垂直两种阅读模式;支持RTL/LTR、封面单页、间距、键盘导航;分栏支持按高度/宽度适配;垂直支持百分比缩放(滑条/按钮/触控捏合/Ctrl+滚轮)。
// @author       you
// @match        https://www.manhuaren.com/m*/
// @match        https://www.manhuaren.com/m*/*
// @run-at       document-idle
// @grant        none
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  // ----------------------- utils -----------------------
  const $ = (sel, root = document) => root.querySelector(sel);
  const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  const on = (el, ev, fn, opt) => el && el.addEventListener(ev, fn, opt);
  const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
  const store = {
    get(k, v) { try { return JSON.parse(localStorage.getItem(k)); } catch { return v; } },
    set(k, v) { localStorage.setItem(k, JSON.stringify(v)); }
  };

  // ----------------------- state -----------------------
  const state = {
    pages: [],                // 原始页URL
    mode: store.get('tm_dpr_mode', 'spread'), // 'spread' | 'vertical'
    pairIndex: 0,             // 当前双页索引(spread)
    rtl: store.get('tm_dpr_rtl', true),
    fitSpread: store.get('tm_dpr_fitSpread', 'height'), // 'height' | 'width'
    gap: store.get('tm_dpr_gap', 16),                   // 间距 px
    firstSingle: store.get('tm_dpr_firstSingle', true), // 封面单页
    zoom: store.get('tm_dpr_zoom', 100),                // 垂直模式百分比
  };
  const save = () => {
    store.set('tm_dpr_mode', state.mode);
    store.set('tm_dpr_rtl', state.rtl);
    store.set('tm_dpr_fitSpread', state.fitSpread);
    store.set('tm_dpr_gap', state.gap);
    store.set('tm_dpr_firstSingle', state.firstSingle);
    store.set('tm_dpr_zoom', state.zoom);
  };

  // ----------------------- image collection (保留原逻辑) -----------------------
  async function collectImages(maxWaitMs = 15000) {
    const start = Date.now();
    let imgs = [];
    while (Date.now() - start < maxWaitMs) {
      if (window.newImgs && Array.isArray(window.newImgs) && window.newImgs.length) {
        imgs = window.newImgs.slice();
        break;
      }
      const inDom = $$('#cp_img img')
        .map(n => n.getAttribute('data-src') || n.src)
        .filter(Boolean);
      if (inDom.length > 0) {
        imgs = inDom;
        if (!(window.newImgs && window.newImgs.length)) {
          await new Promise(r => setTimeout(r, 200));
          if (window.newImgs && window.newImgs.length) imgs = window.newImgs.slice();
        }
        break;
      }
      await new Promise(r => setTimeout(r, 150));
    }
    imgs = imgs.map(s => s.replace(/^\/\//, location.protocol + '//')).filter(Boolean);
    imgs = Array.from(new Set(imgs));
    return imgs;
  }

  // ----------------------- css -----------------------
  const css = `
:root{
  --tm-gap:16px;
  --tm-zoom:100; /* 用于垂直模式百分比缩放 */
  --tm-bar-h:56px;
  --tm-bg:#0b0b0b; --tm-fg:#eaeaea;
  --tm-surface:rgba(255,255,255,0.06);
  --tm-surface-2:rgba(255,255,255,0.12);
  --tm-border:rgba(255,255,255,0.15);
}
html, body { overflow:hidden!important; }
body.tm-dpr-active > *:not(#tm-dpr-root){ display:none !important; }

#tm-dpr-root{
  position:fixed; inset:0; z-index:2147483647;
  background:linear-gradient(180deg,#090909,#0f0f10 40%,#0a0a0a);
  color:var(--tm-fg); font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,PingFang SC,Hiragino Sans GB,Microsoft Yahei,Helvetica Neue,Noto Sans,Arial,sans-serif;
}
#tm-dpr-root *{ box-sizing:border-box; }

#tm-bar{
  position:fixed; top:0; left:0; right:0; height:var(--tm-bar-h);
  display:flex; align-items:center; gap:10px; padding:8px 12px;
  background:rgba(0,0,0,0.48); backdrop-filter: blur(8px);
  border-bottom:1px solid var(--tm-surface-2);
}
#tm-bar .seg, #tm-bar .btn, #tm-bar select, #tm-bar input[type="range"]{
  height:36px; border-radius:10px; border:1px solid var(--tm-border);
  background:var(--tm-surface); color:var(--tm-fg); font-size:12px;
}
#tm-bar .btn{ padding:0 12px; cursor:pointer; line-height:34px; }
#tm-bar .btn:hover{ background:rgba(255,255,255,0.1); }
#tm-bar .seg{ display:inline-flex; overflow:hidden; }
#tm-bar .seg .btn{ border:none; border-right:1px solid var(--tm-surface-2); border-radius:0; }
#tm-bar .seg .btn:last-child{ border-right:none; }
#tm-bar .label{ font-size:12px; opacity:.85; margin:0 4px 0 8px; white-space:nowrap; }
#tm-bar .sp{flex:1;}
#tm-bar .icon{ width:16px; height:16px; vertical-align:-3px; opacity:.85; }

#tm-stage{
  position:absolute; top:var(--tm-bar-h); left:0; right:0; bottom:0;
}

/* Spread(左右分栏) */
#stage-spread{
  position:absolute; inset:0; display:flex; align-items:center; justify-content:center; gap:var(--tm-gap); overflow:hidden; padding:12px;
}
#stage-spread.fit-height img.page{ max-height: calc(100vh - var(--tm-bar-h) - 24px); width:auto; height:auto; }
#stage-spread.fit-width{ align-items:flex-start; overflow:auto; }
#stage-spread.fit-width img.page{ width: calc((100vw - var(--tm-gap) - 24px) / 2); height:auto; }
.page-wrap{ position:relative; display:flex; align-items:center; justify-content:center; min-width:0; }
img.page{ display:block; max-width:100%; object-fit:contain; user-select:none; -webkit-user-drag:none; background:#111; border-radius:8px; box-shadow:0 10px 30px rgba(0,0,0,.35); }
.badge{ position:absolute; bottom:8px; right:8px; font-size:12px; background:rgba(0,0,0,0.55); padding:2px 6px; border-radius:6px; border:1px solid var(--tm-surface-2); }
.page-left .badge{ left:8px; right:auto; }

/* 点击半屏翻组 */
#spread-overlay{ position:absolute; inset:0; display:grid; grid-template-columns:1fr 1fr; }
#spread-overlay .zone{ cursor:pointer; }
#spread-overlay .zone:active{ background:rgba(255,255,255,0.03); }

/* Vertical(垂直) */
#stage-vertical{
  position:absolute; inset:0; overflow:auto; padding:16px 12px 24px;
  --tm-gap-vert: var(--tm-gap);
}
.v-item{ position:relative; margin: 0 auto var(--tm-gap-vert) auto; max-width: min(1800px, 100%); }
.v-item img{ display:block; width: calc(var(--tm-zoom) * 1%); height:auto; max-width:none; margin:0 auto; object-fit:contain; user-select:none; -webkit-user-drag:none; border-radius:8px; box-shadow:0 10px 30px rgba(0,0,0,.35); background:#111; }
.v-pageno{ position:absolute; right:8px; bottom:8px; font-size:12px; background:rgba(0,0,0,0.55); padding:2px 6px; border-radius:6px; border:1px solid var(--tm-surface-2); }

/* 小提示 */
#tm-help{ position:fixed; right:12px; top:calc(var(--tm-bar-h) + 8px);
  display:none; max-width:420px; padding:12px; line-height:1.65;
  background:rgba(0,0,0,0.65); border:1px solid var(--tm-surface-2); border-radius:12px; font-size:12px;
}
#tm-help.visible{ display:block; }

/* 小控件 */
.range{ appearance:none; height:36px; width:160px; vertical-align:middle; background:var(--tm-surface); }
.range::-webkit-slider-thumb{ -webkit-appearance:none; appearance:none; width:14px; height:14px; border-radius:50%; background:#fff; box-shadow:0 0 0 2px rgba(0,0,0,.3); cursor:pointer; }

/* 禁止长按选择,优化触控 */
#tm-dpr-root, #tm-dpr-root *{ -webkit-touch-callout:none; -webkit-tap-highlight-color: transparent; }
`;

  // ----------------------- UI -----------------------
  function icon(svgPath) {
    return `<svg class="icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="${svgPath}"/></svg>`;
  }
  const ICONS = {
    spread: "M3 5h8v14H3V5zm10 0h8v14h-8V5z",
    vertical: "M5 3h14v4H5V3zm0 7h14v4H5v-4zm0 7h14v4H5v-4z",
    rtl: "M10 6h8v2h-8v2l-4-3 4-3v2zM6 16h12v2H6v-2z",
    ltr: "M14 6H6v2h8v2l4-3-4-3v2zM6 16h12v2H6v-2z",
    height: "M13 4h-2v4H8l4 4 4-4h-3V4zm-2 12h2v4h3l-4 4-4-4h3v-4z",
    width: "M4 11v2h4v3l4-4-4-4v3H4zm16 2v-2h-4V8l-4 4 4 4v-3h4z",
    minus: "M5 11h14v2H5z",
    plus: "M5 11h14v2H5V11zm7-7h2v6h-2V4zM11 14h2v6h-2v-6z", // 实际显示只留减/加两组
    chapterPrev: "M15 6l-6 6 6 6V6z",
    chapterNext: "M9 18l6-6-6-6v12z",
    help: "M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 17c-3.86 0-7-3.14-7-7s3.14-7 7-7 7 3.14 7 7-3.14 7-7 7zm-1-5h2c0-3 3-3.25 3-5a3 3 0 10-6 0h2a1 1 0 112 0c0 1.25-3 1.5-3 5z",
  };

  function buildUI() {
    const root = document.createElement('div');
    root.id = 'tm-dpr-root';
    root.innerHTML = `
      <div id="tm-bar">
        <span class="label">模式</span>
        <div class="seg" id="seg-mode">
          <button class="btn" data-mode="spread">${icon(ICONS.spread)} 双页</button>
          <button class="btn" data-mode="vertical">${icon(ICONS.vertical)} 垂直</button>
        </div>

        <span class="label">方向</span>
        <div class="seg" id="seg-dir">
          <button class="btn" data-dir="rtl">${icon(ICONS.rtl)} 右→左</button>
          <button class="btn" data-dir="ltr">${icon(ICONS.ltr)} 左→右</button>
        </div>

        <span class="label" id="lbl-fit">适配</span>
        <div class="seg" id="seg-fit">
          <button class="btn" data-fit="height">${icon(ICONS.height)} 按高度</button>
          <button class="btn" data-fit="width">${icon(ICONS.width)} 按宽度</button>
        </div>

        <span class="label">间距</span>
        <div class="seg" id="seg-gap">
          <button class="btn" data-gap="0">0</button>
          <button class="btn" data-gap="8">8</button>
          <button class="btn" data-gap="16">16</button>
          <button class="btn" data-gap="24">24</button>
          <button class="btn" data-gap="32">32</button>
        </div>

        <label class="label"><input type="checkbox" id="ck-first" style="vertical-align:-2px;margin-right:6px;">封面单页</label>

        <span class="label" id="lbl-zoom" style="display:none;">缩放</span>
        <button class="btn" id="zoom-dec" style="display:none;">${icon(ICONS.minus)}</button>
        <input class="range" type="range" id="zoom-range" min="40" max="240" step="5" style="display:none;">
        <button class="btn" id="zoom-inc" style="display:none;">${icon(ICONS.plus)}</button>
        <span class="label" id="zoom-val" style="display:none;min-width:54px;text-align:right;">100%</span>

        <div class="sp"></div>

        <button class="btn" id="btn-prev">上一组 ←</button>
        <button class="btn" id="btn-next">下一组 →</button>
        <button class="btn" id="btn-prev-ch">${icon(ICONS.chapterPrev)} 上一章</button>
        <button class="btn" id="btn-next-ch">${icon(ICONS.chapterNext)} 下一章</button>
        <button class="btn" id="btn-help">${icon(ICONS.help)} 帮助</button>
      </div>

      <div id="tm-stage">
        <!-- Spread -->
        <div id="stage-spread" class="fit-height" style="--tm-gap:${state.gap}px">
          <div class="page-wrap page-left"><img class="page" id="img-left" /></div>
          <div class="page-wrap page-right"><img class="page" id="img-right" /></div>
          <div id="spread-overlay"><div class="zone" id="zone-left"></div><div class="zone" id="zone-right"></div></div>
        </div>
        <!-- Vertical -->
        <div id="stage-vertical" style="--tm-gap:${state.gap}px; --tm-zoom:${state.zoom}; display:none;"></div>
      </div>

      <div id="tm-help"></div>
    `;
    document.body.appendChild(root);
    document.body.classList.add('tm-dpr-active');

    // 初始按钮状态
    $('#ck-first').checked = !!state.firstSingle;
    $('#zoom-range').value = state.zoom;
    $('#zoom-val').textContent = `${state.zoom}%`;
    updateModeButtons(); updateDirButtons(); updateFitButtons(); updateGapButtons();
    toggleZoomUI(state.mode === 'vertical');

    // 绑定交互
    on($('#seg-mode'), 'click', e => {
      const btn = e.target.closest('.btn'); if (!btn) return;
      state.mode = btn.dataset.mode; save();
      updateModeButtons(); toggleZoomUI(state.mode === 'vertical'); renderMode();
    });
    on($('#seg-dir'), 'click', e => {
      const btn = e.target.closest('.btn'); if (!btn) return;
      state.rtl = (btn.dataset.dir === 'rtl'); save(); updateDirButtons(); renderSpread(); // 仅影响spread布局/点击方向
    });
    on($('#seg-fit'), 'click', e => {
      const btn = e.target.closest('.btn'); if (!btn) return;
      state.fitSpread = btn.dataset.fit; save(); updateFitButtons(); renderSpread();
    });
    on($('#seg-gap'), 'click', e => {
      const btn = e.target.closest('.btn'); if (!btn) return;
      state.gap = parseInt(btn.dataset.gap, 10); save(); updateGapButtons();
      $('#stage-spread').style.setProperty('--tm-gap', state.gap + 'px');
      $('#stage-vertical').style.setProperty('--tm-gap', state.gap + 'px');
    });
    on($('#ck-first'), 'change', (e) => { state.firstSingle = !!e.target.checked; state.pairIndex = 0; save(); renderSpread(); });

    // 导航
    on($('#btn-prev'), 'click', () => navPrev());
    on($('#btn-next'), 'click', () => navNext());

    // 半屏点击
    on($('#zone-left'), 'click', () => state.rtl ? navNext() : navPrev());
    on($('#zone-right'), 'click', () => state.rtl ? navPrev() : navNext());

    // 章节跳转
    const prevChUrl = findChapterUrl('上一章');
    const nextChUrl = findChapterUrl('下一章');
    $('#btn-prev-ch').disabled = !prevChUrl;
    $('#btn-next-ch').disabled = !nextChUrl;
    on($('#btn-prev-ch'), 'click', () => prevChUrl && (location.href = prevChUrl));
    on($('#btn-next-ch'), 'click', () => nextChUrl && (location.href = nextChUrl));

    // 帮助
    $('#tm-help').innerHTML = helpHTML();
    on($('#btn-help'), 'click', () => $('#tm-help').classList.toggle('visible'));

    // 键盘
    on(window, 'keydown', (ev) => {
      const tag = (ev.target && ev.target.tagName) || '';
      if (/INPUT|TEXTAREA|SELECT/.test(tag)) return;
      const k = ev.key;
      if (k === 'ArrowRight' || k === ' ') { ev.preventDefault(); navNext(); }
      else if (k === 'ArrowLeft' || k === 'Backspace') { ev.preventDefault(); navPrev(); }
      else if (k.toLowerCase() === 'r') { state.rtl = !state.rtl; save(); updateDirButtons(); renderSpread(); }
      else if (k.toLowerCase() === 'f') { state.fitSpread = state.fitSpread === 'height' ? 'width' : 'height'; save(); updateFitButtons(); renderSpread(); }
      else if (k.toLowerCase() === 'm') { state.mode = state.mode === 'spread' ? 'vertical' : 'spread'; save(); updateModeButtons(); toggleZoomUI(state.mode === 'vertical'); renderMode(); }
      else if (k.toLowerCase() === 's') { state.firstSingle = !state.firstSingle; $('#ck-first').checked = state.firstSingle; save(); state.pairIndex = 0; renderSpread(); }
      else if (k.toLowerCase() === 'g') { cycleGap(); }
      else if (k.toLowerCase() === 'h') { $('#tm-help').classList.toggle('visible'); }
      else if ((k === '+' || k === '=') && state.mode === 'vertical') { ev.preventDefault(); setZoom(state.zoom + 5); }
      else if ((k === '-' || k === '_') && state.mode === 'vertical') { ev.preventDefault(); setZoom(state.zoom - 5); }
      else if ((k === '0') && state.mode === 'vertical') { ev.preventDefault(); setZoom(100); }
    });

    // 垂直缩放控件
    on($('#zoom-range'), 'input', e => setZoom(parseInt(e.target.value, 10)));
    on($('#zoom-dec'), 'click', () => setZoom(state.zoom - 5));
    on($('#zoom-inc'), 'click', () => setZoom(state.zoom + 5));

    // Ctrl+滚轮 缩放(Chrome/Edge/FF)
    on($('#stage-vertical'), 'wheel', (ev) => {
      if (!ev.ctrlKey) return; // 仅拦截缩放手势
      ev.preventDefault();
      setZoom(state.zoom + (ev.deltaY < 0 ? 5 : -5));
    }, { passive: false });

    // 触控捏合缩放(垂直)
    let pinch = { active:false, startDist:0, startZoom: state.zoom };
    const vert = $('#stage-vertical');
    on(vert, 'touchstart', (e) => {
      if (e.touches.length === 2) {
        pinch.active = true;
        pinch.startDist = dist2(e.touches[0], e.touches[1]);
        pinch.startZoom = state.zoom;
      }
    }, { passive: true });
    on(vert, 'touchmove', (e) => {
      if (pinch.active && e.touches.length === 2) {
        const d = dist2(e.touches[0], e.touches[1]);
        const scale = d / (pinch.startDist || d);
        const z = Math.round(pinch.startZoom * scale);
        setZoom(z, /*silent*/true); // 实时但不打断性能
      }
    }, { passive: true });
    on(vert, 'touchend', () => { if (pinch.active) { pinch.active = false; setZoom(state.zoom); } });

    function dist2(a,b){ const dx=a.clientX-b.clientX, dy=a.clientY-b.clientY; return Math.hypot(dx,dy); }
  }

  function updateModeButtons(){
    $$('#seg-mode .btn').forEach(b => b.style.opacity = b.dataset.mode === state.mode ? '1' : '0.65');
    // 控制面板/按钮可见性
    $('#seg-dir').style.display = state.mode === 'spread' ? '' : '';
    $('#seg-fit').style.display = state.mode === 'spread' ? '' : 'none';
    $('#lbl-fit').style.display = state.mode === 'spread' ? '' : 'none';
    // “上一组/下一组”在垂直模式下作为滚动快捷键仍保留
  }
  function updateDirButtons(){ $$('#seg-dir .btn').forEach(b => b.style.opacity = b.dataset.dir === (state.rtl ? 'rtl':'ltr') ? '1':'0.65'); }
  function updateFitButtons(){
    $$('#seg-fit .btn').forEach(b => b.style.opacity = b.dataset.fit === state.fitSpread ? '1':'0.65');
    const s = $('#stage-spread');
    s.classList.toggle('fit-height', state.fitSpread === 'height');
    s.classList.toggle('fit-width', state.fitSpread === 'width');
  }
  function updateGapButtons(){
    $$('#seg-gap .btn').forEach(b => b.style.opacity = (parseInt(b.dataset.gap,10) === state.gap) ? '1':'0.65');
  }
  function cycleGap(){
    const steps = [0,8,16,24,32]; let i = steps.indexOf(state.gap); i = (i+1) % steps.length;
    state.gap = steps[i]; save(); updateGapButtons();
    $('#stage-spread').style.setProperty('--tm-gap', state.gap + 'px');
    $('#stage-vertical').style.setProperty('--tm-gap', state.gap + 'px');
  }
  function toggleZoomUI(show){
    ['lbl-zoom','zoom-dec','zoom-range','zoom-inc','zoom-val'].forEach(id => { const el = $('#'+id); if (el) el.style.display = show ? '' : 'none'; });
  }
  function setZoom(z, silent=false){
    state.zoom = clamp(z, 40, 240);
    $('#stage-vertical').style.setProperty('--tm-zoom', state.zoom);
    $('#zoom-range').value = state.zoom;
    $('#zoom-val').textContent = `${state.zoom}%`;
    if (!silent) save();
  }

  function helpHTML(){
    return `
      <b>快捷键</b><br>
      → / 空格:下一组(或向下滚动)<br>
      ← / Backspace:上一组(或向上滚动)<br>
      R:切换阅读方向(右→左 / 左→右)<br>
      F:Spread模式切换适配(按高度 / 按宽度)<br>
      M:切换模式(双页 / 垂直)<br>
      S:切换“封面单页”<br>
      G:循环调整间距<br>
      H:显示/隐藏本帮助<br>
      + / - / 0:垂直模式放大 / 缩小 / 还原100%<br>
      <hr style="border-color: rgba(255,255,255,0.12)">
      触控:垂直模式两指捏合缩放;Spread模式点击左右半屏翻组。<br>
      Tips:封面单页用于让后续跨页在正确的左右位置(常见于日漫)。`;
  }

  // ----------------------- data helpers -----------------------
  function findChapterUrl(labelText) {
    const a = $$('a').find(a => (a.textContent || '').trim() === labelText);
    if (!a) return '';
    const href = a.getAttribute('href') || '';
    const m = href.match(/pushHistory\('([^']+)'\)/);
    return m ? (location.origin + m[1]) : (href.startsWith('/') ? (location.origin + href) : '');
  }

  function pairsCount(){
    const N = state.pages.length;
    if (state.firstSingle) return 1 + Math.ceil((N - 1) / 2);
    return Math.ceil(N / 2);
  }

  function getPair(idx){
    const N = state.pages.length;
    if (state.firstSingle){
      if (idx === 0){
        // 封面单页:显示在右侧更贴近漫画阅读
        return { left:null, right: state.pages[0], leftNum:null, rightNum:1 };
      }
      const start = 1 + (idx - 1) * 2; // 2-3, 4-5, ...
      let L = state.pages[start] || null;       // 2,4,6...
      let R = state.pages[start+1] || null;     // 3,5,7...
      let lNum = L ? start+1 : null, rNum = R ? start+2 : null;
      // RTL 时,右侧应是较小的页码(阅读顺序右→左),交换
      if (state.rtl){ [L, R] = [R || null, L || null]; [lNum, rNum] = [rNum, lNum]; }
      return { left:L, right:R, leftNum:lNum, rightNum:rNum };
    } else {
      const start = idx * 2; // 1-2, 3-4, 5-6...
      let L = state.pages[start] || null;
      let R = state.pages[start+1] || null;
      let lNum = L ? start+1 : null, rNum = R ? start+2 : null;
      if (state.rtl){ [L, R] = [R || null, L || null]; [lNum, rNum] = [rNum, lNum]; }
      return { left:L, right:R, leftNum:lNum, rightNum:rNum };
    }
  }

  // ----------------------- rendering -----------------------
  function renderMode(){
    const spread = $('#stage-spread');
    const vertical = $('#stage-vertical');
    if (state.mode === 'spread'){
      spread.style.display = '';
      vertical.style.display = 'none';
      renderSpread();
    } else {
      spread.style.display = 'none';
      vertical.style.display = '';
      renderVertical();
    }
  }

  function renderSpread(){
    const total = pairsCount();
    state.pairIndex = clamp(state.pairIndex, 0, Math.max(0, total-1));

    const {left, right, leftNum, rightNum} = getPair(state.pairIndex);
    const L = $('#img-left'), R = $('#img-right');

    if (left){ L.src = left; L.parentElement.style.visibility = 'visible'; }
    else { L.removeAttribute('src'); L.parentElement.style.visibility = 'hidden'; }
    if (right){ R.src = right; R.parentElement.style.visibility = 'visible'; }
    else { R.removeAttribute('src'); R.parentElement.style.visibility = 'hidden'; }

    setBadge($('.page-left'), leftNum);
    setBadge($('.page-right'), rightNum);

    // 预加载下一组
    const nextIdx = state.pairIndex + 1;
    if (nextIdx < total){
      const n = getPair(nextIdx);
      [n.left, n.right].filter(Boolean).slice(0,2).forEach(src => { const i=new Image(); i.src=src; });
    }
  }

  function renderVertical(){
    const cont = $('#stage-vertical');
    if (!cont.dataset.built){
      cont.innerHTML = state.pages.map((src, i) => `
        <div class="v-item">
          <img loading="lazy" decoding="async" referrerpolicy="no-referrer" src="${src}" />
          <div class="v-pageno">第 ${i+1} 页</div>
        </div>
      `).join('');
      cont.dataset.built = '1';
    }
    // 应用当前缩放
    cont.style.setProperty('--tm-zoom', state.zoom);
  }

  function setBadge(wrap, num){
    if (!wrap) return;
    let b = $('.badge', wrap);
    if (!b){ b = document.createElement('div'); b.className = 'badge'; wrap.appendChild(b); }
    b.textContent = num ? `第 ${num} 页` : '';
  }

  // ----------------------- navigation -----------------------
  function navNext(){
    if (state.mode === 'spread'){
      state.pairIndex++; renderSpread();
    } else {
      // 垂直:平滑向下滚动
      const v = $('#stage-vertical');
      v.scrollBy({ top: Math.max(100, window.innerHeight * 0.9), left:0, behavior:'smooth' });
    }
  }
  function navPrev(){
    if (state.mode === 'spread'){
      state.pairIndex--; renderSpread();
    } else {
      const v = $('#stage-vertical');
      v.scrollBy({ top: -Math.max(100, window.innerHeight * 0.9), left:0, behavior:'smooth' });
    }
  }

  // ----------------------- boot -----------------------
  function injectStyle(text){ const s=document.createElement('style'); s.id='tm-dpr-style'; s.textContent=text; document.head.appendChild(s); }

  async function boot(){
    // 仅在章节页运行:/m数字/
    if (!/\/m\d+\/?$/.test(location.pathname)) return;

    injectStyle(css);
    buildUI();

    const imgs = await collectImages(15000);
    if (!imgs || !imgs.length){
      alert('未能获取到章节图片。请刷新重试。');
      return;
    }
    state.pages = imgs;

    renderMode();

    // 窗口调整重渲染
    on(window, 'resize', () => { if (state.mode === 'spread') renderSpread(); });
  }

  setTimeout(boot, 0);
})();