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.5
// @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
// @grant        GM_download
// @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 GALLERY_ID = 'lsb-post-gallery';
    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 FLUENT_ANIMATED_BASE = 'https://cdn.jsdelivr.net/npm/@flyos/[email protected]/assets/anim/';
    const REPLY_EMOJI_PACKS = [
        {
            id: 'common',
            label: '常用',
            items: ['😀', '😃', '😄', '😁', '😆', '😂', '🤣', '😊', '🙂', '😉', '😍', '🥰', '😘', '😋', '😎', '🤩', '🥳', '😏', '😒', '😔', '😕', '🙁', '😣', '🥺', '😢', '😭', '😤', '😠', '😡', '🤬', '🤯', '😳', '😱', '🤔', '🤭', '🤫', '😶', '🙄', '😮', '😴', '🤗', '👍', '👎', '👏', '🙏', '💪', '🔥', '✨', '🎉', '❤️', '💔', '✅', '❌', '⭐'].map(value => ({ type: 'text', value }))
        },
        {
            id: 'tieba',
            label: '贴吧',
            // 百度贴吧公开的经典表情资源,点击后插入 Markdown 图片,发帖时由站点渲染为图片。
            items: [
                ['0001', '呵呵'], ['0002', '哈哈'], ['0003', '吐舌'], ['0004', '啊'], ['0005', '惊讶'],
                ['0006', '酷'], ['0007', '怒'], ['0008', '开心'], ['0009', '汗'], ['0010', '泪'],
                ['0011', '太开心'], ['0012', '睡觉'], ['0013', '尴尬'], ['0014', '困'], ['0015', '疑问'],
                ['0016', '嘿嘿'], ['0017', '笑哭'], ['0018', '赞'], ['0019', '捂脸'], ['0020', '滑稽'],
                ['0021', '吃瓜'], ['0022', '鼓掌'], ['0023', '玫瑰'], ['0024', '心碎'], ['0025', '礼物'],
                ['0026', '太阳'], ['0027', '月亮'], ['0028', '蛋糕'], ['0029', '咖啡'], ['0030', '音乐']
            ].map(([id, label]) => ({
                type: 'image',
                label,
                value: `https://tb2.bdstatic.com/tb/editor/images/jd/j_${id}.gif`
            }))
        },
        {
            id: 'fun-animated',
            label: '趣味动图',
            large: true,
            // 候选图片逐张检查动画帧,只保留真正会动的 Quby、小桃猫和 Milk & Mocha 卡通反应。
            items: [
                ['https://media.tenor.com/O6JGE8GCd7kAAAAM/quby-dance.gif', 'Quby 跳舞'],
                ['https://media.tenor.com/dmyWp31OFpcAAAAM/quby-quby-eating-watermelon.gif', 'Quby 吃瓜'],
                ['https://media.tenor.com/SJdwELLr0HEAAAAM/quby-quby-sticker.gif', 'Quby 搞怪'],
                ['https://media.tenor.com/AgJJjNmPp8gAAAAM/quby-hearts.gif', 'Quby 比心'],
                ['https://media.tenor.com/Xwy8j1PM2PAAAAAM/quby-stop-sign.gif', 'Quby 拒绝'],
                ['https://media.tenor.com/7sJ3aYeMAcsAAAAM/quby-cute.gif', 'Quby 卖萌'],
                ['https://media.tenor.com/YQ2q2NtGs0UAAAAM/kitty-cat.gif', '小桃猫喝饮料'],
                ['https://media.tenor.com/QPtL6q_2VjkAAAAM/funny-lol.gif', '小桃猫偷笑'],
                ['https://media.tenor.com/BA4F77U50okAAAAM/love-you.gif', '小桃猫抱抱'],
                ['https://media.tenor.com/wMiLL_6AIToAAAAM/milk-and-mocha-love.gif', 'Milk & Mocha 早安'],
                ['https://media.tenor.com/N-mRknpQ9J4AAAAM/otay-okay.gif', 'Mocha 好的'],
                ['https://media.tenor.com/z5m13wE_XQAAAAAM/blank-stare-carrot-poke.gif', 'Mocha 胡萝卜戳'],
                ['https://media.tenor.com/FaukwQJy3pMAAAAM/chillin-milk-and-mocha.gif', 'Milk & Mocha 贴贴'],
                ['https://media.tenor.com/lOX9rc2f36EAAAAM/milk-and-mocha-milkbear.gif', 'Milk & Mocha 睡觉']
            ].map(([value, label]) => ({
                type: 'image',
                label,
                value
            }))
        },
        {
            id: 'fluent-face',
            label: '动态表情',
            // Fluent 动态表情包以 MIT 许可公开发布;WebP 保留动画效果,适合直接作为 Markdown 图片回复。
            items: [
                ['face-joy', '笑哭'], ['face-rofl', '笑翻'], ['face-party', '派对'], ['face-hearts', '爱心眼'],
                ['face-cry', '哭泣'], ['face-angry', '生气'], ['face-blush', '害羞'], ['face-cool', '墨镜'],
                ['face-eyeroll', '翻白眼'], ['face-grimace', '龇牙'], ['face-grin', '咧嘴笑'], ['face-hug', '抱抱'],
                ['face-kiss', '亲亲'], ['face-love', '喜欢'], ['face-mind-blown', '震惊'], ['face-pleading', '可怜'],
                ['face-relieved', '释然'], ['face-shock', '惊呆'], ['face-shush', '嘘'], ['face-sleep', '睡觉'],
                ['face-star-struck', '崇拜'], ['face-sweat-smile', '汗笑'], ['face-think', '思考'], ['face-tongue', '吐舌'],
                ['face-upside-down', '倒脸'], ['face-wink', '眨眼']
            ].map(([name, label]) => ({
                type: 'image',
                label,
                value: `${FLUENT_ANIMATED_BASE}${name}.webp`
            }))
        },
        {
            id: 'fluent-action',
            label: '动态动作',
            items: [
                ['clap', '鼓掌'], ['fire', '火焰'], ['heart', '红心'], ['heart-hands', '比心'], ['hundred', '满分'],
                ['muscle', '加油'], ['ok-hand', 'OK'], ['pray', '祈祷'], ['raise-hand', '举手'], ['rocket', '起飞'],
                ['rainbow', '彩虹'], ['shrug', '摊手'], ['smile', '微笑'], ['sad', '难过'], ['tada', '庆祝'],
                ['thumbs-up', '点赞'], ['thumbs-down', '踩'], ['victory', '胜利'], ['wave', '挥手'], ['handshake', '握手'],
                ['star', '星星'], ['bolt', '闪电'], ['bug', 'Bug'], ['cloud', '云朵'], ['sun', '太阳'], ['snow', '下雪']
            ].map(([name, label]) => ({
                type: 'image',
                label,
                value: `${FLUENT_ANIMATED_BASE}${name}.webp`
            }))
        },
        {
            id: 'kaomoji',
            label: '颜文字',
            items: [
                '(๑•̀ㅂ•́)و✧', '٩(ˊᗜˋ*)و', '(ง •̀_•́)ง', 'ヾ(≧▽≦*)o', '(´▽`ʃ♡ƪ)',
                '(づ ̄3 ̄)づ╭❤~', '┭┮﹏┭┮', '눈_눈', 'ಠ_ಠ', '¬_¬',
                '(╯°□°)╯︵ ┻━┻', '┬─┬ ノ( ゜-゜ノ)', 'Orz', '2333', 'awsl'
            ].map(value => ({ type: 'text', value }))
        }
    ];
    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:flex;flex-direction:column;width:min(288px,calc(100vw - 16px));max-height:min(360px,calc(100vh - 16px));box-sizing:border-box;gap:6px;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-tabs{display:flex;gap:4px;flex:none;max-width:100%;overflow-x:auto;overflow-y:hidden;padding-bottom:5px;border-bottom:1px solid var(--line,rgba(128,128,128,.2));scroll-behavior:smooth;scroll-snap-type:x proximity;overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch;scrollbar-width:thin;scrollbar-color:rgba(81,97,133,.45) transparent}
