LINUX SB READ

给 linux.sb 论坛添加自定义图片背景(本地上传 / 图片直链 / 多图轮播,毛玻璃半透明可调),并提供双栏阅读与弹框阅读模式

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         LINUX SB READ
// @namespace    http://tampermonkey.net/
// @version      1.7.3
// @description  给 linux.sb 论坛添加自定义图片背景(本地上传 / 图片直链 / 多图轮播,毛玻璃半透明可调),并提供双栏阅读与弹框阅读模式
// @author       You
// @license      MIT
// @match        https://linux.sb/*
// @match        https://www.linux.sb/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @connect      imgur.la
// @run-at       document-start
// @noframes
// ==/UserScript==

(function () {
    'use strict';

    // ── 存储键 ────────────────────────────────────────────────────────────
    // 图片单独存一个键:base64 体积大,读写配置时不必反复搬运整个图库。
    const KEY_CONFIG = 'lsb_bg_config';
    const KEY_IMAGES = 'lsb_bg_images';
    const IMGUR_API_URL = 'https://imgur.la/api/1/upload';
    // imgur.la API 文档公开的公共 Key;可在设置面板中替换为用户自己的 Key。
    const IMGUR_PUBLIC_API_KEY = '89bf00be2f91e3e5c74ea050d5b1d3f3';
    const LEGACY_REPLY_PRESETS = 'B友牛逼\n非必要就抽奖';
    const DEFAULT_REPLY_PRESETS = [
        'B友牛逼',
        '非必要就抽奖',
        '感谢分享!',
        '感谢楼主分享',
        '学习了,谢谢分享!',
        '感谢大佬解答',
        '受教了',
        '支持一下',
        'Mark 一下,之后再看'
    ].join('\n');

    const DEFAULTS = {
        enabled: true,
        activeIndex: 0,       // 当前显示的图片下标
        panelAlpha: 0.72,     // 面板(卡片/顶栏)不透明度
        blur: 12,             // 毛玻璃模糊半径 px
        maskAlpha: 0.15,      // 背景图上方蒙版浓度,压住过于花的图
        brightness: 1,        // 背景图亮度
        size: 'cover',        // cover | contain | repeat
        position: 'center',
        fixed: true,          // 背景是否随页面滚动
        carousel: false,      // 是否轮播
        interval: 60,         // 轮播间隔(秒)
        shuffle: false,       // 轮播是否随机顺序
        theme: 'auto',        // auto 跟随站点明暗 | light | dark
        reader: true,         // 双栏阅读:左栏列表 + 右栏正文
        readerWidth: 380,     // 纯净模式左栏宽度 px
        readerHideSidebar: false, // 双栏阅读是否隐藏站点右侧栏
        readerModal: false,   // 弹框阅读:点击帖子后在弹框内打开
        readerModalWidth: 80, // 弹框阅读宽度,占视口百分比
        replyPresets: DEFAULT_REPLY_PRESETS, // 快捷回复预设,每行一条
        imgurApiKey: IMGUR_PUBLIC_API_KEY // imgur.la API Key,用于回复中快捷上传图片
    };

    // 上传图片压缩上限:原图动辄 5MB+,压到这个尺寸后 base64 通常 200~600KB,
    // 既不撑爆油猴存储,也不会让 CSS url() 解析变慢。
    const MAX_EDGE = 2560;
    const JPEG_QUALITY = 0.85;

    const CSS_ID = 'lsb-bg-style';
    const ROOT_ID = 'lsb-bg-root';
    const PANEL_ID = 'lsb-bg-settings';
    const NAV_TOGGLE_ID = 'lsb-bg-nav-toggle';
    const READER_CSS_ID = 'lsb-reader-style';
    const READER_CLASS = 'lsb-reader-on';   // 挂在 <html> 上,纯净模式的 CSS 总开关
    const READER_MODAL_CLASS = 'lsb-reader-modal-on';
    const READER_MODAL_ID = 'lsb-reader-modal';
    const PLUGIN_ICON_SVG = '<svg viewBox="0 0 32 18" fill="currentColor" aria-hidden="true"><text x="16" y="14" text-anchor="middle" font-family="Arial, sans-serif" font-size="15" font-weight="700" letter-spacing=".2">lsb</text></svg>';

    // ── 配置读写 ──────────────────────────────────────────────────────────
    function loadConfig() {
        let raw = {};
        try {
            raw = JSON.parse(GM_getValue(KEY_CONFIG, '{}')) || {};
        } catch (e) {
            raw = {};
        }
        const config = Object.assign({}, DEFAULTS, raw);
        // 新增弹框模式时,未保存过 reader 的配置应默认只启用弹框,避免被双栏默认值覆盖。
        if (raw.readerModal === true && !Object.prototype.hasOwnProperty.call(raw, 'reader')) {
            config.reader = false;
        }
        // 阅读模式只允许一种生效;旧配置若同时打开,优先保留原有的双栏阅读。
        if (config.reader && config.readerModal) {
            config.readerModal = false;
            GM_setValue(KEY_CONFIG, JSON.stringify(config));
        }
        // 仅把插件原先的两条默认话术升级为新默认列表;用户自己编辑过的内容保持原样。
        if (raw.replyPresets === LEGACY_REPLY_PRESETS) {
            config.replyPresets = DEFAULT_REPLY_PRESETS;
            GM_setValue(KEY_CONFIG, JSON.stringify(config));
        }
        return config;
    }

    function saveConfig(cfg) {
        GM_setValue(KEY_CONFIG, JSON.stringify(cfg));
    }

    /**
     * 图库结构:[{ id, name, type: 'url' | 'local', src }]
     * local 的 src 是 data:image/...;base64,...,url 的 src 是外链地址。
     */
    function loadImages() {
        try {
            const list = JSON.parse(GM_getValue(KEY_IMAGES, '[]'));
            return Array.isArray(list) ? list : [];
        } catch (e) {
            return [];
        }
    }

    function saveImages(list) {
        GM_setValue(KEY_IMAGES, JSON.stringify(list));
    }

    let config = loadConfig();
    let images = loadImages();
    let carouselTimer = null;

    const REPLY_EMOJIS = ['😀', '😃', '😄', '😁', '😆', '😂', '🤣', '😊', '🙂', '😉', '😍', '🥰', '😘', '😋', '😎', '🤩', '🥳', '😏', '😒', '😔', '😕', '🙁', '😣', '🥺', '😢', '😭', '😤', '😠', '😡', '🤬', '🤯', '😳', '😱', '🤔', '🤭', '🤫', '😶', '🙄', '😮', '😴', '🤗', '👍', '👎', '👏', '🙏', '💪', '🔥', '✨', '🎉', '❤️', '💔', '✅', '❌', '⭐'];
    let replyEmojiPicker = null;
    let replyEmojiButton = null;
    let replyFeaturesBound = false;

    const REPLY_CSS = `
.reply-panel-head .lsb-reply-head-tools{display:flex;align-items:center;gap:6px;margin-right:auto}
.reply-panel-head .lsb-reply-emoji{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;min-width:28px;padding:0;border:1px solid var(--line,rgba(128,128,128,.25));border-radius:4px;background:#fff;color:var(--text,#333);cursor:pointer;font-size:16px;line-height:1}
.reply-panel-head .lsb-reply-upload{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;min-width:28px;padding:0;border:1px solid var(--line,rgba(128,128,128,.25));border-radius:4px;background:#fff;color:var(--text,#333);cursor:pointer;font-size:16px;line-height:1}
.reply-panel-head .lsb-reply-upload svg{width:18px;height:18px;display:block;overflow:visible}
.reply-panel-head .lsb-reply-upload input{display:none}
.reply-panel-head .lsb-reply-upload[data-uploading]{opacity:.55;cursor:wait}
.reply-panel-head .lsb-reply-preset{width:min(190px,28vw);height:28px;min-width:110px;padding:0 24px 0 8px;border:1px solid var(--line,rgba(128,128,128,.25));border-radius:4px;background:#fff;color:var(--text,#333);font:inherit;cursor:pointer;color-scheme:light}
.reply-panel-head .lsb-reply-preset option{background:#fff!important;color:#1f2937!important}
.reply-panel-head .lsb-reply-preset option:checked{background:#2563eb!important;color:#fff!important}
html.lsb-reply-dark .reply-panel-head .lsb-reply-preset{background:#25283a!important;color:#f4f7ff!important;color-scheme:dark}
html.lsb-reply-dark .reply-panel-head .lsb-reply-preset option{background:#25283a!important;color:#f4f7ff!important}
html.lsb-reply-dark .reply-panel-head .lsb-reply-preset option:checked{background:#2563eb!important;color:#fff!important}
.reply-panel-head .lsb-reply-emoji:hover,.reply-panel-head .lsb-reply-upload:hover,.reply-panel-head .lsb-reply-preset:hover{border-color:var(--brand,#516185);background:#f2f4f8}
.lsb-emoji-picker{position:fixed;z-index:2147483500;display:grid;grid-template-columns:repeat(8,30px);gap:4px;padding:8px;border:1px solid var(--line,rgba(128,128,128,.3));border-radius:6px;background:#fff;box-shadow:0 8px 24px rgba(0,0,0,.2)}
.lsb-emoji-picker[hidden]{display:none}
.lsb-emoji-picker button{width:30px;height:30px;padding:0;border:0;border-radius:4px;background:#fff;color:var(--text,#333);cursor:pointer;font-size:19px;line-height:1}
.lsb-emoji-picker button:hover{background:#eef2ff}
html.lsb-reply-dark .reply-panel-head .lsb-reply-emoji{background:#25283a!important;color:#f4f7ff!important}
html.lsb-reply-dark .reply-panel-head .lsb-reply-upload{background:#25283a!important;color:#f4f7ff!important}
html.lsb-reply-dark .reply-panel-head .lsb-reply-emoji:hover,html.lsb-reply-dark .reply-panel-head .lsb-reply-preset:hover{background:#343953!important}
html.lsb-reply-dark .reply-panel-head .lsb-reply-upload:hover{background:#343953!important}
html.lsb-reply-dark .lsb-emoji-picker{background:#25283a!important}
html.lsb-reply-dark .lsb-emoji-picker button{background:#25283a!important;color:#f4f7ff!important}
html.lsb-reply-dark .lsb-emoji-picker button:hover{background:#343953!important}
@media(max-width:720px){.reply-panel-head{flex-wrap:wrap}.reply-panel-head .lsb-reply-head-tools{order:3;flex:1 0 100%}.reply-panel-head .lsb-reply-preset{width:auto;flex:1}}
`;

    function clampIndex() {
        if (!images.length) {
            config.activeIndex = 0;
            return;
        }
        if (config.activeIndex < 0 || config.activeIndex >= images.length) {
            config.activeIndex = 0;
        }
    }

    function currentImage() {
        clampIndex();
        return images[config.activeIndex] || null;
    }

    // ── 背景层 + 样式注入 ─────────────────────────────────────────────────
    function cssUrl(src) {
        // data: 与普通 URL 都可能含引号/括号/换行,统一转义后放进 url("...")
        return 'url("' + String(src).replace(/["\\]/g, '\\$&').replace(/\s+/g, '') + '")';
    }

    /** 极早期注入时文档可能连 documentElement 都还没有,取不到挂载点就返回 null */
    function mountPoint() {
        return document.head || document.body || document.documentElement || null;
    }

    function ensureBgRoot() {
        const host = document.body || document.documentElement;
        if (!host) return null;
        let el = document.getElementById(ROOT_ID);
        if (!el || !el.isConnected) {
            el = document.createElement('div');
            el.id = ROOT_ID;
            host.appendChild(el);
        } else if (document.body && el.parentNode !== document.body) {
            // 早期挂在 documentElement 上,body 就绪后迁进去
            document.body.appendChild(el);
        }
        return el;
    }

    // ── 明暗判定 ──────────────────────────────────────────────────────────
    // 站点自带的暗夜模式由 themes 插件下发 CSS 变量覆盖(页面里那段
    // <style data-themes-plugin>),没有 .dark 类名也没有 data-theme 属性可认,
    // 所以只能反过来读站点变量的实际亮度来判断当前是明还是暗。

    /** 解析 CSS 颜色为 [r,g,b],解析不出来返回 null */
    function parseRgb(color) {
        const s = String(color || '').trim();
        if (!s) return null;
        let m = s.match(/^#([0-9a-f]{3,8})$/i);
        if (m) {
            let h = m[1];
            if (h.length === 3 || h.length === 4) h = h.slice(0, 3).split('').map(c => c + c).join('');
            if (h.length < 6) return null;
            return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16));
        }
        m = s.match(/^rgba?\(([^)]+)\)$/i);
        if (m) {
            const p = m[1].split(/[,\s/]+/).filter(Boolean).map(Number);
            if (p.length >= 3 && p.slice(0, 3).every(n => Number.isFinite(n))) return p.slice(0, 3);
        }
        return null;
    }

    function luminance(color) {
        const rgb = parseRgb(color);
        return rgb ? 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2] : null;
    }

    /** 当前是否应按深色渲染 */
    function isDarkNow() {
        if (config.theme === 'dark') return true;
        if (config.theme === 'light') return false;

        const cs = getComputedStyle(document.documentElement);
        // --bg 是站点的页面底色,我们不覆盖它,可以放心当作明暗依据
        const bg = luminance(cs.getPropertyValue('--bg'));
        if (bg !== null) return bg < 128;
        // 变量还没加载(document-start 极早期)时退而看正文色:浅字=深色主题
        const text = luminance(cs.getPropertyValue('--text'));
        if (text !== null) return text > 150;
        return !!(window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches);
    }

    function buildCss() {
        const img = currentImage();
        if (!config.enabled || !img) return '';

        const dark = isDarkNow();
        const a = Number(config.panelAlpha);
        // 深色模式下面板要用深色半透明,否则白底盖在深色文字上直接糊成一片
        const base = dark ? '22,24,29' : '255,255,255';
        const panel = `rgba(${base},${a.toFixed(3)})`;
        // 输入框比卡片更需要对比度,单独抬高一档不透明度
        const field = `rgba(${base},${Math.min(1, a + 0.18).toFixed(3)})`;
        // 蒙版跟着主题走:浅色压白、深色压黑,都是为了让面板文字浮出来
        const mask = `rgba(${dark ? '0,0,0' : '255,255,255'},${Number(config.maskAlpha).toFixed(3)})`;
        const line = dark ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.10)';
        const lineSoft = dark ? 'rgba(255,255,255,.08)' : 'rgba(0,0,0,.06)';
        const hover = dark ? 'rgba(255,255,255,.06)' : 'rgba(0,0,0,.04)';
        // checkbox 滑钮固定色:深色轨道配浅灰钮,浅色轨道配白钮
        const swTrack = dark ? 'rgba(255,255,255,.16)' : '#e3e3e3';
        const swKnob = dark ? '#c9ced6' : '#fff';
        const drawer = dark ? '#1b1d22' : '#fff';
        const blur = `blur(${Number(config.blur)}px)`;
        const repeat = config.size === 'repeat' ? 'repeat' : 'no-repeat';
        const size = config.size === 'repeat' ? 'auto' : config.size;

        return `
/* 固定模式:背景层钉在视口上,滚动时图不动。
   跟随模式:改成 absolute 并撑到整篇文档高度,图随内容一起滚。
   (background-attachment 对 position:fixed 的元素无效,必须换定位方式) */
#${ROOT_ID}{
    position:${config.fixed ? 'fixed' : 'absolute'};
    ${config.fixed ? 'inset:0;' : 'top:0;left:0;width:100%;min-height:100vh;height:100%;'}
    z-index:-1;
    pointer-events:none;
    background-image:${cssUrl(img.src)};
    background-size:${size};
    background-position:${config.position};
    background-repeat:${repeat};
    filter:brightness(${Number(config.brightness)});
}
#${ROOT_ID}::after{
    content:"";
    position:absolute;
    inset:0;
    background:${mask};
}
html,body{background:transparent!important}
${config.fixed ? '' : '/* 跟随滚动时背景层要以 body 为定位基准,否则高度只有一屏 */\nbody{position:relative}'}
/* 站点所有面板都走 --panel,改这一个变量即可整体半透明。
   --text / --text-muted 一律不动,交给站点自己的明暗主题决定,
   这样切换暗夜模式时文字颜色始终是站点配好的那一套。 */
:root{
    --panel:${panel}!important;
    --line:${line}!important;
    --line-soft:${lineSoft}!important;
}
.top,.forum-more-region,.home-shell,.main-panel,.box,.card,.list,
.user-header,.user-bio,.modal-panel,
.search-form,.reply-panel,.admin-list-panel,.bulk-bar,.side{
    -webkit-backdrop-filter:${blur} saturate(140%);
    backdrop-filter:${blur} saturate(140%);
}
/* 输入框比卡片更需要对比度,单独抬高一档。
   必须排除 checkbox/radio/range/file/按钮:给它们设 background 会抹掉
   Chrome 的原生控件外观(勾选框会变成空白方块,看不出选中状态)。 */
input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]):not([type=color]):not([type=button]):not([type=submit]):not([type=reset]),
select,textarea,.prompt-input,.notify-form textarea{
    background:${field}!important;
}
/* 站点的 checkbox 是自绘开关:轨道用 --line-soft,滑钮用 radial-gradient(var(--panel))。
   这两个变量被我们改成半透明后滑钮就看不见了,这里用固定色还原开关外观。 */
input[type=checkbox]{
    background-color:${swTrack}!important;
    background-image:radial-gradient(circle,${swKnob} 0 7px,transparent 7.5px)!important;
    border-color:${line}!important;
    -webkit-backdrop-filter:none!important;
    backdrop-filter:none!important;
}
input[type=checkbox]:checked{
    background-color:var(--brand,#2ecc71)!important;
    border-color:var(--brand,#2ecc71)!important;
}
/* 原本 hover 用不透明的 --bg,会把背景图挡掉 */
.post-item:hover,.profile-file-row:hover,.post-entry:hover{
    background:${hover}!important;
}
/* 移动端抽屉不参与半透明:本插件只针对桌面端,抽屉保持原样更稳妥 */
.mobile-menu-drawer,.mobile-menu-body,.mobile-menu-close{
    background:${drawer}!important;
}
`;
    }

    function applyStyle() {
        ensureBgRoot();
        const host = mountPoint();
        if (!host) return false;
        let style = document.getElementById(CSS_ID);
        // document-start 注入的节点可能在文档解析过程中被丢弃,
        // 所以这里不仅认 id,还要确认它仍挂在当前文档里。
        if (!style || !style.isConnected) {
            style = document.createElement('style');
            style.id = CSS_ID;
            host.appendChild(style);
        }
        style.textContent = buildCss();
        return true;
    }

    // ── 纯净模式 ──────────────────────────────────────────────────────────
    // 站点原本是单栏:列表页点标题整页跳到 /topic/N。纯净模式把 .forum-layout
    // 改成「左栏帖子列表 / 右栏帖子正文」,点击列表用 fetch 取回帖子页,抽出
    // .main-panel 塞进右栏,不整页跳转。两栏各自滚动,外层页面基本不滚。
    const READER_VIEW_ID = 'lsb-reader-view';
    const READER_BAR_ID = 'lsb-reader-split';
    const READER_MIN_W = 260;
    const READER_MAX_W = 720;

    let readerDirty = false;   // 是否改动过站点自己的 DOM(关闭时需刷新还原)
    let topicMoved = false;    // 帖子页的正文是否已搬进右栏
    let listUrl = '';          // 左栏当前对应的列表地址
    let listLoading = false;
    let readerModalEl = null;
    let readerModalBodyEl = null;
    let readerModalTitleEl = null;
    let readerModalHistoryPushed = false;
    let readerModalBaseUrl = '';
    let readerModalBaseState = null;
    let readerModalBodyOverflow = '';

    function urlOf(u) {
        try { return new URL(u || location.href, location.href); } catch (e) { return null; }
    }
    function isTopicPage(u) {
        const x = urlOf(u);
        return !!x && /^\/topic\/\d+/.test(x.pathname);
    }
    function isListPage(u) {
        const x = urlOf(u);
        if (!x) return false;
        return x.pathname === '/' || x.pathname === '/index.php' || /^\/forum\/\d+\/?$/.test(x.pathname);
    }
    /** 只有列表页和帖子页才有阅读模式语义,其它页面(个人主页等)不介入。 */
    function readerUsable() { return isTopicPage() || isListPage(); }
    function readerOn() { return !!config.reader && !config.readerModal && document.documentElement.classList.contains(READER_CLASS); }
    function readerModalOn() { return !!config.readerModal && document.documentElement.classList.contains(READER_MODAL_CLASS); }

    function readerLayout() { return document.querySelector('.forum-layout'); }
    function readerMain() { return document.querySelector('.forum-main'); }
    function readerViewEl() { return document.getElementById(READER_VIEW_ID); }

    function readerWidth() {
        return Math.max(READER_MIN_W, Math.min(READER_MAX_W, Number(config.readerWidth) || 380));
    }

    function readerModalWidth() {
        return Math.max(50, Math.min(96, Number(config.readerModalWidth) || 80));
    }

    function buildReaderCss() {
        if (!config.reader && !config.readerModal) return '';
        const dark = isDarkNow();
        const active = dark ? 'rgba(255,255,255,.10)' : 'rgba(0,0,0,.05)';
        const hover = dark ? 'rgba(255,255,255,.05)' : 'rgba(0,0,0,.03)';
        const w = readerWidth();
        const modalWidth = readerModalWidth();
        const modalSurface = dark ? '#1b1d22' : '#fff';
        // 两栏高度靠 measureReaderHeight() 实测后写进 --lsb-rd-h;这里给个保守初值,
        // 避免样式先于测量生效时闪一下过高的两栏。
        return `
html.${READER_CLASS}{--lsb-rd-h:calc(100vh - 110px)}
/* 双栏需要横向空间,放开站点 1100px 的居中限制 */
html.${READER_CLASS} .wrap{max-width:none}
html.${READER_CLASS} .home-shell{padding:12px}
/* 站点在有侧栏时用的是 display:grid + grid-template-columns:1fr 230px,
   两列写死了;插入第三、第四个子元素会被挤到下一行。这里必须改回 flex,
   否则右栏会掉到列表下面(而不是并排)。 */
html.${READER_CLASS} .home-shell .forum-layout,
html.${READER_CLASS} .forum-layout,
html.${READER_CLASS} .forum-layout-has-sidebar{
    display:flex!important; grid-template-columns:none!important;
    min-height:0;
    gap:0; align-items:stretch;
}
html.${READER_CLASS} .forum-main{
    flex:0 0 ${w}px; width:${w}px; min-width:0;
    min-height:0; max-height:var(--lsb-rd-h);
    height:var(--lsb-rd-h); overflow:auto; overscroll-behavior:contain;
    padding-right:10px;
}
html.${READER_CLASS} #${READER_VIEW_ID}{
    flex:1 1 auto; min-width:0; min-height:0; max-height:var(--lsb-rd-h);
    height:var(--lsb-rd-h); overflow:auto; overscroll-behavior:contain;
    padding-left:14px;
}
/* 保留滚轮和触控板滚动,只隐藏双栏内部的滚动条外观。 */
html.${READER_CLASS} .forum-main,
html.${READER_CLASS} #${READER_VIEW_ID},
html.${READER_CLASS} .sidebar{
    scrollbar-width:none; -ms-overflow-style:none;
}
html.${READER_CLASS} .forum-main::-webkit-scrollbar,
html.${READER_CLASS} #${READER_VIEW_ID}::-webkit-scrollbar,
html.${READER_CLASS} .sidebar::-webkit-scrollbar{
    display:none; width:0; height:0;
}
/* 右栏内层 .main-panel 自带 --panel 底色,右栏本体保持透明,避免半透明叠两层 */
html.${READER_CLASS} #${READER_VIEW_ID}>.main-panel{padding:0 2px}
html.${READER_CLASS} #${READER_BAR_ID}{
    flex:0 0 7px; align-self:stretch; position:relative; cursor:col-resize;
}
html.${READER_CLASS} #${READER_BAR_ID}::before{
    content:""; position:absolute; top:0; bottom:0; left:3px; width:1px;
    background:var(--line,rgba(128,128,128,.25));
}
html.${READER_CLASS} #${READER_BAR_ID}:hover::before{
    left:2px; width:3px; border-radius:2px; background:var(--brand,#516185);
}
html.${READER_CLASS} .lsb-rd-hint{
    display:flex; align-items:center; justify-content:center;
    height:100%; color:var(--text-subtle,#888); font-size:14px;
}
/* 窄栏里的列表:整条可点,用底色+左侧色条标出当前打开的帖子 */
html.${READER_CLASS} .forum-main .post-item{
    padding:10px 8px; border-radius:6px; cursor:pointer;
}
html.${READER_CLASS} .forum-main .post-item:hover{background:${hover}!important}
html.${READER_CLASS} .forum-main .post-item.lsb-rd-active{
    background:${active}!important; box-shadow:inset 2px 0 0 var(--brand,#516185);
}
/* 版块徽章在窄栏里挤掉标题,改用 meta 行里那枚(站点默认把它隐藏了) */
html.${READER_CLASS} .forum-main .post-tag{display:none}
html.${READER_CLASS} .forum-main .post-meta .post-forum-meta{display:inline-flex}
html.${READER_CLASS} .mobile-forum-strip{display:none}
${config.readerHideSidebar
                ? `html.${READER_CLASS} .sidebar{display:none}`
                /* grid 改 flex 后侧栏丢了 230px 的列宽;min-height:0 让内部内容在侧栏内滚动 */
                : `html.${READER_CLASS} .sidebar{
    flex:0 0 230px; width:230px; min-width:0; min-height:0;
    height:var(--lsb-rd-h); max-height:var(--lsb-rd-h);
    margin-left:12px; align-self:stretch;
    display:block!important; align-content:initial;
    overflow-x:hidden; overflow-y:auto!important;
    overscroll-behavior:contain;
}
/* 站点侧栏默认使用 Grid,固定高度后会把卡片轨道压扁并由卡片自身隐藏溢出;
   改为块流后每张卡片按完整内容排列,滚动由侧栏统一承接。 */
html.${READER_CLASS} .sidebar>.sidebar-card{
    height:auto; min-height:0; margin-bottom:12px;
}
html.${READER_CLASS} .sidebar>.sidebar-card:last-child{margin-bottom:0}
`}
/* 弹框阅读沿用帖子页原有内容和回复组件,仅把阅读区域放进独立弹框。 */
html.${READER_MODAL_CLASS} #${READER_MODAL_ID}{
    position:fixed; inset:0; z-index:2147483550; display:flex;
    align-items:center; justify-content:center; padding:18px;
    background:rgba(0,0,0,.52);
}
html.${READER_MODAL_CLASS} #${READER_MODAL_ID}[hidden]{display:none!important}
html.${READER_MODAL_CLASS} #${READER_MODAL_ID} .lsb-reader-modal-dialog{
    display:flex; flex-direction:column; width:min(${modalWidth}vw,calc(100vw - 36px));
    height:min(88vh,calc(100vh - 36px)); min-height:280px;
    overflow:hidden; border:1px solid var(--line,rgba(128,128,128,.25));
    border-radius:8px; background:${modalSurface}!important; color:var(--text,#333);
    box-shadow:0 18px 60px rgba(0,0,0,.34);
    --panel:${modalSurface}!important;
    backdrop-filter:none!important; -webkit-backdrop-filter:none!important;
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-head{
    display:flex; align-items:center; gap:10px; flex:0 0 46px; min-width:0;
    padding:0 12px 0 16px; border-bottom:1px solid var(--line,rgba(128,128,128,.25));
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-title{
    min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
    font-size:15px; font-weight:600;
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-close{
    display:inline-flex; align-items:center; justify-content:center; flex:0 0 30px;
    width:30px; height:30px; margin-left:auto; padding:0; border:0; border-radius:5px;
    background:transparent; color:var(--text-subtle,#888); cursor:pointer;
    font-size:22px; line-height:1;
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-close:hover{background:rgba(128,128,128,.12);color:var(--text,#333)}
html.${READER_MODAL_CLASS} .lsb-reader-modal-body{
    min-height:0; flex:1 1 auto; overflow:auto; overscroll-behavior:contain;
    padding:0 12px 18px; background:${modalSurface}!important;
    scrollbar-width:none; -ms-overflow-style:none;
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-body::-webkit-scrollbar{display:none;width:0;height:0}
/* 弹框内帖子面板使用固定主题底色,不再继承全局面板透明度和毛玻璃效果。 */
html.${READER_MODAL_CLASS} .lsb-reader-modal-body>.main-panel{
    padding:0 2px; background:${modalSurface}!important;
    backdrop-filter:none!important; -webkit-backdrop-filter:none!important;
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-loading,
html.${READER_MODAL_CLASS} .lsb-reader-modal-error{
    display:flex; align-items:center; justify-content:center; height:100%;
    color:var(--text-subtle,#888); font-size:14px;
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-error{color:#b42318}
`;
    }

    function applyReaderStyle() {
        const host = mountPoint();
        if (!host) return;
        let st = document.getElementById(READER_CSS_ID);
        if (!st || !st.isConnected) {
            st = document.createElement('style');
            st.id = READER_CSS_ID;
            host.appendChild(st);
        }
        st.textContent = buildReaderCss();
    }

    /**
     * 两栏高度要精确到「刚好占满视口剩余空间」,否则外层页面会多出一截滚动,
     * 双栏跟着一起晃。顶栏高度、各层 padding、页脚高度随版本而变,写死数值不靠谱,
     * 也不能只看「有没有溢出」——页脚会把空隙填掉,那样会在两个值之间来回摆。
     * 这里直接量出「两栏之外的固定占位」,再用视口高度减掉它,一次就到位。
     */
    function measureReaderHeight() {
        const layout = readerLayout();
        const root = document.documentElement;
        if (!layout || !readerOn()) {
            root.style.removeProperty('--lsb-rd-h');
            return;
        }
        for (let i = 0; i < 3; i++) {
            const main = readerMain();
            const h = main ? main.getBoundingClientRect().height : 0;
            // 整篇文档高度 − 两栏当前高度 = 顶栏 + 页脚 + 各层 padding 的总占位
            const docH = document.body.getBoundingClientRect().bottom + window.scrollY;
            // 留 1px 余量:各层高度常带小数,贴到分毫不差反而会多出 1px 滚动条
            const target = Math.max(240, Math.floor(window.innerHeight - (docH - h)) - 1);
            if (Math.abs(target - h) < 1) break;
            root.style.setProperty('--lsb-rd-h', target + 'px');
        }
    }

    function readerHint(msg) {
        const view = readerViewEl();
        if (!view) return;
        view.textContent = '';
        const d = document.createElement('div');
        d.className = 'lsb-rd-hint';
        d.textContent = msg || '从左侧选择一个帖子';
        view.appendChild(d);
    }

    function ensureReaderModal() {
        if (readerModalEl && readerModalEl.isConnected) return readerModalEl;
        const modal = document.createElement('div');
        modal.id = READER_MODAL_ID;
        modal.hidden = true;
        modal.setAttribute('role', 'dialog');
        modal.setAttribute('aria-modal', 'true');
        modal.setAttribute('tabindex', '-1');
        modal.innerHTML = `
<div class="lsb-reader-modal-dialog">
    <div class="lsb-reader-modal-head">
        <div class="lsb-reader-modal-title">帖子阅读</div>
        <button type="button" class="lsb-reader-modal-close" aria-label="关闭弹框">×</button>
    </div>
    <div class="lsb-reader-modal-body"></div>
</div>`;
        modal.addEventListener('click', event => {
            if (event.target === modal) closeReaderModal(true);
        });
        modal.querySelector('.lsb-reader-modal-close')?.addEventListener('click', () => closeReaderModal(true));
        document.body.appendChild(modal);
        readerModalEl = modal;
        readerModalBodyEl = modal.querySelector('.lsb-reader-modal-body');
        readerModalTitleEl = modal.querySelector('.lsb-reader-modal-title');
        return modal;
    }

    function closeReaderModal(restoreHistory) {
        const wasOpen = !!(readerModalEl && !readerModalEl.hidden);
        if (restoreHistory && readerModalHistoryPushed && readerModalBaseUrl) {
            // 从列表点进弹框时只保留一个历史入口,关闭后恢复列表地址,避免留下无意义的帖子历史记录。
            history.replaceState(readerModalBaseState, '', readerModalBaseUrl);
        }
        readerModalHistoryPushed = false;
        readerModalBaseUrl = '';
        readerModalBaseState = null;
        if (readerModalEl) readerModalEl.hidden = true;
        if (readerModalBodyEl) readerModalBodyEl.textContent = '';
        if (readerModalTitleEl) readerModalTitleEl.textContent = '帖子阅读';
        if (wasOpen && document.body) document.body.style.overflow = readerModalBodyOverflow;
        readerModalBodyOverflow = '';
        if (readerModalDocumentTitle) {
            document.title = readerModalDocumentTitle;
            readerModalDocumentTitle = '';
        }
    }

    let readerModalLoadToken = 0;
    let readerModalDocumentTitle = '';

    async function openReaderModal(url, push) {
        const modal = ensureReaderModal();
        const target = urlOf(url);
        if (!target || !readerModalBodyEl) return;
        const firstOpen = modal.hidden;
        if (firstOpen) {
            readerModalDocumentTitle = document.title;
            readerModalBodyOverflow = document.body.style.overflow;
            document.body.style.overflow = 'hidden';
        }
        modal.hidden = false;
        if (push && !readerModalHistoryPushed) {
            readerModalBaseUrl = location.href;
            readerModalBaseState = history.state;
            history.pushState({ lsbReaderModal: 1 }, '', target.href);
            readerModalHistoryPushed = true;
        } else if (push && readerModalHistoryPushed) {
            // 弹框内切换帖子只替换当前阅读地址,关闭弹框时始终回到进入弹框前的列表地址。
            history.replaceState({ lsbReaderModal: 1 }, '', target.href);
        } else if (readerModalHistoryPushed) {
            history.replaceState({ lsbReaderModal: 1 }, '', target.href);
        }
        readerModalTitleEl.textContent = '加载中…';
        readerModalBodyEl.innerHTML = '<div class="lsb-reader-modal-loading">加载中…</div>';
        readerModalEl.focus();
        const token = ++readerModalLoadToken;
        try {
            const doc = await fetchDoc(target.href);
            if (token !== readerModalLoadToken || modal.hidden) return;
            const panel = pickPanel(doc);
            readerModalBodyEl.textContent = '';
            readerModalBodyEl.appendChild(document.importNode(panel, true));
            enhanceReplyFeatures(readerModalBodyEl);
            readerModalBodyEl.scrollTop = 0;
            const title = doc.querySelector('title')?.textContent?.trim();
            readerModalTitleEl.textContent = title || '帖子阅读';
            if (title) document.title = title;
        } catch (e) {
            if (token !== readerModalLoadToken || modal.hidden) return;
            readerModalTitleEl.textContent = '帖子阅读';
            readerModalBodyEl.textContent = '';
            const error = document.createElement('div');
            error.className = 'lsb-reader-modal-error';
            error.textContent = '加载失败:' + String(e.message || e);
            readerModalBodyEl.appendChild(error);
            toast('帖子加载失败:' + e.message, true);
        }
    }

    function applyReaderModal() {
        const on = !!config.readerModal && readerUsable();
        document.documentElement.classList.toggle(READER_MODAL_CLASS, on);
        if (!on) {
            closeReaderModal(true);
            return;
        }
        if (isTopicPage() && (!readerModalEl || readerModalEl.hidden)) {
            openReaderModal(location.pathname + location.search, false);
        }
    }

    function replyTextarea() {
        return document.querySelector('#' + READER_MODAL_ID + ' #reply textarea[name="body"]')
            || document.querySelector('#' + READER_VIEW_ID + ' #reply textarea[name="body"]')
            || document.querySelector('#reply textarea[name="body"]');
    }

    function insertReplyText(textarea, text) {
        if (!textarea || !text) return;
        const start = Number.isInteger(textarea.selectionStart) ? textarea.selectionStart : textarea.value.length;
        const end = Number.isInteger(textarea.selectionEnd) ? textarea.selectionEnd : start;
        textarea.focus();
        textarea.setRangeText(text, start, end, 'end');
        textarea.dispatchEvent(new Event('input', { bubbles: true }));
    }

    // imgur.la API 使用 multipart/form-data 上传二进制 source,并通过 X-API-Key 认证。
    function uploadImgurImage(file) {
        return new Promise((resolve, reject) => {
            const apiKey = String(config.imgurApiKey || '').trim();
            if (!apiKey) {
                reject(new Error('请先在插件设置中填写 imgur.la API Key'));
                return;
            }
            const form = new FormData();
            form.append('source', file, file.name);
            form.append('format', 'json');
            GM_xmlhttpRequest({
                method: 'POST',
                url: IMGUR_API_URL,
                headers: { 'X-API-Key': apiKey },
                data: form,
                timeout: 60000,
                onload: response => {
                    let data;
                    try {
                        data = JSON.parse(response.responseText || '{}');
                    } catch (e) {
                        reject(new Error('图床返回了无法解析的结果'));
                        return;
                    }
                    const url = data.image?.url || data.url;
                    if (response.status >= 200 && response.status < 300 && url) {
                        resolve(url);
                        return;
                    }
                    const message = data.error?.message || data.status_txt || data.success?.message;
                    reject(new Error(message || ('上传失败(HTTP ' + response.status + ')')));
                },
                ontimeout: () => reject(new Error('上传超时,请稍后重试')),
                onerror: () => reject(new Error('无法连接 imgur.la')),
            });
        });
    }

    // 多图按选择顺序逐张上传;每张成功后立即插入 Markdown,避免后续失败导致已上传链接丢失。
    async function uploadReplyImages(files, button, targetTextarea) {
        const list = Array.from(files || []).filter(file => /^image\//.test(file.type));
        const textarea = targetTextarea || replyTextarea();
        if (!list.length || !textarea) return;
        if (button) {
            button.disabled = true;
            button.dataset.uploading = '1';
        }
        let successCount = 0;
        try {
            for (const file of list) {
                try {
                    const url = await uploadImgurImage(file);
                    insertReplyText(textarea, `![${file.name.replace(/[\[\]]/g, '')}](${url})\n`);
                    successCount += 1;
                } catch (e) {
                    toast('「' + file.name + '」上传失败:' + e.message, true);
                }
            }
            if (successCount) toast('已上传 ' + successCount + ' 张图片并插入回复');
        } finally {
            if (button) {
                button.disabled = false;
                delete button.dataset.uploading;
            }
        }
    }

    function positionReplyEmojiPicker() {
        if (!replyEmojiPicker || !replyEmojiButton) return;
        const rect = replyEmojiButton.getBoundingClientRect();
        const width = replyEmojiPicker.offsetWidth || 280;
        const height = replyEmojiPicker.offsetHeight || 180;
        replyEmojiPicker.style.left = Math.max(8, Math.min(window.innerWidth - width - 8, rect.left)) + 'px';
        replyEmojiPicker.style.top = Math.max(8, rect.top - height - 8) + 'px';
    }

    function closeReplyEmojiPicker() {
        if (replyEmojiPicker) replyEmojiPicker.hidden = true;
        replyEmojiButton = null;
    }

    // 表情面板只创建一次,后续切换帖子时复用同一组表情按钮,避免动态右栏重复挂载。
    function createReplyEmojiPicker() {
        if (replyEmojiPicker) return replyEmojiPicker;
        const picker = document.createElement('div');
        picker.className = 'lsb-emoji-picker';
        picker.hidden = true;
        picker.setAttribute('aria-label', '选择表情');
        REPLY_EMOJIS.forEach(emoji => {
            const button = document.createElement('button');
            button.type = 'button';
            button.textContent = emoji;
            button.dataset.emoji = emoji;
            button.setAttribute('aria-label', emoji);
            picker.appendChild(button);
        });
        document.body.appendChild(picker);
        replyEmojiPicker = picker;
        return picker;
    }

    function enhanceReplyFeatures(root) {
        const scope = root instanceof Element ? root : document;
        const textarea = scope.querySelector?.('#reply textarea[name="body"]')
            || (scope.matches?.('#reply textarea[name="body"]') ? scope : null);
        const panel = textarea?.closest('#reply');
        const head = panel?.querySelector('.reply-panel-head');
        if (!textarea || !head || head.querySelector('.lsb-reply-head-tools')) return;

        // 回复辅助入口紧跟标题,正文编辑区只保留站点原有的 Markdown 工具栏和输入框。
        const tools = document.createElement('div');
        tools.className = 'lsb-reply-head-tools';
        const emojiButton = document.createElement('button');
        emojiButton.type = 'button';
        emojiButton.className = 'lsb-reply-emoji';
        emojiButton.textContent = '😀';
        emojiButton.dataset.replyEmoji = '1';
        emojiButton.title = '选择表情';
        emojiButton.setAttribute('aria-label', '选择表情');

        const uploadButton = document.createElement('button');
        uploadButton.type = 'button';
        uploadButton.className = 'lsb-reply-upload';
        uploadButton.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.75" y="4" width="13.5" height="12.5" rx="1.8"></rect><circle cx="6.7" cy="8" r="1.15"></circle><path d="m4.5 14 3.05-3.05a1.25 1.25 0 0 1 1.77 0l1.45 1.45 1.25-1.25a1.25 1.25 0 0 1 1.77 0l.46.46"></path><path d="M17.5 11.5v7"></path><path d="m14.8 14.2 2.7-2.7 2.7 2.7"></path><path d="M14.8 20h5.4"></path></svg>';
        uploadButton.dataset.replyUpload = '1';
        uploadButton.title = '上传图片到 imgur.la';
        uploadButton.setAttribute('aria-label', '上传图片到 imgur.la');
        const uploadInput = document.createElement('input');
        uploadInput.type = 'file';
        uploadInput.accept = 'image/*';
        uploadInput.multiple = true;
        uploadInput.hidden = true;
        uploadInput.dataset.replyUploadFile = '1';
        document.body.appendChild(uploadInput);
        uploadButton.addEventListener('click', event => {
            event.preventDefault();
            uploadInput.click();
        });
        uploadInput.addEventListener('change', () => {
            uploadReplyImages(uploadInput.files, uploadButton);
            uploadInput.value = '';
        });

        const presetSelect = document.createElement('select');
        presetSelect.className = 'lsb-reply-preset';
        presetSelect.dataset.replyPreset = '1';
        presetSelect.setAttribute('aria-label', '快捷回复');
        const placeholderOption = document.createElement('option');
        placeholderOption.value = '';
        placeholderOption.textContent = '快捷回复';
        presetSelect.appendChild(placeholderOption);
        String(config.replyPresets || '').split(/\r?\n/).map(text => text.trim()).filter(Boolean).forEach(text => {
            const option = document.createElement('option');
            option.value = text;
            option.textContent = text;
            presetSelect.appendChild(option);
        });
        const manageOption = document.createElement('option');
        manageOption.value = '__manage__';
        manageOption.textContent = '管理预设…';
        presetSelect.appendChild(manageOption);
        // 首项仅作为快捷回复标题,保持空值,避免初始化时误填入第一条真实预设。
        presetSelect.selectedIndex = 0;
        tools.append(emojiButton, uploadButton, presetSelect);
        head.querySelector('h3')?.after(tools);
        presetSelect.closest('html')?.classList.toggle('lsb-reply-dark', isDarkNow());
    }

    function bindReplyFeatures() {
        if (replyFeaturesBound) return;
        replyFeaturesBound = true;
        // 事件统一委托到 document,保证纯净模式 fetch 导入的回复区也能立即使用预设和表情。
        document.addEventListener('click', event => {
            const target = event.target instanceof Element ? event.target : null;
            const emoji = target?.closest('[data-emoji]');
            if (emoji && replyEmojiPicker?.contains(emoji)) {
                event.preventDefault();
                insertReplyText(replyTextarea(), emoji.dataset.emoji || '');
                closeReplyEmojiPicker();
                return;
            }
            const emojiButton = target?.closest('[data-reply-emoji]');
            if (emojiButton) {
                event.preventDefault();
                const picker = createReplyEmojiPicker();
                if (picker.hidden || replyEmojiButton !== emojiButton) {
                    replyEmojiButton = emojiButton;
                    picker.hidden = false;
                    positionReplyEmojiPicker();
                } else {
                    closeReplyEmojiPicker();
                }
                return;
            }
            if (replyEmojiPicker && !replyEmojiPicker.contains(target) && !target?.closest('[data-reply-emoji]')) {
                closeReplyEmojiPicker();
            }
        }, true);
        document.addEventListener('change', event => {
            const select = event.target instanceof Element ? event.target.closest('[data-reply-preset]') : null;
            if (!select || !select.value) return;
            if (select.value === '__manage__') {
                select.value = '';
                togglePanel(true);
                const presets = panelEl?.querySelector('[data-role=reply-presets]');
                presets?.focus();
                return;
            }
            const textarea = replyTextarea();
            if (!textarea) return;
            // 预设话术代表完整回复,选择后替换当前草稿,避免误把多条模板拼接到一起。
            textarea.value = select.value;
            textarea.dispatchEvent(new Event('input', { bubbles: true }));
            textarea.focus();
            textarea.setSelectionRange(textarea.value.length, textarea.value.length);
        });
        document.addEventListener('paste', event => {
            const textarea = event.target instanceof Element
                ? event.target.closest('#reply textarea[name="body"]')
                : null;
            if (!textarea || !event.clipboardData) return;
            const itemFiles = Array.from(event.clipboardData.items || [])
                .filter(item => item.kind === 'file' && /^image\//.test(item.type))
                .map(item => item.getAsFile())
                .filter(Boolean);
            const imageFiles = itemFiles.length
                ? itemFiles
                : Array.from(event.clipboardData.files || []).filter(file => /^image\//.test(file.type));
            if (!imageFiles.length) return;

            // 剪贴板含图片时由图床上传接管粘贴,避免浏览器同时写入图片对应的本地路径或 HTML 文本。
            event.preventDefault();
            toast('正在上传剪贴板图片…');
            uploadReplyImages(imageFiles, null, textarea);
        });
        window.addEventListener('resize', positionReplyEmojiPicker);
        window.addEventListener('scroll', positionReplyEmojiPicker, true);
        if (window.MutationObserver && document.body) {
            new MutationObserver(mutations => mutations.forEach(mutation => {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === 1) enhanceReplyFeatures(node);
                });
            })).observe(document.body, { childList: true, subtree: true });
        }
    }

    /** 建左右两栏的骨架:分隔条与右栏紧跟在 .forum-main 之后,站点侧栏留在最右 */
    function ensureReaderShell() {
        const layout = readerLayout();
        const main = readerMain();
        if (!layout || !main) return null;
        let bar = document.getElementById(READER_BAR_ID);
        if (!bar || !bar.isConnected) {
            bar = document.createElement('div');
            bar.id = READER_BAR_ID;
            bar.title = '拖动调整左栏宽度';
            bindSplitter(bar);
        }
        let view = readerViewEl();
        if (!view || !view.isConnected) {
            view = document.createElement('div');
            view.id = READER_VIEW_ID;
        }
        if (main.nextElementSibling !== bar) main.after(bar);
        if (bar.nextElementSibling !== view) bar.after(view);
        return view;
    }

    async function fetchDoc(url) {
        // 不带 X-Requested-With:站点对 AJAX 请求会返回 JSON 片段,这里要的是整页 HTML
        const res = await fetch(urlOf(url).href, { credentials: 'same-origin' });
        if (!res.ok) throw new Error('HTTP ' + res.status);
        return new DOMParser().parseFromString(await res.text(), 'text/html');
    }

    function pickPanel(doc) {
        const p = doc.querySelector('.forum-main .main-panel') || doc.querySelector('.main-panel');
        if (!p) throw new Error('页面结构与预期不符');
        return p;
    }

    /** 高亮左栏中对应的条目 */
    function markActive(pathname) {
        const main = readerMain();
        if (!main) return;
        main.querySelectorAll('.post-item.lsb-rd-active').forEach(n => n.classList.remove('lsb-rd-active'));
        if (!pathname) return;
        main.querySelectorAll('.post-item a.post-title').forEach(a => {
            const u = urlOf(a.getAttribute('href'));
            if (u && u.pathname === pathname) a.closest('.post-item').classList.add('lsb-rd-active');
        });
    }

    async function openTopic(url, push) {
        const view = ensureReaderShell();
        if (!view) return;
        readerHint('加载中…');
        try {
            const doc = await fetchDoc(url);
            const panel = pickPanel(doc);
            view.textContent = '';
            view.appendChild(document.importNode(panel, true));
            enhanceReplyFeatures(view);
            view.scrollTop = 0;
            const t = doc.querySelector('title');
            if (t) document.title = t.textContent;
            if (push !== false) history.pushState({ lsbReader: 1 }, '', urlOf(url).href);
            markActive(urlOf(url).pathname);
        } catch (e) {
            readerHint('加载失败:' + e.message);
            toast('帖子加载失败:' + e.message, true);
        }
    }

    async function loadList(url, push) {
        const main = readerMain();
        if (!main || listLoading) return;
        listLoading = true;
        try {
            const doc = await fetchDoc(url);
            const panel = pickPanel(doc);
            main.textContent = '';
            main.appendChild(document.importNode(panel, true));
            main.scrollTop = 0;
            listUrl = urlOf(url).href;
            readerDirty = true;   // 左栏内容已不是服务端这次渲染的那份
            if (push) history.pushState({ lsbReader: 1 }, '', listUrl);
            markActive(isTopicPage() ? urlOf().pathname : null);
            measureReaderHeight();
        } catch (e) {
            toast('列表加载失败:' + e.message, true);
        } finally {
            listLoading = false;
        }
    }

    /** 帖子页直接打开时:把正文搬到右栏,左栏补上所属版块的列表 */
    function listUrlForTopic() {
        const a = document.querySelector('#' + READER_VIEW_ID + ' .breadcrumb a[href^="/forum/"]');
        return a ? a.getAttribute('href') : '/';
    }

    function applyReader() {
        const on = !!config.reader && !config.readerModal && readerUsable();
        document.documentElement.classList.toggle(READER_CLASS, on);
        applyReaderStyle();
        if (!on) {
            teardownReader();
            return;
        }
        const view = ensureReaderShell();
        if (!view) return;
        if (isTopicPage() && !topicMoved) {
            const panel = document.querySelector('.forum-main .main-panel');
            if (panel) {
                view.textContent = '';
                view.appendChild(panel);
                enhanceReplyFeatures(view);
                topicMoved = true;
                readerDirty = true;
                loadList(listUrlForTopic());
            }
        } else if (!view.firstChild) {
            readerHint();
            listUrl = location.href;
        }
        markActive(isTopicPage() ? urlOf().pathname : null);
        measureReaderHeight();
    }

    function teardownReader() {
        const view = readerViewEl();
        const bar = document.getElementById(READER_BAR_ID);
        if (view) view.remove();
        if (bar) bar.remove();
        document.documentElement.style.removeProperty('--lsb-rd-h');
        document.querySelectorAll('.post-item.lsb-rd-active')
            .forEach(n => n.classList.remove('lsb-rd-active'));
        // 搬过 DOM 或换过左栏内容,页面已经不是服务端渲染的那份,只能刷新还原
        if (readerDirty) {
            readerDirty = false;
            topicMoved = false;
            location.reload();
        }
    }

    function bindSplitter(bar) {
        bar.addEventListener('mousedown', e => {
            if (e.button !== 0) return;
            e.preventDefault();
            const main = readerMain();
            if (!main) return;
            const startX = e.clientX;
            const startW = main.getBoundingClientRect().width;
            const move = ev => {
                config.readerWidth = Math.round(
                    Math.max(READER_MIN_W, Math.min(READER_MAX_W, startW + ev.clientX - startX)));
                applyReaderStyle();
            };
            const up = () => {
                document.removeEventListener('mousemove', move);
                document.removeEventListener('mouseup', up);
                document.body.style.userSelect = '';
                saveConfig(config);
                measureReaderHeight();
                if (panelEl && panelEl.classList.contains('lsb-open')) syncPanel();
            };
            document.body.style.userSelect = 'none';
            document.addEventListener('mousemove', move);
            document.addEventListener('mouseup', up);
        });
    }

    function bindReaderNav() {
        // 用捕获阶段:要在站点自己的委托处理之前拦下导航
        document.addEventListener('click', e => {
            const modalOn = readerModalOn();
            if ((!readerOn() && !modalOn) || e.defaultPrevented) return;
            // 新标签页 / 中键等原生行为一律放过
            if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
            const a = e.target.closest && e.target.closest('a[href]');
            if (!a || (a.target && a.target !== '_self')) return;
            const main = readerMain();
            const view = readerViewEl();
            const modal = readerModalEl;
            const inModal = modalOn && modal && !modal.hidden && modal.contains(a);
            const inList = !inModal && main && main.contains(a);
            const inView = !inModal && view && view.contains(a);
            if (!inList && !inView && !inModal) return;
            const u = urlOf(a.getAttribute('href'));
            if (!u || u.origin !== location.origin) return;

            // 引用回复由站点脚本通过 .quote-reply 事件填充回复框;纯净模式不能把它当作楼层导航拦截。
            if ((inView || inModal) && a.matches('.quote-reply')) return;

            if (isTopicPage(u.href)) {
                // 右栏里指向本帖的楼层永久链接:原地滚到该楼层,不必重新请求
                if ((inView || inModal) && u.pathname === urlOf().pathname && /(^|&)floor=/.test(u.search.slice(1))) {
                    e.preventDefault();
                    const entry = a.closest('.post-entry');
                    if (entry) entry.scrollIntoView({ block: 'start' });
                    return;
                }
                e.preventDefault();
                if (modalOn) openReaderModal(u.pathname + u.search, true);
                else openTopic(u.pathname + u.search);
                return;
            }
            if (readerOn() && inList && isListPage(u.href)) {
                e.preventDefault();
                // 右栏正开着帖子时不动地址栏(那里应保留帖子链接,方便刷新/分享);
                // 右栏是空态时换列表则同步地址,刷新后还能回到同一个列表。
                loadList(u.pathname + u.search, !isTopicPage());
            }
        }, true);

        window.addEventListener('popstate', () => {
            if (readerModalOn()) {
                if (isTopicPage()) openReaderModal(location.pathname + location.search, false);
                else closeReaderModal(false);
                return;
            }
            if (!readerOn()) return;
            if (isTopicPage()) {
                openTopic(location.pathname + location.search, false);
            } else {
                readerHint();
                markActive(null);
                if (isListPage() && listUrl && location.href !== listUrl) loadList(location.href);
            }
        });

        // 视口变化后两栏高度要重新贴合,否则会多出或缺一截
        let rzTimer = null;
        window.addEventListener('resize', () => {
            if (!readerOn()) return;
            clearTimeout(rzTimer);
            rzTimer = setTimeout(measureReaderHeight, 120);
        });
        window.addEventListener('keydown', e => {
            if (e.key === 'Escape' && readerModalOn() && readerModalEl && !readerModalEl.hidden) {
                e.preventDefault();
                closeReaderModal(true);
            }
        });
    }

    // ── 轮播 ──────────────────────────────────────────────────────────────
    function stopCarousel() {
        if (carouselTimer) {
            clearInterval(carouselTimer);
            carouselTimer = null;
        }
    }

    function nextIndex() {
        if (images.length < 2) return config.activeIndex;
        if (config.shuffle) {
            let i = config.activeIndex;
            while (i === config.activeIndex) i = Math.floor(Math.random() * images.length);
            return i;
        }
        return (config.activeIndex + 1) % images.length;
    }

    function startCarousel() {
        stopCarousel();
        if (!config.enabled || !config.carousel || images.length < 2) return;
        const sec = Math.max(5, Number(config.interval) || 60);
        carouselTimer = setInterval(() => {
            config.activeIndex = nextIndex();
            saveConfig(config);
            applyStyle();
            syncPanel();
        }, sec * 1000);
    }

    function refresh() {
        applyStyle();
        applyReaderStyle();
        syncPanelTheme();
        startCarousel();
    }

    // ── 图片处理 ──────────────────────────────────────────────────────────
    function nextId() {
        return 'img_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
    }

    /** 本地文件 → 等比压缩后的 data URL,避免超大 base64 塞满油猴存储 */
    function fileToDataUrl(file) {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onerror = () => reject(new Error('读取文件失败'));
            reader.onload = () => {
                const raw = String(reader.result);
                const im = new Image();
                im.onerror = () => reject(new Error('图片解码失败'));
                im.onload = () => {
                    const scale = Math.min(1, MAX_EDGE / Math.max(im.width, im.height));
                    if (scale >= 1 && raw.length < 1.5e6) {
                        resolve(raw); // 尺寸和体积都不大,保留原图(含 PNG 透明通道)
                        return;
                    }
                    const cv = document.createElement('canvas');
                    cv.width = Math.round(im.width * scale);
                    cv.height = Math.round(im.height * scale);
                    const ctx = cv.getContext('2d');
                    // 压成 JPEG 会丢透明通道,先铺白底避免变黑块
                    ctx.fillStyle = '#fff';
                    ctx.fillRect(0, 0, cv.width, cv.height);
                    ctx.drawImage(im, 0, 0, cv.width, cv.height);
                    resolve(cv.toDataURL('image/jpeg', JPEG_QUALITY));
                };
                im.src = raw;
            };
            reader.readAsDataURL(file);
        });
    }

    async function addLocalFiles(fileList) {
        const files = Array.from(fileList || []).filter(f => /^image\//.test(f.type));
        if (!files.length) return 0;
        for (const f of files) {
            try {
                const src = await fileToDataUrl(f);
                images.push({ id: nextId(), name: f.name, type: 'local', src });
            } catch (e) {
                toast('「' + f.name + '」添加失败:' + e.message, true);
            }
        }
        saveImages(images);
        return files.length;
    }

    function addUrl(url) {
        const u = String(url || '').trim();
        if (!/^https?:\/\//i.test(u)) {
            toast('请填写 http/https 开头的图片直链', true);
            return false;
        }
        images.push({ id: nextId(), name: u.split('/').pop().slice(0, 40) || u, type: 'url', src: u });
        saveImages(images);
        return true;
    }

    function removeImage(id) {
        const i = images.findIndex(x => x.id === id);
        if (i < 0) return;
        images.splice(i, 1);
        if (config.activeIndex >= images.length) config.activeIndex = Math.max(0, images.length - 1);
        else if (config.activeIndex > i) config.activeIndex -= 1;
        saveImages(images);
        saveConfig(config);
    }

    // ── 设置面板样式 ──────────────────────────────────────────────────────
    // 面板样式全部用 #lsb-bg-settings 前缀 + 自有变量,防止被站点 CSS 或本脚本
    // 注入的半透明规则影响(设置面板必须始终清晰可读)。
    // 明暗两套色值挂在 .lsb-dark 上切换,不读站点变量——站点变量随时可能被
    // themes 插件改写,面板必须自成一体。
    const PANEL_CSS = `
/* 直接隐藏站点底部运行信息栏,避免页面底部占用额外高度。 */
footer.footer{display:none!important}
 #${PANEL_ID},#lsb-bg-toggle,#${NAV_TOGGLE_ID}{
    --lsb-surface:#fff;
    --lsb-surface-2:#fafafa;
    --lsb-surface-3:#f0f0f0;
    --lsb-fg:#222;
    --lsb-fg-muted:#666;
    --lsb-fg-subtle:#999;
    --lsb-fg-faint:#aaa;
    --lsb-border:#e2e2e2;
    --lsb-border-soft:#eee;
    --lsb-border-input:#ddd;
    --lsb-accent:#2ecc71;
    --lsb-accent-hover:#27ae60;
    --lsb-accent-soft:#f3fdf7;
    --lsb-thumb-bg:#f2f2f2;
}
 #${PANEL_ID}.lsb-dark,#lsb-bg-toggle.lsb-dark,#${NAV_TOGGLE_ID}.lsb-dark{
    --lsb-surface:#22242a;
    --lsb-surface-2:#2a2d34;
    --lsb-surface-3:#34383f;
    --lsb-fg:#e8eaed;
    --lsb-fg-muted:#a8adb5;
    --lsb-fg-subtle:#868c95;
    --lsb-fg-faint:#6f757e;
    --lsb-border:#3a3e46;
    --lsb-border-soft:#31353c;
    --lsb-border-input:#43484f;
    --lsb-accent-soft:#1d3327;
    --lsb-thumb-bg:#2e323a;
}
#${PANEL_ID}{
    position:fixed; z-index:2147483600; top:58px; right:12px;
    width:min(340px,calc(100vw - 24px)); max-height:calc(100vh - 70px);
    display:none; flex-direction:column; overflow:hidden;
    background:var(--lsb-surface)!important; color:var(--lsb-fg);
    border:1px solid var(--lsb-border); border-radius:10px;
    box-shadow:0 18px 48px rgba(0,0,0,.28);
    font:13px/1.5 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei","Segoe UI",Arial,sans-serif;
    backdrop-filter:none!important; -webkit-backdrop-filter:none!important;
    /* 让面板内的原生控件(滑块轨道、下拉箭头、勾选框)跟着明暗走 */
    color-scheme:light;
}
#${PANEL_ID}.lsb-dark{color-scheme:dark}
#${PANEL_ID}.lsb-open{display:flex}
#${PANEL_ID} *{box-sizing:border-box}
#${PANEL_ID} .lsb-head{
    display:flex; align-items:center; gap:8px; padding:10px 12px;
    border-bottom:1px solid var(--lsb-border-soft); font-weight:600;
}
#${PANEL_ID} .lsb-head .lsb-back{
    display:none; width:24px; height:24px; padding:0; border:0;
    background:transparent; color:var(--lsb-fg-muted); cursor:pointer;
    font-size:18px; line-height:1;
}
#${PANEL_ID} .lsb-head .lsb-back.lsb-visible{display:inline-flex; align-items:center; justify-content:center}
#${PANEL_ID} .lsb-head .lsb-spacer{flex:1}
#${PANEL_ID} .lsb-x{
    border:none; background:transparent; cursor:pointer; font-size:18px;
    line-height:1; color:var(--lsb-fg-subtle); padding:2px 4px;
}
#${PANEL_ID} .lsb-body{padding:12px; overflow:auto; display:grid; gap:12px}
#${PANEL_ID} .lsb-view{display:grid; gap:12px}
#${PANEL_ID} .lsb-view[hidden]{display:none}
#${PANEL_ID} .lsb-menu-item{
    display:flex; align-items:center; width:100%; min-height:42px; padding:0 12px;
    border:1px solid var(--lsb-border); border-radius:6px;
    background:var(--lsb-surface-2); color:var(--lsb-fg); cursor:pointer;
    text-align:left; font:inherit; transition:background .15s ease, border-color .15s ease;
}
#${PANEL_ID} .lsb-menu-item:hover{background:var(--lsb-surface-3); border-color:var(--lsb-accent)}
#${PANEL_ID} .lsb-menu-item .lsb-menu-desc{margin-left:auto; color:var(--lsb-fg-subtle); font-size:12px}
#${PANEL_ID} .lsb-menu-item .lsb-menu-arrow{margin-left:10px; color:var(--lsb-fg-subtle); font-size:18px; line-height:1}
#${PANEL_ID} .lsb-row{display:flex; align-items:center; gap:8px}
#${PANEL_ID} .lsb-row>label{flex:0 0 68px; color:var(--lsb-fg-muted)}
#${PANEL_ID} .lsb-row input[type=range]{flex:1; min-width:0; accent-color:var(--lsb-accent)}
#${PANEL_ID} .lsb-val{
    flex:0 0 42px; text-align:right; color:var(--lsb-fg-subtle);
    font-variant-numeric:tabular-nums;
}
#${PANEL_ID} input[type=text],#${PANEL_ID} input[type=number],#${PANEL_ID} select{
    flex:1; min-width:0; height:30px; padding:0 8px;
    border:1px solid var(--lsb-border-input)!important; border-radius:5px;
    background:var(--lsb-surface)!important; color:var(--lsb-fg)!important;
}
#${PANEL_ID} .lsb-presets{
    width:100%; min-height:82px; resize:vertical; padding:7px 8px;
    border:1px solid var(--lsb-border-input)!important; border-radius:5px;
    background:var(--lsb-surface)!important; color:var(--lsb-fg)!important;
    font:inherit; line-height:1.5;
}
#${PANEL_ID} input::placeholder{color:var(--lsb-fg-faint)}
#${PANEL_ID} .lsb-btn{
    height:30px; padding:0 12px; border:1px solid var(--lsb-border-input); border-radius:5px;
    background:var(--lsb-surface-2); color:var(--lsb-fg); cursor:pointer; white-space:nowrap;
}
#${PANEL_ID} .lsb-btn:hover{background:var(--lsb-surface-3)}
#${PANEL_ID} .lsb-btn.lsb-primary{
    background:var(--lsb-accent); border-color:var(--lsb-accent); color:#fff;
}
#${PANEL_ID} .lsb-btn.lsb-primary:hover{background:var(--lsb-accent-hover)}
#${PANEL_ID} .lsb-drop{
    border:1px dashed var(--lsb-border-input); border-radius:8px; padding:14px;
    text-align:center; color:var(--lsb-fg-subtle); cursor:pointer; transition:.15s ease;
}
#${PANEL_ID} .lsb-drop.lsb-over{
    border-color:var(--lsb-accent); background:var(--lsb-accent-soft); color:var(--lsb-accent);
}
#${PANEL_ID} .lsb-grid{display:grid; grid-template-columns:repeat(3,1fr); gap:6px}
#${PANEL_ID} .lsb-thumb{
    position:relative; padding-top:64%; border-radius:6px; overflow:hidden;
    border:2px solid transparent; cursor:pointer;
    background:var(--lsb-thumb-bg) center/cover no-repeat;
}
#${PANEL_ID} .lsb-thumb.lsb-active{border-color:var(--lsb-accent)}
#${PANEL_ID} .lsb-thumb .lsb-del{
    position:absolute; top:2px; right:2px; width:18px; height:18px; border:none;
    border-radius:50%; background:rgba(0,0,0,.55); color:#fff; font-size:12px;
    line-height:18px; padding:0; cursor:pointer; opacity:0; transition:.15s;
}
#${PANEL_ID} .lsb-thumb:hover .lsb-del{opacity:1}
#${PANEL_ID} .lsb-sec{
    border-top:1px solid var(--lsb-border-soft); padding-top:10px;
    color:var(--lsb-fg-subtle); font-size:12px;
}
#${PANEL_ID} .lsb-empty{color:var(--lsb-fg-faint); text-align:center; padding:8px 0}
#${PANEL_ID} .lsb-tip{color:var(--lsb-fg-faint); font-size:12px}
/* 面板内的勾选框不走站点自绘样式,用原生外观即可(color-scheme 已处理明暗) */
#${PANEL_ID} input[type=checkbox]{accent-color:var(--lsb-accent)}
#lsb-bg-toggle{
    position:fixed; z-index:2147483600; right:12px; bottom:58px;
    width:38px; height:38px; border-radius:50%;
    border:1px solid var(--lsb-border);
    background:#fff!important; color:var(--lsb-accent);
    cursor:pointer; font-size:17px; line-height:1;
    box-shadow:0 6px 18px rgba(0,0,0,.22);
    backdrop-filter:none!important; -webkit-backdrop-filter:none!important;
}
#lsb-bg-toggle:hover{color:var(--lsb-accent); background:#f5fff8!important}
#lsb-bg-toggle svg{width:28px; height:20px; display:block}
#${NAV_TOGGLE_ID}{
    grid-column:4; grid-row:1; justify-self:end;
    display:inline-flex; align-items:center; justify-content:center;
    width:30px; height:30px; padding:0; border:1px solid var(--lsb-border);
    border-radius:var(--radius-sm,6px); background:#fff!important;
    color:var(--lsb-accent); cursor:pointer; line-height:1;
    transition:color .15s ease, background .15s ease, border-color .15s ease;
}
#${NAV_TOGGLE_ID}:hover{color:var(--lsb-accent); border-color:var(--lsb-accent); background:#f5fff8!important}
#${NAV_TOGGLE_ID} svg{width:26px; height:18px; display:block}
#lsb-bg-toast{
    position:fixed; z-index:2147483601; left:50%; bottom:76px; transform:translateX(-50%);
    max-width:80vw; padding:8px 14px; border-radius:6px; font-size:13px;
    background:rgba(17,24,39,.92); color:#fff; opacity:0; transition:opacity .25s ease;
    pointer-events:none;
}
#lsb-bg-toast.lsb-show{opacity:1}
#lsb-bg-toast.lsb-err{background:rgba(185,28,28,.94)}
`;

    // ── 设置面板 DOM ──────────────────────────────────────────────────────
    let panelEl = null;
    let toggleEl = null;
    let navToggleEl = null;
    let toastEl = null;
    let toastTimer = null;
    let panelView = 'main';

    function toast(msg, isError) {
        if (!toastEl) {
            toastEl = document.createElement('div');
            toastEl.id = 'lsb-bg-toast';
            document.body.appendChild(toastEl);
        }
        toastEl.textContent = msg;
        toastEl.className = 'lsb-show' + (isError ? ' lsb-err' : '');
        clearTimeout(toastTimer);
        toastTimer = setTimeout(() => { toastEl.className = ''; }, 2400);
    }

    const PANEL_HTML = `
<div class="lsb-head">
    <button type="button" class="lsb-back" data-act="back" aria-label="返回主菜单">‹</button>
    <span data-role="title">LINUAX SB READ</span><span class="lsb-spacer"></span>
    <button type="button" class="lsb-x" data-act="close" aria-label="关闭">×</button>
</div>
<div class="lsb-body">
    <div class="lsb-view" data-view="main">
        <div class="lsb-sec">功能</div>
        <button type="button" class="lsb-menu-item" data-act="images">
            <span>图片设置</span><span class="lsb-menu-desc">图库、效果、轮播</span><span class="lsb-menu-arrow">›</span>
        </button>
        <div class="lsb-sec">阅读模式</div>
        <div class="lsb-row">
            <label for="lsb-reader">双栏阅读</label>
            <input type="checkbox" id="lsb-reader" data-cfg="reader">
            <span class="lsb-spacer"></span>
            <span class="lsb-tip">左列表 / 右正文</span>
        </div>
        <div class="lsb-row">
            <label for="lsb-reader-modal">弹框阅读</label>
            <input type="checkbox" id="lsb-reader-modal" data-cfg="readerModal">
            <span class="lsb-spacer"></span>
            <span class="lsb-tip">点击帖子弹框打开</span>
        </div>
        <div class="lsb-row">
            <label>弹框宽度</label>
            <input type="range" min="50" max="96" step="1" data-cfg="readerModalWidth">
            <span class="lsb-val" data-val="readerModalWidth"></span>
        </div>
        <div class="lsb-row">
            <label>左栏宽度</label>
            <input type="range" min="260" max="720" step="10" data-cfg="readerWidth">
            <span class="lsb-val" data-val="readerWidth"></span>
        </div>
        <div class="lsb-row">
            <label for="lsb-rd-side">隐藏侧栏</label>
            <input type="checkbox" id="lsb-rd-side" data-cfg="readerHideSidebar">
        </div>
        <div class="lsb-sec">快捷回复</div>
        <textarea class="lsb-presets" data-role="reply-presets" aria-label="快捷回复预设" placeholder="每行填写一条回复"></textarea>
        <span class="lsb-tip">每行一条,保存后显示在回复标题旁</span>
        <div class="lsb-row">
            <label for="lsb-imgur-key">图床 API Key</label>
            <input type="text" id="lsb-imgur-key" data-cfg="imgurApiKey" autocomplete="off" spellcheck="false" placeholder="imgur.la API Key">
        </div>
        <span class="lsb-tip">回复框中的图片按钮会上传到 imgur.la;可替换为自己的 Key</span>
        <div class="lsb-sec"></div>
        <div class="lsb-row">
            <button type="button" class="lsb-btn" data-act="reset">恢复默认</button>
        </div>
    </div>
    <div class="lsb-view" data-view="images" hidden>
        <div class="lsb-row">
            <label for="lsb-enabled">启用背景</label>
            <input type="checkbox" id="lsb-enabled" data-cfg="enabled">
            <span class="lsb-spacer"></span>
            <button type="button" class="lsb-btn" data-act="next">切换下一张</button>
        </div>
        <div class="lsb-drop" data-act="pick">
            点击选择本地图片,或把图片拖进来
            <input type="file" accept="image/*" multiple hidden data-role="file">
        </div>
        <div class="lsb-row">
            <input type="text" data-role="url" placeholder="粘贴图片直链 https://...">
            <button type="button" class="lsb-btn lsb-primary" data-act="add-url">添加</button>
        </div>
        <div class="lsb-grid" data-role="grid"></div>
        <div class="lsb-sec">显示效果</div>
        <div class="lsb-row">
            <label>配色</label>
            <select data-cfg="theme">
                <option value="auto">跟随站点明暗</option>
                <option value="light">强制浅色</option>
                <option value="dark">强制深色</option>
            </select>
        </div>
        <div class="lsb-row"><label>透明度</label><input type="range" min="0" max="1" step="0.02" data-cfg="panelAlpha"><span class="lsb-val" data-val="panelAlpha"></span></div>
        <div class="lsb-row"><label>模糊</label><input type="range" min="0" max="40" step="1" data-cfg="blur"><span class="lsb-val" data-val="blur"></span></div>
        <div class="lsb-row"><label>蒙版</label><input type="range" min="0" max="0.8" step="0.02" data-cfg="maskAlpha"><span class="lsb-val" data-val="maskAlpha"></span></div>
        <div class="lsb-row"><label>亮度</label><input type="range" min="0.3" max="1.6" step="0.02" data-cfg="brightness"><span class="lsb-val" data-val="brightness"></span></div>
        <div class="lsb-row">
            <label>填充</label>
            <select data-cfg="size"><option value="cover">铺满裁切</option><option value="contain">完整显示</option><option value="repeat">平铺</option></select>
        </div>
        <div class="lsb-row">
            <label>位置</label>
            <select data-cfg="position"><option value="center">居中</option><option value="top center">顶部</option><option value="bottom center">底部</option><option value="left center">左侧</option><option value="right center">右侧</option></select>
        </div>
        <div class="lsb-row"><label for="lsb-fixed">固定不滚动</label><input type="checkbox" id="lsb-fixed" data-cfg="fixed"></div>
        <div class="lsb-sec">轮播</div>
        <div class="lsb-row"><label for="lsb-carousel">自动轮播</label><input type="checkbox" id="lsb-carousel" data-cfg="carousel"><label for="lsb-shuffle" style="flex:0 0 auto">随机</label><input type="checkbox" id="lsb-shuffle" data-cfg="shuffle"></div>
        <div class="lsb-row"><label>间隔(秒)</label><input type="number" min="5" step="5" data-cfg="interval"></div>
        <div class="lsb-sec"></div>
        <div class="lsb-row"><button type="button" class="lsb-btn" data-act="clear">清空图库</button></div>
    </div>
</div>`;

    function renderGrid() {
        const grid = panelEl.querySelector('[data-role=grid]');
        grid.textContent = '';
        if (!images.length) {
            const p = document.createElement('div');
            p.className = 'lsb-empty';
            p.style.gridColumn = '1 / -1';
            p.textContent = '图库为空,先添加一张图片';
            grid.appendChild(p);
            return;
        }
        clampIndex();
        images.forEach((img, i) => {
            const cell = document.createElement('div');
            cell.className = 'lsb-thumb' + (i === config.activeIndex ? ' lsb-active' : '');
            cell.style.backgroundImage = cssUrl(img.src);
            cell.title = img.name || '';
            cell.dataset.id = img.id;
            const del = document.createElement('button');
            del.type = 'button';
            del.className = 'lsb-del';
            del.dataset.del = img.id;
            del.textContent = '×';
            del.title = '删除';
            cell.appendChild(del);
            grid.appendChild(cell);
        });
    }

    /** 数值型配置的显示文本:px 类带单位,比例类保留两位小数 */
    function formatVal(key, v) {
        const n = Number(v);
        if (key === 'blur') return n + 'px';
        if (key === 'readerWidth') return n + 'px';
        if (key === 'readerModalWidth') return n + '%';
        return n.toFixed(2);
    }

    /** 把 config 回填到控件上(轮播换图后也要同步高亮) */
    function syncPanel() {
        if (!panelEl) return;
        panelEl.querySelectorAll('[data-cfg]').forEach(el => {
            const key = el.dataset.cfg;
            if (el.type === 'checkbox') el.checked = !!config[key];
            else el.value = config[key];
        });
        panelEl.querySelectorAll('[data-val]').forEach(el => {
            const key = el.dataset.val;
            el.textContent = formatVal(key, config[key]);
        });
        const presets = panelEl.querySelector('[data-role=reply-presets]');
        if (presets && document.activeElement !== presets) presets.value = config.replyPresets || '';
        const readerMode = panelEl.querySelector('[data-cfg="reader"]');
        const readerModalMode = panelEl.querySelector('[data-cfg="readerModal"]');
        if (readerMode) readerMode.checked = !!config.reader;
        if (readerModalMode) readerModalMode.checked = !!config.readerModal;
        syncPanelTheme();
        renderGrid();
    }

    /** 设置面板自己也要跟着明暗切换,否则暗夜模式下白底面板很刺眼 */
    function syncPanelTheme() {
        const dark = isDarkNow();
        if (panelEl) panelEl.classList.toggle('lsb-dark', dark);
        if (toggleEl) toggleEl.classList.toggle('lsb-dark', dark);
        if (navToggleEl) navToggleEl.classList.toggle('lsb-dark', dark);
    }

    // 图片资源与背景效果属于高频率较低的高级设置,进入二级视图后再展示,主菜单只保留常用开关。
    function setPanelView(view) {
        if (!panelEl) return;
        panelView = view === 'images' ? 'images' : 'main';
        panelEl.querySelectorAll('[data-view]').forEach(el => {
            el.hidden = el.dataset.view !== panelView;
        });
        const back = panelEl.querySelector('[data-act=back]');
        const title = panelEl.querySelector('[data-role=title]');
        if (back) back.classList.toggle('lsb-visible', panelView === 'images');
        if (title) title.textContent = panelView === 'images' ? '图片设置' : 'LINUAX SB READ';
    }

    // 顶栏入口使用独立图标,点击后复用同一设置面板,避免用户必须寻找油猴菜单。
    function createNavToggle() {
        const button = document.createElement('button');
        button.id = NAV_TOGGLE_ID;
        button.type = 'button';
        button.title = '背景设置';
        button.setAttribute('aria-label', '打开背景设置');
        button.setAttribute('aria-controls', PANEL_ID);
        button.setAttribute('aria-expanded', 'false');
        button.innerHTML = PLUGIN_ICON_SVG;
        button.addEventListener('click', () => togglePanel());
        return button;
    }

    /** 顶栏把搜索框放在第 5 个网格列;图标固定占据其左侧空列,避免挤动版块导航。 */
    function injectNavToggle() {
        const search = document.querySelector('.top .bar .search-form, .bar .search-form');
        if (!search || !search.parentElement) return false;
        let button = document.getElementById(NAV_TOGGLE_ID);
        if (!button || !button.isConnected) {
            button = createNavToggle();
            search.parentElement.insertBefore(button, search);
        } else if (button.nextElementSibling !== search) {
            search.parentElement.insertBefore(button, search);
        }
        navToggleEl = button;
        button.setAttribute('aria-expanded', String(!!(panelEl && panelEl.classList.contains('lsb-open'))));
        syncPanelTheme();
        if (toggleEl) toggleEl.style.display = 'none';
        return true;
    }

    function buildPanel() {
        // 非 HTML 文档(直接打开 SVG/XML 等)没有 body,挂不了 UI,直接放弃
        if (!document.body) return;
        GM_addStyle(PANEL_CSS);

        toggleEl = document.createElement('button');
        toggleEl.id = 'lsb-bg-toggle';
        toggleEl.type = 'button';
        toggleEl.title = '背景设置';
        toggleEl.setAttribute('aria-label', '背景设置');
        toggleEl.setAttribute('aria-controls', PANEL_ID);
        toggleEl.setAttribute('aria-expanded', 'false');
        toggleEl.innerHTML = PLUGIN_ICON_SVG;
        document.body.appendChild(toggleEl);

        panelEl = document.createElement('div');
        panelEl.id = PANEL_ID;
        panelEl.innerHTML = PANEL_HTML;
        document.body.appendChild(panelEl);

        toggleEl.addEventListener('click', togglePanel);
        bindPanel();
        syncPanel();
        // 搜索框可能由站点脚本延迟生成;找到顶部结构就隐藏右下角兜底入口。
        toggleEl.style.display = injectNavToggle() ? 'none' : '';

        // 顶栏由站点脚本异步重绘时重新挂载入口,避免切换页面后图标消失。
        const headerObserver = new MutationObserver(() => {
            toggleEl.style.display = injectNavToggle() ? 'none' : '';
        });
        headerObserver.observe(document.body, { childList: true, subtree: true });
    }

    // 顶部图标与兜底按钮共用同一面板状态,打开时同步控件值,确保轮播切图或主题变化后面板仍显示最新配置。
    function togglePanel(force) {
        if (!panelEl) return;
        const open = typeof force === 'boolean' ? force : !panelEl.classList.contains('lsb-open');
        panelEl.classList.toggle('lsb-open', open);
        if (open) setPanelView('main');
        if (toggleEl) toggleEl.setAttribute('aria-expanded', String(open));
        if (navToggleEl) navToggleEl.setAttribute('aria-expanded', String(open));
        if (open) syncPanel();
    }

    function bindPanel() {
        const fileInput = panelEl.querySelector('[data-role=file]');
        const urlInput = panelEl.querySelector('[data-role=url]');
        const drop = panelEl.querySelector('[data-act=pick]');
        const presetsInput = panelEl.querySelector('[data-role=reply-presets]');

        // 话术按行保存;更新后重建当前回复区的下拉框,让新预设无需刷新即可使用。
        presetsInput.addEventListener('input', () => {
            config.replyPresets = presetsInput.value;
            saveConfig(config);
            document.querySelectorAll('.lsb-reply-head-tools').forEach(node => node.remove());
            enhanceReplyFeatures(document);
        });

        // 滑块/下拉/勾选:input 事件即时预览,避免松手才生效的迟滞感
        panelEl.addEventListener('input', e => {
            const el = e.target.closest('[data-cfg]');
            if (!el) return;
            const key = el.dataset.cfg;
            config[key] = el.type === 'checkbox' ? el.checked
                : (el.type === 'number' || el.type === 'range') ? Number(el.value)
                : el.value;
            if (key === 'reader' && config.reader) config.readerModal = false;
            if (key === 'readerModal' && config.readerModal) config.reader = false;
            saveConfig(config);
            refresh();
            // 阅读模式和侧栏可见性会改动页面结构,保存配置后立即应用新的阅读布局。
            if (key === 'reader' || key === 'readerModal' || key === 'readerHideSidebar') {
                // 双栏已经搬动过原页面 DOM,切换为弹框时交给新页面初始化,避免在当前文档中半途还原。
                if (key === 'readerModal' && config.readerModal && readerDirty) {
                    location.reload();
                    return;
                }
                // 从弹框切回双栏时先还原地址和页面滚动,再让双栏接管原页面 DOM。
                if (config.reader) applyReaderModal();
                applyReader();
                if (config.readerModal) applyReaderModal();
                syncPanel();
            }
            const out = panelEl.querySelector(`[data-val="${key}"]`);
            if (out) out.textContent = formatVal(key, config[key]);
        });
        panelEl.addEventListener('change', e => {
            if (e.target.closest('select')) refresh();
        });

        panelEl.addEventListener('click', async e => {
            const del = e.target.closest('[data-del]');
            if (del) {
                e.stopPropagation();
                removeImage(del.dataset.del);
                refresh();
                syncPanel();
                return;
            }
            const thumb = e.target.closest('.lsb-thumb');
            if (thumb) {
                const i = images.findIndex(x => x.id === thumb.dataset.id);
                if (i >= 0) {
                    config.activeIndex = i;
                    saveConfig(config);
                    refresh();
                    syncPanel();
                }
                return;
            }
            const act = e.target.closest('[data-act]');
            if (!act) return;
            switch (act.dataset.act) {
                case 'close':
                    togglePanel(false);
                    break;
                case 'images':
                    setPanelView('images');
                    syncPanel();
                    break;
                case 'back':
                    setPanelView('main');
                    break;
                case 'pick':
                    fileInput.click();
                    break;
                case 'add-url':
                    if (addUrl(urlInput.value)) {
                        config.activeIndex = images.length - 1;
                        config.enabled = true;
                        saveConfig(config);
                        urlInput.value = '';
                        refresh();
                        syncPanel();
                        toast('已添加图片');
                    }
                    break;
                case 'next':
                    if (images.length < 2) { toast('至少需要两张图片', true); break; }
                    config.activeIndex = nextIndex();
                    saveConfig(config);
                    refresh();
                    syncPanel();
                    break;
                case 'reset':
                    config = Object.assign({}, DEFAULTS, { activeIndex: config.activeIndex });
                    saveConfig(config);
                    refresh();
                    // 恢复默认时若当前正在弹框阅读,先恢复原始地址再启用默认双栏。
                    if (config.reader) applyReaderModal();
                    applyReader();
                    if (config.readerModal) applyReaderModal();
                    syncPanel();
                    toast('已恢复默认参数');
                    break;
                case 'clear':
                    if (!images.length) break;
                    if (!confirm('确定清空图库中的 ' + images.length + ' 张图片?')) break;
                    images = [];
                    config.activeIndex = 0;
                    saveImages(images);
                    saveConfig(config);
                    refresh();
                    syncPanel();
                    toast('图库已清空');
                    break;
            }
        });

        fileInput.addEventListener('change', async () => {
            const n = await addLocalFiles(fileInput.files);
            fileInput.value = '';
            if (!n) return;
            config.activeIndex = images.length - 1;
            config.enabled = true;
            saveConfig(config);
            refresh();
            syncPanel();
            toast('已添加 ' + n + ' 张图片');
        });

        urlInput.addEventListener('keydown', e => {
            if (e.key === 'Enter') {
                e.preventDefault();
                panelEl.querySelector('[data-act=add-url]').click();
            }
        });

        ['dragenter', 'dragover'].forEach(t => drop.addEventListener(t, e => {
            e.preventDefault();
            drop.classList.add('lsb-over');
        }));
        ['dragleave', 'drop'].forEach(t => drop.addEventListener(t, e => {
            e.preventDefault();
            drop.classList.remove('lsb-over');
        }));
        drop.addEventListener('drop', async e => {
            const n = await addLocalFiles(e.dataTransfer && e.dataTransfer.files);
            if (!n) return;
            config.activeIndex = images.length - 1;
            config.enabled = true;
            saveConfig(config);
            refresh();
            syncPanel();
            toast('已添加 ' + n + ' 张图片');
        });
    }

    // ── 跟随站点明暗切换 ──────────────────────────────────────────────────
    // 站点切换暗夜模式时只会改 CSS 变量(新增/替换 <style>、或在 <html> 上挂
    // 内联样式与属性),不会通知我们。这里监听这些变化,发现明暗真的翻转了
    // 才重建样式——只比对布尔结果,避免被自己注入的样式反复触发。
    let lastDark = null;
    let themeTimer = null;

    function checkTheme() {
        const dark = isDarkNow();
        document.documentElement.classList.toggle('lsb-reply-dark', dark);
        if (dark === lastDark) return;
        lastDark = dark;
        applyStyle();
        applyReaderStyle();   // 纯净模式的选中态/悬停底色也分明暗
        syncPanelTheme();
    }

    function watchTheme() {
        lastDark = isDarkNow();

        const schedule = () => {
            clearTimeout(themeTimer);
            themeTimer = setTimeout(checkTheme, 60);
        };

        const mo = new MutationObserver(records => {
            for (const r of records) {
                // 忽略本脚本自己的节点,否则 applyStyle 会触发下一轮观察
                const t = r.target;
                if (t && (t.id === CSS_ID || t.id === ROOT_ID || t.id === PANEL_ID
                    || t.id === READER_CSS_ID || t.id === READER_VIEW_ID || t.id === READER_MODAL_ID)) continue;
                const self = n => n.id === CSS_ID || n.id === ROOT_ID || n.id === PANEL_ID
                    || n.id === READER_CSS_ID || n.id === READER_VIEW_ID || n.id === READER_BAR_ID
                    || n.id === READER_MODAL_ID;
                const added = Array.from(r.addedNodes || []);
                if (added.length && added.every(self)) continue;
                schedule();
                return;
            }
        });
        mo.observe(document.documentElement, {
            attributes: true,
            attributeFilter: ['style', 'class', 'data-theme', 'data-color-scheme'],
        });
        if (document.head) mo.observe(document.head, { childList: true, subtree: true, characterData: true });
        if (document.body) mo.observe(document.body, { attributes: true, attributeFilter: ['style', 'class'] });

        if (window.matchMedia) {
            const mq = matchMedia('(prefers-color-scheme: dark)');
            // Safari 14 之前只有 addListener
            if (mq.addEventListener) mq.addEventListener('change', schedule);
            else if (mq.addListener) mq.addListener(schedule);
        }
        // 站点可能在自己脚本里延迟写入主题,兜一次晚检查
        setTimeout(checkTheme, 1200);
    }

    // ── 启动 ──────────────────────────────────────────────────────────────
    // 尽早注入样式,避免先闪一下原始灰底再变成背景图。
    // document-start 时 <head> 可能还不存在,这种情况下退到 DOMContentLoaded。
    applyStyle();
    applyReaderStyle();

    function init() {
        // 再跑一次:早期注入的 style / 背景层若没挂上或被文档解析覆盖,这里补回来
        applyStyle();
        buildPanel();
        GM_addStyle(REPLY_CSS);
        bindReplyFeatures();
        enhanceReplyFeatures(document);
        startCarousel();
        watchTheme();
        applyReader();
        applyReaderModal();
        bindReaderNav();
    }

    if (document.body) {
        init();
    } else {
        document.addEventListener('DOMContentLoaded', init, { once: true });
    }

    GM_registerMenuCommand('打开背景设置', () => togglePanel(true));
    GM_registerMenuCommand('启用 / 关闭背景', () => {
        config.enabled = !config.enabled;
        saveConfig(config);
        refresh();
        syncPanel();
        toast(config.enabled ? '背景已启用' : '背景已关闭');
    });
    GM_registerMenuCommand('切换双栏阅读', () => {
        config.reader = !config.reader;
        if (config.reader) config.readerModal = false;
        saveConfig(config);
        refresh();
        if (config.reader) applyReaderModal();
        applyReader();
        if (!config.reader) applyReaderModal();
        syncPanel();
        if (!config.reader && !readerDirty) toast('双栏阅读已关闭');
        else if (config.reader) toast(readerUsable() ? '双栏阅读已开启' : '当前页面不支持双栏阅读');
    });
    GM_registerMenuCommand('切换弹框阅读', () => {
        config.readerModal = !config.readerModal;
        if (config.readerModal) config.reader = false;
        saveConfig(config);
        refresh();
        // 双栏模式改动过原页面 DOM,切换弹框模式必须刷新后从服务端原始页面开始。
        if (config.readerModal && readerDirty) {
            location.reload();
            return;
        }
        applyReader();
        applyReaderModal();
        syncPanel();
        toast(config.readerModal ? (readerUsable() ? '弹框阅读已开启' : '当前页面不支持弹框阅读') : '弹框阅读已关闭');
    });
})();