Greasy Fork

来自缓存

Greasy Fork is available in English.

AO3 Reader

优化AO3网站在手机端的阅读体验,支持左右点击翻页的分页阅读模式

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         AO3 Reader
// @namespace    https://github.com/franose371/ao3-reader
// @version      1.1.0
// @description  优化AO3网站在手机端的阅读体验,支持左右点击翻页的分页阅读模式
// @author       franose371
// @homepageURL  https://github.com/franose371/ao3-reader
// @supportURL   https://github.com/franose371/ao3-reader/issues
// @match        https://archiveofourown.org/works/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  // ── Injected CSS ────────────────────────────────────────────────────
  GM_addStyle(`
    /* AO3 Reader - Floating entry button */
    #ao3-reader-entry-btn {
      position: fixed;
      bottom: 24px;
      right: 16px;
      z-index: 9998;
      width: 12px;
      height: 24px;
      border-radius: 50%;
      background: #900000;
      color: #fff;
      border: none;
      box-shadow: 0 2px 8px rgba(0,0,0,0.3);
      font-size: 10px;
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }

    /* Reading mode overlay */
    #ao3-reader-overlay {
      position: fixed;
      top: 0;
      left: 0;
      width: 100vw;
      z-index: 9999;
      display: flex;
      flex-direction: column;
      font-family: 'Noto Serif SC', 'Source Han Serif SC', 'Noto Serif CJK SC', Georgia, 'Times New Roman', serif;
      -webkit-user-select: none;
      user-select: none;
      -webkit-tap-highlight-color: transparent;
    }

    /* Theme variants */
    #ao3-reader-overlay.theme-light {
      background: #ffffff;
      color: #1a1a1a;
      --ao3-reader-card-bg: #ffffff;
    }
    #ao3-reader-overlay.theme-sepia {
      background: #f5f0e8;
      color: #3d3027;
      --ao3-reader-card-bg: #f5f0e8;
    }
    #ao3-reader-overlay.theme-dark {
      background: #1a1a1a;
      color: #d4d4d4;
      --ao3-reader-card-bg: #252525;
    }

    /* Header */
    #ao3-reader-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 8px 12px;
      min-height: 40px;
      flex-shrink: 0;
      border-bottom: 1px solid rgba(128,128,128,0.2);
      background: inherit;
    }
    #ao3-reader-header .chapter-title {
      font-size: 13px;
      font-weight: bold;
      flex: 1;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      margin: 0 8px;
    }
    #ao3-reader-header .page-indicator {
      font-size: 12px;
      opacity: 0.7;
      white-space: nowrap;
    }
    #ao3-reader-header button {
      background: none;
      border: none;
      font-size: 18px;
      padding: 4px 8px;
      cursor: pointer;
      color: inherit;
      opacity: 0.7;
      -webkit-tap-highlight-color: transparent;
    }
    #ao3-reader-header button:active {
      opacity: 1;
    }

    /* Viewport / pages */
    #ao3-reader-viewport {
      flex: 1;
      overflow: hidden;
      position: relative;
    }

    #ao3-reader-pages {
      position: absolute;
      top: 0;
      left: 0;
      height: 100%;
    }

    .ao3-reader-page {
      -webkit-overflow-scrolling: touch;
    }

    .ao3-reader-page > * {
      box-sizing: border-box;
    }

    .ao3-reader-page h1,
    .ao3-reader-page h2,
    .ao3-reader-page h3,
    .ao3-reader-page h4 {
      margin-top: 0.8em;
      margin-bottom: 0.4em;
    }

    .ao3-reader-page p {
      margin: 0 0 0.8em 0;
      text-indent: 2em;
    }

    .ao3-reader-page hr {
      border: none;
      border-top: 1px solid rgba(128,128,128,0.3);
      margin: 16px 0;
      width: 30%;
    }

    .ao3-reader-page img {
      max-width: 100%;
      height: auto;
    }

    .ao3-reader-page blockquote {
      margin: 8px 0;
      padding-left: 12px;
      border-left: 3px solid rgba(128,128,128,0.3);
      font-style: italic;
    }

    /* Chapter summary */
    .ao3-reader-chapter-summary {
      margin: 0 0 14px 0 !important;
      padding: 8px 12px;
      border-left: 3px solid rgba(128,128,128,0.4);
      font-style: italic;
      opacity: 0.85;
    }
    .ao3-reader-chapter-summary p {
      margin: 0 0 4px 0;
      text-indent: 0 !important;
    }
    .ao3-reader-chapter-summary hr {
      display: none;
    }

    /* Chapter loading */
    #ao3-reader-loading {
      position: absolute;
      inset: 0;
      z-index: 3;
      display: none;
      align-items: center;
      justify-content: center;
      background: rgba(0,0,0,0.18);
      -webkit-backdrop-filter: blur(2px);
      backdrop-filter: blur(2px);
    }
    #ao3-reader-loading.show {
      display: flex;
    }
    #ao3-reader-loading .loading-card {
      display: flex;
      align-items: center;
      gap: 12px;
      padding: 14px 18px;
      border-radius: 999px;
      color: inherit;
      background: var(--ao3-reader-card-bg, #fff);
      border: 1px solid rgba(128,128,128,0.24);
      box-shadow: 0 8px 28px rgba(0,0,0,0.22);
    }
    #ao3-reader-loading .loading-spinner {
      width: 18px;
      height: 18px;
      border: 2px solid rgba(128,128,128,0.35);
      border-top-color: currentColor;
      border-radius: 50%;
      animation: ao3-reader-spin 0.8s linear infinite;
    }
    #ao3-reader-loading .loading-text {
      font-size: 14px;
      white-space: nowrap;
    }
    #ao3-reader-loading .error-icon {
      font-size: 22px;
      line-height: 1;
    }
    #ao3-reader-loading.error .loading-card {
      border-color: rgba(200,50,50,0.4);
      box-shadow: 0 8px 28px rgba(200,50,50,0.18);
    }
    @keyframes ao3-reader-spin {
      to {
        transform: rotate(360deg);
      }
    }

    /* Footer */
    #ao3-reader-footer {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 8px 16px;
      min-height: 40px;
      flex-shrink: 0;
      border-top: 1px solid rgba(128,128,128,0.2);
      background: inherit;
    }
    #ao3-reader-footer button {
      background: none;
      border: 1px solid rgba(128,128,128,0.3);
      border-radius: 4px;
      padding: 6px 12px;
      font-size: 13px;
      cursor: pointer;
      color: inherit;
      -webkit-tap-highlight-color: transparent;
    }
    #ao3-reader-footer button:active {
      background: rgba(128,128,128,0.1);
    }
    #ao3-reader-footer button:disabled {
      opacity: 0.3;
    }

    /* Menu — bottom sheet */
    #ao3-reader-menu {
      position: fixed;
      top: 0;
      left: 0;
      width: 100vw;
      height: 100vh;
      height: 100dvh;
      z-index: 10000;
      display: none;
    }
    #ao3-reader-menu.show {
      display: block;
    }
    #ao3-reader-menu .menu-backdrop {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(0,0,0,0.3);
    }
    #ao3-reader-menu .menu-panel {
      position: absolute;
      bottom: 0;
      left: 0;
      width: 100%;
      background: #fff;
      color: #333;
      border-radius: 12px 12px 0 0;
      padding: 20px 24px 28px;
      box-shadow: 0 -4px 24px rgba(0,0,0,0.2);
      max-height: 55vh;
      overflow-y: auto;
      box-sizing: border-box;
    }
    #ao3-reader-menu .menu-panel h3 {
      margin: 0 0 14px 0;
      font-size: 16px;
      text-align: center;
    }
    #ao3-reader-menu .menu-row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      margin: 10px 0;
      font-size: 14px;
    }
    #ao3-reader-menu .menu-row label {
      flex: 1;
    }
    #ao3-reader-menu .menu-row button {
      background: #f0f0f0;
      border: 1px solid #ccc;
      border-radius: 4px;
      padding: 6px 14px;
      font-size: 14px;
      cursor: pointer;
    }
    #ao3-reader-menu .menu-row button:active {
      background: #ddd;
    }
    #ao3-reader-menu .menu-row select {
      padding: 4px 8px;
      font-size: 14px;
      border-radius: 4px;
      border: 1px solid #ccc;
    }
    #ao3-reader-menu .btn-primary {
      display: block;
      width: 100%;
      margin-top: 14px;
      padding: 10px;
      background: #900000 !important;
      color: #fff !important;
      border: none !important;
      border-radius: 6px;
      font-size: 15px;
      cursor: pointer;
    }
  `);

  // ── Default settings ────────────────────────────────────────────────
  const DEFAULTS = {
    swapLR: false,
    fontSize: 18,
    theme: 'light',
    lineHeight: 1.8,
    customColor: '',
    marginTop: 12,
    marginBottom: 12,
    marginLeft: 20,
    marginRight: 20,
  };

  // ── State ───────────────────────────────────────────────────────────
  let settings = { ...DEFAULTS };
  let currentPage = 0;
  let totalPages = 1;
  let chapterLinks = { prev: null, next: null, select: null };
  let chapterTitle = '';
  let workTitle = '';
  let isActive = false;
  let isLoadingChapter = false;
  let readerEventsBound = false;
  let touchStartX = 0;
  let touchStartY = 0;
  let lastPageActionTime = 0;
  let cachedContentEl = null;   // for repaginate after AJAX chapter load
  let chapterSummary = null;    // cloned DOM node for chapter summary

  // DOM refs (populated when reader is created)
  let overlay, header, viewport, pagesEl, footer, menu, pageIndicator, loadingEl;

  // ── Settings (GM storage) ───────────────────────────────────────────
  function loadSettings() {
    const saved = GM_getValue('ao3ReaderSettings', null);
    if (saved) {
      settings = { ...DEFAULTS, ...saved };
    }
  }

  function saveSettings() {
    GM_setValue('ao3ReaderSettings', settings);
  }

  // ── AO3 page parsing ────────────────────────────────────────────────
  function parseAO3Page() {
    const result = { hasContent: false, contentEl: null };

    // Get the userstuff content — exclude blockquote.userstuff used in
    // chapter summaries/notes to avoid picking up summary instead of body
    const allUserstuff = document.querySelectorAll('#chapters .userstuff');
    let userstuff = null;
    for (const el of allUserstuff) {
      if (!el.closest('.summary') && !el.closest('.notes') && el.tagName !== 'BLOCKQUOTE') {
        userstuff = el;
        break;
      }
    }
    if (!userstuff && allUserstuff.length > 0) userstuff = allUserstuff[0];
    if (!userstuff) return result;

    // Get work title
    const titleEl = document.querySelector('h2.title.heading');
    workTitle = titleEl ? titleEl.textContent.trim() : '';

    // Get chapter title
    const chapterTitleEl =
      document.querySelector('#chapters h3.title') ||
      document.querySelector('.chapter .title');
    chapterTitle = chapterTitleEl
      ? chapterTitleEl.textContent.trim()
      : workTitle;

    // Get chapter navigation links
    const prevLink = document.querySelector('li.chapter.previous a');
    const nextLink = document.querySelector('li.chapter.next a');
    chapterLinks.prev = prevLink ? prevLink.href : null;
    chapterLinks.next = nextLink ? nextLink.href : null;

    // Get chapter select dropdown (if exists)
    const chapterSelect = document.querySelector('#selected_id');
    if (chapterSelect && chapterSelect.tagName === 'SELECT') {
      const options = [...chapterSelect.options].map((opt) => ({
        value: opt.value,
        text: opt.textContent.trim(),
        selected: opt.selected,
      }));
      chapterLinks.select = {
        options,
        currentIndex: chapterSelect.selectedIndex,
        onChange: chapterSelect.getAttribute('onchange'),
      };
    }

    // Get author
    const authorEl = document.querySelector('h3.byline a[rel="author"]');
    if (authorEl) {
      chapterTitle = workTitle + ' - ' + authorEl.textContent.trim();
    }

    // Get chapter summary (if exists)
    const summaryBlock = document.querySelector('.preface .summary blockquote.userstuff')
      || document.querySelector('.chapter.preface .summary blockquote.userstuff');
    chapterSummary = summaryBlock ? summaryBlock.cloneNode(true) : null;

    result.hasContent = true;
    result.contentEl = userstuff;
    return result;
  }

  // ── Reading mode UI ─────────────────────────────────────────────────
  function createReaderUI() {
    // Floating entry button
    const entryBtn = document.createElement('button');
    entryBtn.id = 'ao3-reader-entry-btn';
    entryBtn.innerHTML = '📖';
    entryBtn.title = '进入阅读模式';
    entryBtn.addEventListener('click', enterReadingMode);
    document.body.appendChild(entryBtn);

    // Overlay
    overlay = document.createElement('div');
    overlay.id = 'ao3-reader-overlay';
    overlay.style.display = 'none';

    // Header
    header = document.createElement('div');
    header.id = 'ao3-reader-header';
    header.innerHTML = `
      <button id="ao3-reader-btn-exit" title="退出">✕</button>
      <span class="chapter-title">${escapeHtml(chapterTitle)}</span>
      <span class="page-indicator" id="ao3-reader-page-indicator">1/1</span>
    `;

    // Viewport
    viewport = document.createElement('div');
    viewport.id = 'ao3-reader-viewport';

    pagesEl = document.createElement('div');
    pagesEl.id = 'ao3-reader-pages';
    viewport.appendChild(pagesEl);

    // Footer
    footer = document.createElement('div');
    footer.id = 'ao3-reader-footer';
    footer.innerHTML = buildFooterHTML();

    // Menu
    menu = document.createElement('div');
    menu.id = 'ao3-reader-menu';
    menu.innerHTML = buildMenuHTML();

    // Chapter loading overlay
    loadingEl = document.createElement('div');
    loadingEl.id = 'ao3-reader-loading';
    loadingEl.setAttribute('role', 'status');
    loadingEl.setAttribute('aria-live', 'polite');
    loadingEl.setAttribute('aria-hidden', 'true');
    loadingEl.innerHTML = `
      <div class="loading-card">
        <div class="loading-spinner"></div>
        <div class="loading-text">章节加载中...</div>
      </div>
    `;

    overlay.appendChild(header);
    overlay.appendChild(viewport);
    overlay.appendChild(footer);
    overlay.appendChild(loadingEl);
    overlay.appendChild(menu);
    document.body.appendChild(overlay);

    // Cache DOM refs
    pageIndicator = document.getElementById('ao3-reader-page-indicator');
  }

  function buildFooterHTML() {
    const prevDisabled = chapterLinks.prev ? '' : 'disabled';
    const nextDisabled = chapterLinks.next ? '' : 'disabled';
    const prevHref = chapterLinks.prev || '#';
    const nextHref = chapterLinks.next || '#';

    let selectHTML = '';
    if (chapterLinks.select) {
      const opts = chapterLinks.select.options
        .map(
          (o, i) =>
            `<option value="${i}" ${o.selected ? 'selected' : ''}>${escapeHtml(o.text)}</option>`
        )
        .join('');
      selectHTML = `
        <select id="ao3-reader-chapter-select" style="max-width:40%;font-size:12px;">
          ${opts}
        </select>`;
    }

    return `
      <button id="ao3-reader-prev-chapter" data-href="${prevHref}" ${prevDisabled}>← 上一章</button>
      ${selectHTML}
      <button id="ao3-reader-next-chapter" data-href="${nextHref}" ${nextDisabled}>下一章 →</button>
    `;
  }

  function buildMenuHTML() {
    const customDisplay = settings.theme === 'custom' ? '' : 'display:none;';
    const colorVal = settings.customColor || getThemeDefaultColor();
    return `
      <div class="menu-backdrop"></div>
      <div class="menu-panel">
        <h3>阅读设置</h3>
        <div class="menu-row">
          <label>翻页方向</label>
          <select id="ao3-menu-swap">
            <option value="0" ${!settings.swapLR ? 'selected' : ''}>左=上页, 右=下页</option>
            <option value="1" ${settings.swapLR ? 'selected' : ''}>左=下页, 右=上页</option>
          </select>
        </div>
        <div class="menu-row">
          <label>字号</label>
          <div style="display:flex;align-items:center;gap:6px;">
            <button id="ao3-menu-font-down">−</button>
            <span id="ao3-menu-font-val">${settings.fontSize}</span>
            <button id="ao3-menu-font-up">+</button>
          </div>
        </div>
        <div class="menu-row">
          <label>主题</label>
          <select id="ao3-menu-theme">
            <option value="light" ${settings.theme === 'light' ? 'selected' : ''}>浅色</option>
            <option value="sepia" ${settings.theme === 'sepia' ? 'selected' : ''}>护眼</option>
            <option value="dark" ${settings.theme === 'dark' ? 'selected' : ''}>深色</option>
            <option value="custom" ${settings.theme === 'custom' ? 'selected' : ''}>自定义</option>
          </select>
        </div>
        <div class="menu-row" id="ao3-menu-color-row" style="${customDisplay}">
          <label>背景色</label>
          <input type="text" id="ao3-menu-custom-color" value="${colorVal}"
            placeholder="#f5f0e8" pattern="^#[0-9a-fA-F]{6}$"
            style="width:80px;padding:4px 6px;font-size:13px;border:1px solid #ccc;border-radius:4px;">
        </div>
        <div class="menu-row">
          <label>行高</label>
          <select id="ao3-menu-lh">
            <option value="1.5" ${settings.lineHeight === 1.5 ? 'selected' : ''}>1.5</option>
            <option value="1.8" ${settings.lineHeight === 1.8 ? 'selected' : ''}>1.8</option>
            <option value="2.0" ${settings.lineHeight === 2.0 ? 'selected' : ''}>2.0</option>
            <option value="2.2" ${settings.lineHeight === 2.2 ? 'selected' : ''}>2.2</option>
          </select>
        </div>
        <div style="font-size:14px;font-weight:bold;margin:10px 0 4px;">边距设置 (px)</div>
        <div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;">
          <div class="menu-row" style="margin:4px 0;">
            <label>上</label>
            <select id="ao3-menu-mt" style="width:60px;">
              ${[0,4,8,12,16,20,24,28,32,40].map(v => `<option value="${v}" ${(settings.marginTop || 12) === v ? 'selected' : ''}>${v}</option>`).join('')}
            </select>
          </div>
          <div class="menu-row" style="margin:4px 0;">
            <label>下</label>
            <select id="ao3-menu-mb" style="width:60px;">
              ${[0,4,8,12,16,20,24,28,32,40].map(v => `<option value="${v}" ${(settings.marginBottom || 12) === v ? 'selected' : ''}>${v}</option>`).join('')}
            </select>
          </div>
          <div class="menu-row" style="margin:4px 0;">
            <label>左</label>
            <select id="ao3-menu-ml" style="width:60px;">
              ${[4,8,12,16,20,24,28,32,40].map(v => `<option value="${v}" ${(settings.marginLeft || 20) === v ? 'selected' : ''}>${v}</option>`).join('')}
            </select>
          </div>
          <div class="menu-row" style="margin:4px 0;">
            <label>右</label>
            <select id="ao3-menu-mr" style="width:60px;">
              ${[4,8,12,16,20,24,28,32,40].map(v => `<option value="${v}" ${(settings.marginRight || 20) === v ? 'selected' : ''}>${v}</option>`).join('')}
            </select>
          </div>
        </div>
        <button id="ao3-menu-exit" class="btn-primary">退出阅读模式</button>
      </div>
    `;
  }

  // ── Reading mode lifecycle ──────────────────────────────────────────
  function enterReadingMode() {
    if (isActive) return;

    const parsed = parseAO3Page();
    if (!parsed.hasContent) return;

    // Rebuild footer/menu with current state
    if (overlay) {
      footer.innerHTML = buildFooterHTML();
      menu.innerHTML = buildMenuHTML();
    }

    // Use window.innerHeight to avoid browser address bar overlap (Edge)
    overlay.style.height = window.innerHeight + 'px';
    overlay.style.visibility = 'hidden';
    overlay.style.display = 'flex';
    document.body.style.overflow = 'hidden';

    // Render paginated content with accurate viewport height
    const numPages = renderPages(parsed.contentEl);
    if (numPages === 0) {
      overlay.style.display = 'none';
      overlay.style.visibility = '';
      document.body.style.overflow = '';
      return;
    }

    applyTheme();

    // Make visible
    overlay.style.visibility = '';
    document.getElementById('ao3-reader-entry-btn').style.display = 'none';

    isActive = true;
    currentPage = 0;
    totalPages = numPages;
    updatePagePosition();
    updatePageIndicator();

    bindReaderEvents();
  }

  function exitReadingMode() {
    if (!isActive) return;

    overlay.style.display = 'none';
    document.body.style.overflow = '';
    document.getElementById('ao3-reader-entry-btn').style.display = '';
    menu.classList.remove('show');
    hideChapterLoading();
    isActive = false;

    unbindReaderEvents();
  }

  // ── Pagination ──────────────────────────────────────────────────────
  function prepareContentClone(contentEl) {
    const clone = contentEl.cloneNode(true);
    clone.querySelectorAll('.landmark').forEach((el) => el.remove());
    const toast = clone.querySelector('#toast');
    if (toast) toast.remove();

    // Prepend chapter summary at the beginning
    if (chapterSummary) {
      const summaryClone = chapterSummary.cloneNode(true);
      summaryClone.classList.add('ao3-reader-chapter-summary');
      clone.insertBefore(summaryClone, clone.firstChild);
    }

    return clone;
  }

  function renderPages(contentEl) {
    const pageWidth = window.innerWidth;
    // viewport is between header and footer in flex layout
    const pageHeight = viewport.clientHeight || Math.max(200, window.innerHeight - 100);
    const padTop = settings.marginTop || 12;
    const padBottom = settings.marginBottom || 12;
    const padLeft = settings.marginLeft || 20;
    const padRight = settings.marginRight || 20;
    const contentWidth = Math.max(100, pageWidth - padLeft - padRight);
    const contentHeight = Math.max(100, pageHeight - padTop - padBottom);
    const columnGap = padLeft + padRight;

    // Native column fragmentation paginates the real laid-out text flow and
    // breaks between lines, rather than clipping the rendered content.
    const measure = document.createElement('div');
    measure.className = 'ao3-reader-page';
    measure.style.cssText = buildColumnContentStyle({
      contentWidth,
      contentHeight,
      columnGap,
      hidden: true,
    });
    measure.appendChild(prepareContentClone(contentEl));
    document.body.appendChild(measure);

    const measuredWidth = measure.scrollWidth || measure.getBoundingClientRect().width;
    const numPages = Math.max(1, Math.ceil((measuredWidth + columnGap) / pageWidth));
    document.body.removeChild(measure);

    // Cache for repaginate (so we don't re-parse the original DOM)
    cachedContentEl = contentEl;

    // Build page elements
    pagesEl.innerHTML = '';
    pagesEl.style.cssText = `
      position: absolute;
      top: 0;
      left: 0;
      height: 100%;
      width: ${numPages * pageWidth}px;
      font-size: ${settings.fontSize}px;
      line-height: ${settings.lineHeight};
    `;

    const columnContent = document.createElement('div');
    columnContent.className = 'ao3-reader-page';
    columnContent.style.cssText = `
      ${buildColumnContentStyle({ contentWidth, contentHeight, columnGap })}
      position: absolute;
      left: ${padLeft}px;
      top: ${padTop}px;
    `;
    columnContent.appendChild(prepareContentClone(contentEl));
    pagesEl.appendChild(columnContent);

    return numPages;
  }

  function buildColumnContentStyle({ contentWidth, contentHeight, columnGap, hidden = false }) {
    return `
      ${hidden ? 'position: fixed; left: -10000px; top: 0;' : ''}
      width: ${contentWidth}px;
      height: ${contentHeight}px;
      overflow: visible;
      box-sizing: border-box;
      visibility: ${hidden ? 'hidden' : 'visible'};
      pointer-events: ${hidden ? 'none' : 'auto'};
      font-size: ${settings.fontSize}px;
      line-height: ${settings.lineHeight};
      font-family: 'Noto Serif SC', 'Source Han Serif SC', 'Noto Serif CJK SC', Georgia, 'Times New Roman', serif;
      column-width: ${contentWidth}px;
      column-gap: ${columnGap}px;
      column-fill: auto;
      break-inside: auto;
    `;
  }

  function updatePagePosition() {
    pagesEl.style.transform = `translateX(-${currentPage * window.innerWidth}px)`;
  }

  function updatePageIndicator() {
    if (pageIndicator) {
      pageIndicator.textContent = `${currentPage + 1}/${totalPages}`;
    }
  }

  function goToPage(n) {
    if (n < 0 || n >= totalPages) return false;
    currentPage = n;
    updatePagePosition();
    updatePageIndicator();
    return true;
  }

  function goToPrevPage() {
    if (currentPage > 0) {
      currentPage--;
      updatePagePosition();
      updatePageIndicator();
      lastPageActionTime = Date.now();
    } else if (chapterLinks.prev) {
      lastPageActionTime = Date.now();
      loadChapter(chapterLinks.prev);
    }
  }

  function goToNextPage() {
    if (currentPage < totalPages - 1) {
      currentPage++;
      updatePagePosition();
      updatePageIndicator();
      lastPageActionTime = Date.now();
    } else if (chapterLinks.next) {
      lastPageActionTime = Date.now();
      loadChapter(chapterLinks.next);
    }
  }

  function repaginate() {
    if (!cachedContentEl) {
      const parsed = parseAO3Page();
      if (!parsed.hasContent) return;
      cachedContentEl = parsed.contentEl;
    }

    const savedPage = currentPage;
    const numPages = renderPages(cachedContentEl);
    totalPages = numPages;
    currentPage = Math.min(savedPage, totalPages - 1);
    updatePagePosition();
    updatePageIndicator();
  }

  function handleTap(clientX) {
    const vw = window.innerWidth;
    const third = vw / 3;

    if (clientX < third) {
      settings.swapLR ? goToNextPage() : goToPrevPage();
    } else if (clientX > third * 2) {
      settings.swapLR ? goToPrevPage() : goToNextPage();
    } else {
      toggleMenu();
      lastPageActionTime = Date.now();
    }
  }

  // ── Theme ────────────────────────────────────────────────────────────
  function applyTheme() {
    overlay.classList.remove('theme-light', 'theme-sepia', 'theme-dark');
    overlay.classList.add('theme-' + settings.theme);

    if (settings.customColor && /^#[0-9a-fA-F]{6}$/.test(settings.customColor)) {
      overlay.style.backgroundColor = settings.customColor;
      overlay.style.setProperty('--ao3-reader-card-bg', settings.customColor);
    } else {
      overlay.style.backgroundColor = '';
      overlay.style.removeProperty('--ao3-reader-card-bg');
    }
  }

  // ── Menu ────────────────────────────────────────────────────────────
  function toggleMenu() {
    if (menu.classList.contains('show')) {
      menu.classList.remove('show');
    } else {
      // Refresh menu values
      const swapSel = document.getElementById('ao3-menu-swap');
      const fontVal = document.getElementById('ao3-menu-font-val');
      const themeSel = document.getElementById('ao3-menu-theme');
      const colorInput = document.getElementById('ao3-menu-custom-color');
      const colorRow = document.getElementById('ao3-menu-color-row');
      if (swapSel) swapSel.value = settings.swapLR ? '1' : '0';
      if (fontVal) fontVal.textContent = settings.fontSize;
      if (themeSel) themeSel.value = settings.theme;
      if (colorInput) {
        colorInput.value = settings.customColor || getThemeDefaultColor();
      }
      if (colorRow) {
        colorRow.style.display = settings.theme === 'custom' ? '' : 'none';
      }
      const lhSel = document.getElementById('ao3-menu-lh');
      if (lhSel) lhSel.value = String(settings.lineHeight);
      const mtSel = document.getElementById('ao3-menu-mt');
      if (mtSel) mtSel.value = String(settings.marginTop || 12);
      const mbSel = document.getElementById('ao3-menu-mb');
      if (mbSel) mbSel.value = String(settings.marginBottom || 12);
      const mlSel = document.getElementById('ao3-menu-ml');
      if (mlSel) mlSel.value = String(settings.marginLeft || 20);
      const mrSel = document.getElementById('ao3-menu-mr');
      if (mrSel) mrSel.value = String(settings.marginRight || 20);
      menu.classList.add('show');
    }
  }

  function getThemeDefaultColor() {
    switch (settings.theme) {
      case 'light': return '#ffffff';
      case 'sepia': return '#f5f0e8';
      case 'dark': return '#1a1a1a';
      default: return '#f5f0e8';
    }
  }

  function bindMenuEvents() {
    // Exit button
    const exitBtn = document.getElementById('ao3-menu-exit');
    if (exitBtn) {
      exitBtn.addEventListener('click', exitReadingMode);
    }

    // Swap toggle
    const swapSel = document.getElementById('ao3-menu-swap');
    if (swapSel) {
      swapSel.addEventListener('change', function () {
        settings.swapLR = this.value === '1';
        saveSettings();
      });
    }

    // Theme
    const themeSel = document.getElementById('ao3-menu-theme');
    const colorInput = document.getElementById('ao3-menu-custom-color');
    const colorRow = document.getElementById('ao3-menu-color-row');
    if (themeSel) {
      themeSel.addEventListener('change', function () {
        settings.theme = this.value;
        if (this.value === 'custom') {
          if (colorRow) colorRow.style.display = '';
          if (colorInput) {
            settings.customColor = colorInput.value;
          }
        } else {
          if (colorRow) colorRow.style.display = 'none';
          settings.customColor = '';
        }
        applyTheme();
        saveSettings();
      });
    }

    // Custom color input
    if (colorInput) {
      colorInput.addEventListener('input', function () {
        const val = this.value.trim();
        if (/^#[0-9a-fA-F]{6}$/.test(val)) {
          settings.customColor = val;
          settings.theme = 'custom';
          if (themeSel) themeSel.value = 'custom';
          applyTheme();
          saveSettings();
        }
      });
    }

    // Font size
    const fontDown = document.getElementById('ao3-menu-font-down');
    const fontUp = document.getElementById('ao3-menu-font-up');
    const fontVal = document.getElementById('ao3-menu-font-val');

    if (fontDown) {
      fontDown.addEventListener('click', () => {
        if (settings.fontSize > 12) {
          settings.fontSize--;
          if (fontVal) fontVal.textContent = settings.fontSize;
          saveSettings();
          repaginate();
        }
      });
    }
    if (fontUp) {
      fontUp.addEventListener('click', () => {
        if (settings.fontSize < 28) {
          settings.fontSize++;
          if (fontVal) fontVal.textContent = settings.fontSize;
          saveSettings();
          repaginate();
        }
      });
    }

    // Line height
    const lhSel = document.getElementById('ao3-menu-lh');
    if (lhSel) {
      lhSel.addEventListener('change', function () {
        settings.lineHeight = parseFloat(this.value);
        saveSettings();
        repaginate();
      });
    }

    // Margins
    ['mt','mb','ml','mr'].forEach((key) => {
      const sel = document.getElementById('ao3-menu-' + key);
      if (!sel) return;
      sel.addEventListener('change', function () {
        const map = { mt: 'marginTop', mb: 'marginBottom', ml: 'marginLeft', mr: 'marginRight' };
        settings[map[key]] = parseInt(this.value);
        saveSettings();
        repaginate();
      });
    });
  }

  // ── Chapter navigation ──────────────────────────────────────────────
  function bindFooterEvents() {
    const prevBtn = document.getElementById('ao3-reader-prev-chapter');
    const nextBtn = document.getElementById('ao3-reader-next-chapter');
    const selectEl = document.getElementById('ao3-reader-chapter-select');

    if (prevBtn) {
      prevBtn.addEventListener('click', (e) => {
        e.stopPropagation();
        const href = prevBtn.getAttribute('data-href');
        if (href && href !== '#') {
          loadChapter(href);
        }
      });
    }

    if (nextBtn) {
      nextBtn.addEventListener('click', (e) => {
        e.stopPropagation();
        const href = nextBtn.getAttribute('data-href');
        if (href && href !== '#') {
          loadChapter(href);
        }
      });
    }

    if (selectEl && chapterLinks.select) {
      selectEl.addEventListener('change', (e) => {
        e.stopPropagation();
        const idx = parseInt(selectEl.value);
        const opt = chapterLinks.select.options[idx];
        if (opt && opt.value) {
          const workId = extractWorkId();
          if (workId) {
            loadChapter(buildChapterUrl(opt.value, workId));
          }
        }
      });
    }
  }

  function extractWorkId(url) {
    const m = (url || window.location.pathname).match(/\/works\/(\d+)/);
    return m ? m[1] : null;
  }

  // Build a chapter URL from an option value which may be:
  //   a full URL  → use as-is
  //   a path      → prepend origin
  //   a bare ID   → construct /works/{workId}/chapters/{id}
  function buildChapterUrl(optValue, workId) {
    if (/^https?:\/\//i.test(optValue)) return optValue;
    if (/^\/works\/\d+\/chapters\/\d+/.test(optValue)) {
      return window.location.origin + optValue;
    }
    return 'https://archiveofourown.org/works/' + workId + '/chapters/' + optValue;
  }

  function showChapterLoading() {
    if (!loadingEl) return;
    menu.classList.remove('show');
    // Restore loading spinner HTML (in case error state was previously shown)
    loadingEl.querySelector('.loading-card').innerHTML = `
      <div class="loading-spinner"></div>
      <div class="loading-text">章节加载中...</div>
    `;
    loadingEl.classList.remove('error');
    loadingEl.classList.add('show');
    loadingEl.setAttribute('aria-hidden', 'false');
  }

  function hideChapterLoading() {
    if (!loadingEl) return;
    loadingEl.classList.remove('show', 'error');
    loadingEl.setAttribute('aria-hidden', 'true');
  }

  function showChapterError(message) {
    if (!loadingEl) return;
    const card = loadingEl.querySelector('.loading-card');
    if (card) {
      card.innerHTML = `
        <div class="error-icon">⚠</div>
        <div class="loading-text">${escapeHtml(message)}</div>
      `;
      card.style.cursor = 'pointer';
      card.onclick = () => hideChapterLoading();
    }
    loadingEl.classList.add('show', 'error');
    loadingEl.setAttribute('aria-hidden', 'false');
    // Auto-dismiss after 4 seconds
    clearTimeout(loadingEl._errorTimer);
    loadingEl._errorTimer = setTimeout(() => hideChapterLoading(), 4000);
  }

  async function loadChapter(url, opts = {}) {
    if (isLoadingChapter) return;
    const { updateHistory = true } = opts;
    isLoadingChapter = true;
    showChapterLoading();

    // Fetch the chapter page
    let html;
    try {
      const resp = await fetch(url, { credentials: 'include' });
      if (!resp.ok) throw new Error('HTTP ' + resp.status);
      html = await resp.text();
    } catch (err) {
      isLoadingChapter = false;
      showChapterError('章节加载失败,请检查网络连接后重试');
      return;
    }

    const parser = new DOMParser();
    const doc = parser.parseFromString(html, 'text/html');

    // Extract body content — same filtering as parseAO3Page
    const allUserstuff = doc.querySelectorAll('#chapters .userstuff');
    let userstuff = null;
    for (const el of allUserstuff) {
      if (!el.closest('.summary') && !el.closest('.notes') && el.tagName !== 'BLOCKQUOTE') {
        userstuff = el;
        break;
      }
    }
    if (!userstuff && allUserstuff.length > 0) userstuff = allUserstuff[0];
    if (!userstuff) {
      isLoadingChapter = false;
      showChapterError('章节内容解析失败,该页面可能不是有效的AO3章节页面');
      return;
    }

    // Extract metadata
    const titleEl = doc.querySelector('h2.title.heading');
    workTitle = titleEl ? titleEl.textContent.trim() : workTitle;

    const chapterTitleEl =
      doc.querySelector('#chapters h3.title') ||
      doc.querySelector('.chapter .title');
    chapterTitle = chapterTitleEl
      ? chapterTitleEl.textContent.trim()
      : workTitle;

    const authorEl = doc.querySelector('h3.byline a[rel="author"]');
    if (authorEl) {
      chapterTitle = workTitle + ' - ' + authorEl.textContent.trim();
    }

    // Get chapter summary from fetched page
    const summaryBlock = doc.querySelector('.preface .summary blockquote.userstuff')
      || doc.querySelector('.chapter.preface .summary blockquote.userstuff');
    // Import node from parsed document into current document
    chapterSummary = summaryBlock ? document.importNode(summaryBlock, true) : null;

    // Chapter navigation
    const prevLink = doc.querySelector('li.chapter.previous a');
    const nextLink = doc.querySelector('li.chapter.next a');
    chapterLinks.prev = prevLink ? prevLink.href : null;
    chapterLinks.next = nextLink ? nextLink.href : null;

    const chapterSelect = doc.querySelector('#selected_id');
    if (chapterSelect && chapterSelect.tagName === 'SELECT') {
      chapterLinks.select = {
        options: [...chapterSelect.options].map((opt) => ({
          value: opt.value,
          text: opt.textContent.trim(),
          selected: opt.selected,
        })),
        currentIndex: chapterSelect.selectedIndex,
        onChange: chapterSelect.getAttribute('onchange'),
      };
    } else {
      chapterLinks.select = null;
    }

    // Update UI
    const titleSpan = header.querySelector('.chapter-title');
    if (titleSpan) titleSpan.textContent = chapterTitle;

    // Re-paginate with new content
    const tempContainer = document.createElement('div');
    while (userstuff.firstChild) {
      tempContainer.appendChild(userstuff.firstChild);
    }

    const numPages = renderPages(tempContainer);
    totalPages = numPages;
    currentPage = 0;
    updatePagePosition();
    updatePageIndicator();

    // Update footer navigation
    footer.innerHTML = buildFooterHTML();
    bindFooterEvents();

    // Update URL (skip for popstate — browser already handled it)
    if (updateHistory) {
      history.pushState({ ao3Reader: true }, '', url);
    }

    isLoadingChapter = false;
    hideChapterLoading();
  }

  // ── Event handling ──────────────────────────────────────────────────
  function isInteractiveTarget(el) {
    return el.closest('a, button, select, input, textarea, [role="button"]');
  }

  function onViewportClick(e) {
    if (isInteractiveTarget(e.target)) return;
    // Suppress click if a touch event already triggered a page action
    // (mobile browsers fire both touchend and click for the same tap)
    if (Date.now() - lastPageActionTime < 500) return;
    e.preventDefault();
    handleTap(e.clientX);
  }

  function onViewportTouchStart(e) {
    if (e.touches.length === 1) {
      touchStartX = e.touches[0].clientX;
      touchStartY = e.touches[0].clientY;
    }
  }

  function onViewportTouchEnd(e) {
    if (menu.classList.contains('show')) return;
    if (isInteractiveTarget(e.target)) return;

    const dx = e.changedTouches[0].clientX - touchStartX;
    const dy = e.changedTouches[0].clientY - touchStartY;

    // Swipe detection
    if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 40) {
      e.preventDefault();
      if (dx > 0) {
        settings.swapLR ? goToNextPage() : goToPrevPage();
      } else {
        settings.swapLR ? goToPrevPage() : goToNextPage();
      }
      return;
    }

    // Tap detection (minimal movement)
    if (Math.abs(dx) < 10 && Math.abs(dy) < 10) {
      handleTap(e.changedTouches[0].clientX);
    }
  }

  function onKeyDown(e) {
    if (menu.classList.contains('show')) {
      if (e.key === 'Escape') toggleMenu();
      return;
    }
    if (e.key === 'ArrowLeft') {
      settings.swapLR ? goToNextPage() : goToPrevPage();
    } else if (e.key === 'ArrowRight') {
      settings.swapLR ? goToPrevPage() : goToNextPage();
    } else if (e.key === 'Escape') {
      exitReadingMode();
    }
  }

  function onResize() {
    if (!isActive) return;
    overlay.style.height = window.innerHeight + 'px';
    repaginate();
  }

  function onPopState() {
    if (!isActive) return;
    loadChapter(window.location.href, { updateHistory: false });
  }

  function onMenuBackdropClick(e) {
    if (e.target.classList.contains('menu-backdrop')) {
      toggleMenu();
    }
  }

  function bindReaderEvents() {
    if (readerEventsBound) return;

    viewport.addEventListener('click', onViewportClick);
    viewport.addEventListener('touchstart', onViewportTouchStart, {
      passive: true,
    });
    viewport.addEventListener('touchend', onViewportTouchEnd);
    document.addEventListener('keydown', onKeyDown);
    window.addEventListener('resize', onResize);
    window.addEventListener('popstate', onPopState);
    menu.addEventListener('click', onMenuBackdropClick);

    // Header exit button
    const exitBtn = document.getElementById('ao3-reader-btn-exit');
    if (exitBtn) {
      exitBtn.addEventListener('click', exitReadingMode);
    }

    bindMenuEvents();
    bindFooterEvents();

    readerEventsBound = true;
  }

  function unbindReaderEvents() {
    if (!readerEventsBound) return;

    viewport.removeEventListener('click', onViewportClick);
    viewport.removeEventListener('touchstart', onViewportTouchStart);
    viewport.removeEventListener('touchend', onViewportTouchEnd);
    document.removeEventListener('keydown', onKeyDown);
    window.removeEventListener('resize', onResize);
    window.removeEventListener('popstate', onPopState);
    menu.removeEventListener('click', onMenuBackdropClick);

    const exitBtn = document.getElementById('ao3-reader-btn-exit');
    if (exitBtn) {
      exitBtn.removeEventListener('click', exitReadingMode);
    }

    readerEventsBound = false;
  }

  // ── Utils ───────────────────────────────────────────────────────────
  function escapeHtml(str) {
    const div = document.createElement('div');
    div.textContent = str;
    return div.innerHTML;
  }

  // ── Init ────────────────────────────────────────────────────────────
  function init() {
    loadSettings();

    // Check if we're on a work page with content
    const parsed = parseAO3Page();
    if (!parsed.hasContent) return;

    createReaderUI();
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();