.lsb-emoji-tabs::-webkit-scrollbar{height:4px}
.lsb-emoji-tabs::-webkit-scrollbar-track{background:transparent}
.lsb-emoji-tabs::-webkit-scrollbar-thumb{border-radius:4px;background:rgba(81,97,133,.45)}
.lsb-emoji-tabs button{flex:0 0 auto;width:auto;min-width:max-content;height:26px;padding:0 9px;border:0;border-radius:4px;background:transparent;color:var(--text-muted,var(--text,#333));cursor:pointer;font:inherit;font-size:12px;line-height:1;scroll-snap-align:center}
.lsb-emoji-tabs button:hover,.lsb-emoji-tabs button[data-active="1"]{background:#eef2ff;color:#315ccf}
.lsb-emoji-grid{display:grid;grid-template-columns:repeat(8,30px);gap:4px;overflow-y:auto;min-height:0}
.lsb-emoji-grid 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-grid button:hover{background:#eef2ff}
.lsb-emoji-grid img{display:block;width:26px;height:26px;object-fit:contain;margin:auto}
.lsb-emoji-grid.lsb-emoji-large-grid{grid-template-columns:repeat(4,54px);gap:6px;align-content:start;overflow-x:hidden}
.lsb-emoji-grid.lsb-emoji-large-grid button{width:54px;height:54px;box-sizing:border-box}
.lsb-emoji-grid.lsb-emoji-large-grid img{width:48px;height:48px}
.lsb-emoji-grid.lsb-emoji-kaomoji-grid{grid-template-columns:repeat(2,minmax(0,1fr));align-content:start;overflow-x:hidden}
.lsb-emoji-grid.lsb-emoji-kaomoji-grid button{width:100%;min-width:0;box-sizing:border-box;height:auto;min-height:30px;padding:4px 6px;font-size:13px;line-height:1.2;white-space:normal;overflow-wrap:anywhere}
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-tabs{border-color:rgba(255,255,255,.12);scrollbar-color:rgba(203,210,225,.45) transparent}
html.lsb-reply-dark .lsb-emoji-tabs::-webkit-scrollbar-thumb{background:rgba(203,210,225,.45)}
html.lsb-reply-dark .lsb-emoji-tabs button{color:#cbd2e1}
html.lsb-reply-dark .lsb-emoji-tabs button:hover,html.lsb-reply-dark .lsb-emoji-tabs button[data-active="1"]{background:#343953;color:#a9c0ff}
html.lsb-reply-dark .lsb-emoji-grid button{background:#25283a!important;color:#f4f7ff!important}
html.lsb-reply-dark .lsb-emoji-grid button:hover{background:#343953!important}
@media(max-width:420px){.lsb-emoji-grid{grid-template-columns:repeat(6,30px)}.lsb-emoji-grid.lsb-emoji-large-grid{grid-template-columns:repeat(3,54px)}}
@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}}
`;

    const GALLERY_CSS = `
.post-content img{cursor:zoom-in}
#${GALLERY_ID}{position:fixed;inset:0;width:100vw;height:100vh;height:100dvh;z-index:2147483600;display:block;background:rgba(5,7,10,.96);color:#f8fafc;opacity:0;transition:opacity .18s ease;overflow:hidden;isolation:isolate}
#${GALLERY_ID}[hidden]{display:none!important}
#${GALLERY_ID}.lsb-gallery-open{opacity:1}
#${GALLERY_ID} .lsb-gallery-stage{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:58px 76px 70px;box-sizing:border-box;overflow:hidden;touch-action:none;cursor:default}
#${GALLERY_ID} .lsb-gallery-image{display:block;max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;transform:translate3d(0,0,0) scale(1);transform-origin:center;opacity:0;user-select:none;-webkit-user-drag:none;transition:opacity .16s ease,transform .14s ease;will-change:transform}
#${GALLERY_ID} .lsb-gallery-image.lsb-gallery-image-ready{opacity:1}
#${GALLERY_ID} .lsb-gallery-image.lsb-gallery-dragging{transition:opacity .16s ease;cursor:grabbing}
#${GALLERY_ID}[data-pannable="1"] .lsb-gallery-image{cursor:grab}
#${GALLERY_ID} .lsb-gallery-count{position:absolute;top:18px;left:20px;min-width:58px;height:34px;display:flex;align-items:center;justify-content:center;padding:0 11px;box-sizing:border-box;border:1px solid rgba(255,255,255,.16);border-radius:6px;background:rgba(17,24,39,.72);color:#f8fafc;font:600 13px/1 system-ui,-apple-system,"Segoe UI",sans-serif;font-variant-numeric:tabular-nums;backdrop-filter:blur(10px)}
#${GALLERY_ID} .lsb-gallery-close{position:absolute;top:16px;right:18px}
#${GALLERY_ID} .lsb-gallery-nav{display:contents}
#${GALLERY_ID} .lsb-gallery-prev,#${GALLERY_ID} .lsb-gallery-next{position:absolute;top:50%;transform:translateY(-50%)}
#${GALLERY_ID} .lsb-gallery-prev{left:18px}
#${GALLERY_ID} .lsb-gallery-next{right:18px}
#${GALLERY_ID} .lsb-gallery-toolbar{position:absolute;left:50%;bottom:18px;display:flex;align-items:center;gap:5px;padding:5px;border:1px solid rgba(255,255,255,.16);border-radius:6px;background:rgba(17,24,39,.78);transform:translateX(-50%);box-shadow:0 8px 26px rgba(0,0,0,.28);backdrop-filter:blur(10px)}
#${GALLERY_ID} button{display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;min-width:38px;padding:0;border:1px solid transparent;border-radius:5px;background:rgba(17,24,39,.72);color:#f8fafc;cursor:pointer;transition:background .14s ease,border-color .14s ease,opacity .14s ease}
#${GALLERY_ID} button:hover{border-color:rgba(255,255,255,.22);background:rgba(51,65,85,.9)}
#${GALLERY_ID} button:focus-visible{outline:2px solid #60a5fa;outline-offset:2px}
#${GALLERY_ID} button:disabled{opacity:.32;cursor:default}
#${GALLERY_ID} button svg{display:block;width:19px;height:19px;pointer-events:none}
#${GALLERY_ID} .lsb-gallery-scale{width:58px;font:600 12px/1 system-ui,-apple-system,"Segoe UI",sans-serif;font-variant-numeric:tabular-nums}
@media(max-width:640px){
    #${GALLERY_ID} .lsb-gallery-stage{padding:54px 12px 78px}
    #${GALLERY_ID} .lsb-gallery-count{top:12px;left:12px;height:36px}
    #${GALLERY_ID} .lsb-gallery-close{top:11px;right:12px}
    #${GALLERY_ID} .lsb-gallery-prev{left:12px}
    #${GALLERY_ID} .lsb-gallery-next{right:12px}
    #${GALLERY_ID} .lsb-gallery-toolbar{left:12px;bottom:15px;transform:none}
    #${GALLERY_ID} button{width:40px;height:40px;min-width:40px}
    #${GALLERY_ID} .lsb-gallery-scale{width:52px}
}
@media(max-width:460px){
    #${GALLERY_ID} .lsb-gallery-toolbar{gap:2px;padding:3px}
    #${GALLERY_ID} button{width:36px;height:36px;min-width:36px}
    #${GALLERY_ID} .lsb-gallery-scale{width:45px}
}
@media(prefers-reduced-motion:reduce){#${GALLERY_ID},#${GALLERY_ID} .lsb-gallery-image{transition:none}}
`;

    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 || 288;
        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', '选择表情');

        const tabs = document.createElement('div');
        tabs.className = 'lsb-emoji-tabs';
        tabs.setAttribute('role', 'tablist');
        const grid = document.createElement('div');
        grid.className = 'lsb-emoji-grid';
        grid.setAttribute('role', 'listbox');

        // 每个分组只在切换时重建当前网格;贴吧图片通过 Markdown 链接写入回复,发送后仍能正常显示。
        const renderPack = pack => {
            grid.replaceChildren();
            // 颜文字长度差异很大,改用两列自适应按钮并允许换行,避免长文本冲出固定 Emoji 格子互相覆盖。
            grid.classList.toggle('lsb-emoji-kaomoji-grid', pack.id === 'kaomoji');
            // 大图贴纸使用独立的 54px 网格,既能看清表情内容,也避免和普通 Emoji 共用小缩略图尺寸。
            grid.classList.toggle('lsb-emoji-large-grid', !!pack.large);
            tabs.querySelectorAll('[data-emoji-pack]').forEach(tab => {
                const active = tab.dataset.emojiPack === pack.id;
                tab.dataset.active = active ? '1' : '0';
                tab.setAttribute('aria-selected', String(active));
            });
            pack.items.forEach(item => {
                const button = document.createElement('button');
                button.type = 'button';
                button.dataset.emoji = item.type === 'image'
                    ? `![表情-${item.label}](${item.value})`
                    : item.value;
                button.setAttribute('aria-label', item.label || item.value);
                button.title = item.label || item.value;
                if (item.type === 'image') {
                    button.dataset.emojiImage = '1';
                    const image = document.createElement('img');
                    image.src = item.value;
                    image.alt = item.label || '贴吧表情';
                    image.loading = 'lazy';
                    button.appendChild(image);
                } else {
                    button.textContent = item.value;
                }
                grid.appendChild(button);
            });
        };

        REPLY_EMOJI_PACKS.forEach((pack, index) => {
            const tab = document.createElement('button');
            tab.type = 'button';
            tab.textContent = pack.label;
            tab.dataset.emojiPack = pack.id;
            tab.setAttribute('role', 'tab');
            tab.setAttribute('aria-selected', String(index === 0));
            tab.addEventListener('click', () => {
                renderPack(pack);
                const tabsRect = tabs.getBoundingClientRect();
                const tabRect = tab.getBoundingClientRect();
                tabs.scrollTo({
                    left: tabs.scrollLeft + tabRect.left - tabsRect.left - (tabsRect.width - tabRect.width) / 2,
                    behavior: 'smooth'
                });
            });
            tabs.appendChild(tab);
        });
        // 桌面滚轮在标题栏上转为横向移动;触屏仍使用浏览器原生滑动,二者共用同一滚动位置。
        tabs.addEventListener('wheel', event => {
            if (tabs.scrollWidth <= tabs.clientWidth || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
            event.preventDefault();
            tabs.scrollBy({ left: event.deltaY, behavior: 'smooth' });
        }, { passive: false });
        picker.append(tabs, grid);
        renderPack(REPLY_EMOJI_PACKS[0]);
        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 });
        }
    }

    // ── 帖子图片画廊 ──────────────────────────────────────────────────────
    let galleryEl = null;
    let galleryImageEl = null;
    let galleryItems = [];
    let galleryIndex = 0;
    let galleryScale = 1;
    let galleryPanX = 0;
    let galleryPanY = 0;
    let galleryBodyOverflow = '';
    let galleryHtmlOverflow = '';
    let galleryHtmlScrollbarGutter = '';
    let galleryReturnFocus = null;
    let galleryCloseTimer = null;
    let galleryBound = false;
    let gallerySuppressClick = false;
    const galleryPointers = new Map();
    const GALLERY_MIN_SCALE = 0.5;
    const GALLERY_MAX_SCALE = 5;

    /** 同步当前缩放和平移,并限制拖动范围,避免图片被完全拖出可视区域。 */
    function applyGalleryTransform() {
        if (!galleryEl || !galleryImageEl) return;
        const stage = galleryEl.querySelector('.lsb-gallery-stage');
        if (galleryScale <= 1 || !stage) {
            galleryPanX = 0;
            galleryPanY = 0;
        } else {
            const maxX = Math.max(0, (galleryImageEl.offsetWidth * galleryScale - stage.clientWidth) / 2);
            const maxY = Math.max(0, (galleryImageEl.offsetHeight * galleryScale - stage.clientHeight) / 2);
            galleryPanX = Math.max(-maxX, Math.min(maxX, galleryPanX));
            galleryPanY = Math.max(-maxY, Math.min(maxY, galleryPanY));
        }
        galleryImageEl.style.transform = `translate3d(${galleryPanX}px,${galleryPanY}px,0) scale(${galleryScale})`;
        galleryEl.dataset.pannable = galleryScale > 1 ? '1' : '0';
        const scaleButton = galleryEl.querySelector('[data-gallery-act="reset"]');
        if (scaleButton) scaleButton.textContent = Math.round(galleryScale * 100) + '%';
    }

    /** 所有缩放入口共用同一范围和步进精度,切回适配尺寸时同时回到图片中心。 */
    function setGalleryScale(value) {
        galleryScale = Math.round(Math.max(GALLERY_MIN_SCALE, Math.min(GALLERY_MAX_SCALE, value)) * 100) / 100;
        if (galleryScale <= 1) {
            galleryPanX = 0;
            galleryPanY = 0;
        }
        applyGalleryTransform();
    }

    /** 切图时恢复适配尺寸并更新序号,列表首尾之间循环切换。 */
    function showGalleryImage(index) {
        if (!galleryEl || !galleryImageEl || !galleryItems.length) return;
        galleryIndex = (index + galleryItems.length) % galleryItems.length;
        galleryScale = 1;
        galleryPanX = 0;
        galleryPanY = 0;
        const item = galleryItems[galleryIndex];
        galleryImageEl.classList.remove('lsb-gallery-image-ready', 'lsb-gallery-dragging');
        galleryImageEl.alt = item.alt || '帖子图片';
        galleryImageEl.onload = () => {
            if (galleryImageEl.src !== item.src) return;
            applyGalleryTransform();
            requestAnimationFrame(() => galleryImageEl?.classList.add('lsb-gallery-image-ready'));
        };
        galleryImageEl.onerror = () => {
            if (galleryImageEl.src === item.src) toast('图片加载失败', true);
        };
        galleryImageEl.src = item.src;
        if (galleryImageEl.complete && galleryImageEl.naturalWidth) galleryImageEl.onload();
        const count = galleryEl.querySelector('.lsb-gallery-count');
        if (count) count.textContent = `${galleryIndex + 1} / ${galleryItems.length}`;
        galleryEl.querySelectorAll('[data-gallery-act="prev"],[data-gallery-act="next"]').forEach(button => {
            button.disabled = galleryItems.length < 2;
        });
        applyGalleryTransform();
    }

    /** 下载优先交给油猴处理跨域原图;接口不可用或失败时退回浏览器原生下载。 */
    function downloadGalleryImage() {
        const item = galleryItems[galleryIndex];
        if (!item) return;
        let pathName = '';
        try { pathName = decodeURIComponent(new URL(item.src, location.href).pathname.split('/').pop() || ''); } catch (e) { /* 保留默认文件名 */ }
        const ext = (pathName.match(/\.(?:apng|avif|gif|jpe?g|png|webp|bmp)$/i) || ['.jpg'])[0].toLowerCase();
        const pathStem = pathName.replace(/\.[^.]+$/, '');
        let stem = String(item.alt || pathStem || `linux-sb-image-${galleryIndex + 1}`).trim();
        stem = stem.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/[. ]+$/g, '').slice(0, 80) || 'linux-sb-image';
        const name = /\.(?:apng|avif|gif|jpe?g|png|webp|bmp)$/i.test(stem) ? stem : stem + ext;
        let downloadSettled = false;
        const fallback = () => {
            if (downloadSettled) return;
            downloadSettled = true;
            const link = document.createElement('a');
            link.href = item.src;
            link.download = name;
            link.target = '_blank';
            link.rel = 'noopener';
            document.body.appendChild(link);
            link.click();
            link.remove();
            toast('已交给浏览器下载');
        };
        const success = () => {
            if (downloadSettled) return;
            downloadSettled = true;
            toast('图片已下载');
        };
        const downloader = typeof GM_download === 'function'
            ? GM_download
            : (typeof GM !== 'undefined' && typeof GM.download === 'function' ? GM.download : null);
        if (!downloader) {
            fallback();
            return;
        }
        try {
            const result = downloader({
                url: item.src,
                name,
                saveAs: true,
                onload: success,
                onerror: fallback,
                ontimeout: fallback
            });
            // Promise 版 GM.download 不触发旧接口回调时,仍需兜住授权或网络异常。
            if (result && typeof result.then === 'function') result.then(success).catch(fallback);
        } catch (e) {
            fallback();
        }
    }

    /** 关闭画廊后恢复打开前的页面滚动状态和键盘焦点。 */
    function closeGallery() {
        if (!galleryEl || galleryEl.hidden) return;
        galleryEl.classList.remove('lsb-gallery-open');
        galleryPointers.clear();
        galleryImageEl?.classList.remove('lsb-gallery-dragging');
        if (document.body) document.body.style.overflow = galleryBodyOverflow;
        document.documentElement.style.overflow = galleryHtmlOverflow;
        document.documentElement.style.scrollbarGutter = galleryHtmlScrollbarGutter;
        galleryBodyOverflow = '';
        galleryHtmlOverflow = '';
        galleryHtmlScrollbarGutter = '';
        const focusTarget = galleryReturnFocus;
        galleryReturnFocus = null;
        clearTimeout(galleryCloseTimer);
        galleryCloseTimer = setTimeout(() => {
            if (galleryEl && !galleryEl.classList.contains('lsb-gallery-open')) galleryEl.hidden = true;
        }, 180);
        if (focusTarget?.isConnected) focusTarget.focus?.({ preventScroll: true });
    }

    /** 首次使用时建立全屏画布,并集中绑定按钮、滚轮、拖拽、滑动和双指缩放。 */
    function ensureGallery() {
        if (galleryEl?.isConnected) return galleryEl;
        const gallery = document.createElement('div');
        gallery.id = GALLERY_ID;
        gallery.hidden = true;
        gallery.tabIndex = -1;
        gallery.setAttribute('role', 'dialog');
        gallery.setAttribute('aria-modal', 'true');
        gallery.setAttribute('aria-label', '帖子图片画廊');
        gallery.innerHTML = `
<div class="lsb-gallery-stage">
    <img class="lsb-gallery-image" alt="帖子图片" draggable="false">
</div>
<div class="lsb-gallery-count" aria-live="polite">1 / 1</div>
<button type="button" class="lsb-gallery-close" data-gallery-act="close" title="关闭" aria-label="关闭画廊">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<div class="lsb-gallery-nav">
    <button type="button" class="lsb-gallery-prev" data-gallery-act="prev" title="上一张" aria-label="上一张图片">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
    </button>
    <button type="button" class="lsb-gallery-next" data-gallery-act="next" title="下一张" aria-label="下一张图片">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
    </button>
</div>
<div class="lsb-gallery-toolbar">
    <button type="button" data-gallery-act="zoom-out" title="缩小" aria-label="缩小图片">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3M8 11h6"/></svg>
    </button>
    <button type="button" class="lsb-gallery-scale" data-gallery-act="reset" title="恢复适配尺寸" aria-label="恢复适配尺寸">100%</button>
    <button type="button" data-gallery-act="zoom-in" title="放大" aria-label="放大图片">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3M11 8v6M8 11h6"/></svg>
    </button>
    <button type="button" data-gallery-act="download" title="下载原图" aria-label="下载原图">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14"/></svg>
    </button>
</div>`;
        document.body.appendChild(gallery);
        galleryEl = gallery;
        galleryImageEl = gallery.querySelector('.lsb-gallery-image');
        const stage = gallery.querySelector('.lsb-gallery-stage');

        gallery.addEventListener('click', event => {
            const action = event.target.closest?.('[data-gallery-act]')?.dataset.galleryAct;
            if (action) {
                event.preventDefault();
                if (action === 'close') closeGallery();
                else if (action === 'prev') showGalleryImage(galleryIndex - 1);
                else if (action === 'next') showGalleryImage(galleryIndex + 1);
                else if (action === 'zoom-out') setGalleryScale(galleryScale - 0.25);
                else if (action === 'zoom-in') setGalleryScale(galleryScale + 0.25);
                else if (action === 'reset') setGalleryScale(1);
                else if (action === 'download') downloadGalleryImage();
                return;
            }
            // 图片左右留白是高频切图区域;上下留白仍负责关闭,兼顾快速浏览和退出操作。
            if (event.target === stage) {
                if (gallerySuppressClick) return;
                const imageRect = galleryImageEl?.getBoundingClientRect();
                if (galleryItems.length > 1 && imageRect && event.clientX < imageRect.left) {
                    showGalleryImage(galleryIndex - 1);
                    return;
                }
                if (galleryItems.length > 1 && imageRect && event.clientX > imageRect.right) {
                    showGalleryImage(galleryIndex + 1);
                    return;
                }
                closeGallery();
            }
        });
        stage.addEventListener('dblclick', event => {
            if (event.target !== galleryImageEl) return;
            event.preventDefault();
            setGalleryScale(galleryScale === 1 ? 2 : 1);
        });
        stage.addEventListener('wheel', event => {
            event.preventDefault();
            setGalleryScale(galleryScale + (event.deltaY < 0 ? 0.25 : -0.25));
        }, { passive: false });

        let gesture = null;
        stage.addEventListener('pointerdown', event => {
            if (event.pointerType === 'mouse' && event.button !== 0) return;
            stage.setPointerCapture?.(event.pointerId);
            galleryPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
            if (galleryPointers.size === 1) {
                gesture = { startX: event.clientX, startY: event.clientY, panX: galleryPanX, panY: galleryPanY, moved: false, pinch: false };
            } else if (galleryPointers.size === 2) {
                const points = Array.from(galleryPointers.values());
                gesture = {
                    pinch: true,
                    distance: Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y),
                    scale: galleryScale,
                    moved: false
                };
            }
            if (galleryScale > 1 || galleryPointers.size > 1) galleryImageEl?.classList.add('lsb-gallery-dragging');
        });
        stage.addEventListener('pointermove', event => {
            if (!galleryPointers.has(event.pointerId) || !gesture) return;
            galleryPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
            if (gesture.pinch && galleryPointers.size >= 2) {
                const points = Array.from(galleryPointers.values());
                const distance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
                if (Math.abs(distance - gesture.distance) > 3) gesture.moved = true;
                setGalleryScale(gesture.scale * distance / Math.max(1, gesture.distance));
                return;
            }
            const dx = event.clientX - gesture.startX;
            const dy = event.clientY - gesture.startY;
            if (Math.hypot(dx, dy) > 5) gesture.moved = true;
            if (galleryScale > 1) {
                galleryPanX = gesture.panX + dx;
                galleryPanY = gesture.panY + dy;
                galleryImageEl?.classList.add('lsb-gallery-dragging');
                applyGalleryTransform();
            }
        });
        const finishPointer = event => {
            if (!galleryPointers.has(event.pointerId)) return;
            const wasSingle = galleryPointers.size === 1;
            galleryPointers.delete(event.pointerId);
            if (wasSingle && gesture && !gesture.pinch && galleryScale <= 1 && event.type !== 'pointercancel') {
                const dx = event.clientX - gesture.startX;
                const dy = event.clientY - gesture.startY;
                // 触屏纵向滑动与上下按钮保持同一语义:上滑下一张,下滑上一张。
                if (Math.abs(dy) > 60 && Math.abs(dy) > Math.abs(dx) * 1.2) {
                    showGalleryImage(galleryIndex + (dy < 0 ? 1 : -1));
                    gesture.moved = true;
                }
            }
            if (gesture?.moved) {
                gallerySuppressClick = true;
                setTimeout(() => { gallerySuppressClick = false; }, 0);
            }
            if (!galleryPointers.size) {
                galleryImageEl?.classList.remove('lsb-gallery-dragging');
                gesture = null;
            } else {
                const point = Array.from(galleryPointers.values())[0];
                gesture = { startX: point.x, startY: point.y, panX: galleryPanX, panY: galleryPanY, moved: true, pinch: false };
            }
        };
        stage.addEventListener('pointerup', finishPointer);
        stage.addEventListener('pointercancel', finishPointer);
        return gallery;
    }

    /** 打开当前帖子范围内的图片集合,避免双栏或弹框中的不同帖子互相串图。 */
    function openGallery(items, index, sourceImage) {
        const gallery = ensureGallery();
        galleryItems = items;
        galleryReturnFocus = sourceImage;
        clearTimeout(galleryCloseTimer);
        galleryBodyOverflow = document.body?.style.overflow || '';
        galleryHtmlOverflow = document.documentElement.style.overflow;
        galleryHtmlScrollbarGutter = document.documentElement.style.scrollbarGutter;
        if (document.body) document.body.style.overflow = 'hidden';
        document.documentElement.style.overflow = 'hidden';
        document.documentElement.style.scrollbarGutter = 'auto';
        gallery.hidden = false;
        showGalleryImage(index);
        requestAnimationFrame(() => gallery.classList.add('lsb-gallery-open'));
        gallery.focus({ preventScroll: true });
    }

    /** 使用文档级事件委托覆盖动态导入的帖子正文,并优先处理画廊键盘操作。 */
    function bindImageGallery() {
        if (galleryBound) return;
        galleryBound = true;
        document.addEventListener('click', event => {
            const target = event.target instanceof Element ? event.target : null;
            if (!target || galleryEl?.contains(target)) return;
            const image = target.closest('.post-content img');
            if (!image || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
            const panel = image.closest('.main-panel');
            if (!panel) return;
            const items = [];
            let activeIndex = 0;
            panel.querySelectorAll('.post-content img').forEach(node => {
                const rawSrc = node.currentSrc || node.getAttribute('src') || node.dataset.src;
                if (!rawSrc) return;
                let src = rawSrc;
                try { src = new URL(rawSrc, document.baseURI).href; } catch (e) { /* data/blob 地址保持原值 */ }
                if (node === image) activeIndex = items.length;
                items.push({ src, alt: node.getAttribute('alt') || node.getAttribute('title') || '' });
            });
            if (!items.length) return;
            event.preventDefault();
            event.stopImmediatePropagation();
            openGallery(items, activeIndex, image);
        }, true);
        window.addEventListener('keydown', event => {
            if (!galleryEl || galleryEl.hidden) return;
            let handled = true;
            if (event.key === 'Escape') closeGallery();
            else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') showGalleryImage(galleryIndex - 1);
            else if (event.key === 'ArrowDown' || event.key === 'ArrowRight') showGalleryImage(galleryIndex + 1);
            else if (event.key === '+' || event.key === '=' || event.key === 'Add') setGalleryScale(galleryScale + 0.25);
            else if (event.key === '-' || event.key === '_' || event.key === 'Subtract') setGalleryScale(galleryScale - 0.25);
            else if (event.key === '0') setGalleryScale(1);
            else handled = false;
            if (!handled) return;
            // 画廊浮在阅读弹框之上,已处理的 Esc 不再向下关闭阅读弹框。
            event.preventDefault();
            event.stopImmediatePropagation();
        }, true);
        window.addEventListener('resize', () => {
            if (galleryEl && !galleryEl.hidden) applyGalleryTransform();
        });
    }

    /** 建左右两栏的骨架:分隔条与右栏紧跟在 .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
                    || t.id === GALLERY_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 || n.id === GALLERY_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);
        GM_addStyle(GALLERY_CSS);
        bindReplyFeatures();
        enhanceReplyFeatures(document);
        bindImageGallery();
        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() ? '弹框阅读已开启' : '当前页面不支持弹框阅读') : '弹框阅读已关闭');
    });
})();