Greasy Fork is available in English.
给 linux.sb 论坛添加自定义图片背景(本地上传 / 图片直链 / 多图轮播,毛玻璃半透明可调),并提供双栏阅读与弹框阅读模式
// ==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.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() ? '弹框阅读已开启' : '当前页面不支持弹框阅读') : '弹框阅读已关闭');
});
})();