给 linux.sb 论坛添加自定义图片背景(本地上传 / 图片直链 / 多图轮播,毛玻璃半透明可调),并提供双栏阅读与弹框阅读模式
// ==UserScript==
// @name LINUX SB READ
// @namespace http://tampermonkey.net/
// @version 1.7.3
// @description 给 linux.sb 论坛添加自定义图片背景(本地上传 / 图片直链 / 多图轮播,毛玻璃半透明可调),并提供双栏阅读与弹框阅读模式
// @author You
// @license MIT
// @match https://linux.sb/*
// @match https://www.linux.sb/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @connect imgur.la
// @run-at document-start
// @noframes
// ==/UserScript==
(function () {
'use strict';
// ── 存储键 ────────────────────────────────────────────────────────────
// 图片单独存一个键:base64 体积大,读写配置时不必反复搬运整个图库。
const KEY_CONFIG = 'lsb_bg_config';
const KEY_IMAGES = 'lsb_bg_images';
const IMGUR_API_URL = 'https://imgur.la/api/1/upload';
// imgur.la API 文档公开的公共 Key;可在设置面板中替换为用户自己的 Key。
const IMGUR_PUBLIC_API_KEY = '89bf00be2f91e3e5c74ea050d5b1d3f3';
const LEGACY_REPLY_PRESETS = 'B友牛逼\n非必要就抽奖';
const DEFAULT_REPLY_PRESETS = [
'B友牛逼',
'非必要就抽奖',
'感谢分享!',
'感谢楼主分享',
'学习了,谢谢分享!',
'感谢大佬解答',
'受教了',
'支持一下',
'Mark 一下,之后再看'
].join('\n');
const DEFAULTS = {
enabled: true,
activeIndex: 0, // 当前显示的图片下标
panelAlpha: 0.72, // 面板(卡片/顶栏)不透明度
blur: 12, // 毛玻璃模糊半径 px
maskAlpha: 0.15, // 背景图上方蒙版浓度,压住过于花的图
brightness: 1, // 背景图亮度
size: 'cover', // cover | contain | repeat
position: 'center',
fixed: true, // 背景是否随页面滚动
carousel: false, // 是否轮播
interval: 60, // 轮播间隔(秒)
shuffle: false, // 轮播是否随机顺序
theme: 'auto', // auto 跟随站点明暗 | light | dark
reader: true, // 双栏阅读:左栏列表 + 右栏正文
readerWidth: 380, // 纯净模式左栏宽度 px
readerHideSidebar: false, // 双栏阅读是否隐藏站点右侧栏
readerModal: false, // 弹框阅读:点击帖子后在弹框内打开
readerModalWidth: 80, // 弹框阅读宽度,占视口百分比
replyPresets: DEFAULT_REPLY_PRESETS, // 快捷回复预设,每行一条
imgurApiKey: IMGUR_PUBLIC_API_KEY // imgur.la API Key,用于回复中快捷上传图片
};
// 上传图片压缩上限:原图动辄 5MB+,压到这个尺寸后 base64 通常 200~600KB,
// 既不撑爆油猴存储,也不会让 CSS url() 解析变慢。
const MAX_EDGE = 2560;
const JPEG_QUALITY = 0.85;
const CSS_ID = 'lsb-bg-style';
const ROOT_ID = 'lsb-bg-root';
const PANEL_ID = 'lsb-bg-settings';
const NAV_TOGGLE_ID = 'lsb-bg-nav-toggle';
const READER_CSS_ID = 'lsb-reader-style';
const READER_CLASS = 'lsb-reader-on'; // 挂在 <html> 上,纯净模式的 CSS 总开关
const READER_MODAL_CLASS = 'lsb-reader-modal-on';
const READER_MODAL_ID = 'lsb-reader-modal';
const PLUGIN_ICON_SVG = '<svg viewBox="0 0 32 18" fill="currentColor" aria-hidden="true"><text x="16" y="14" text-anchor="middle" font-family="Arial, sans-serif" font-size="15" font-weight="700" letter-spacing=".2">lsb</text></svg>';
// ── 配置读写 ──────────────────────────────────────────────────────────
function loadConfig() {
let raw = {};
try {
raw = JSON.parse(GM_getValue(KEY_CONFIG, '{}')) || {};
} catch (e) {
raw = {};
}
const config = Object.assign({}, DEFAULTS, raw);
// 新增弹框模式时,未保存过 reader 的配置应默认只启用弹框,避免被双栏默认值覆盖。
if (raw.readerModal === true && !Object.prototype.hasOwnProperty.call(raw, 'reader')) {
config.reader = false;
}
// 阅读模式只允许一种生效;旧配置若同时打开,优先保留原有的双栏阅读。
if (config.reader && config.readerModal) {
config.readerModal = false;
GM_setValue(KEY_CONFIG, JSON.stringify(config));
}
// 仅把插件原先的两条默认话术升级为新默认列表;用户自己编辑过的内容保持原样。
if (raw.replyPresets === LEGACY_REPLY_PRESETS) {
config.replyPresets = DEFAULT_REPLY_PRESETS;
GM_setValue(KEY_CONFIG, JSON.stringify(config));
}
return config;
}
function saveConfig(cfg) {
GM_setValue(KEY_CONFIG, JSON.stringify(cfg));
}
/**
* 图库结构:[{ id, name, type: 'url' | 'local', src }]
* local 的 src 是 data:image/...;base64,...,url 的 src 是外链地址。
*/
function loadImages() {
try {
const list = JSON.parse(GM_getValue(KEY_IMAGES, '[]'));
return Array.isArray(list) ? list : [];
} catch (e) {
return [];
}
}
function saveImages(list) {
GM_setValue(KEY_IMAGES, JSON.stringify(list));
}
let config = loadConfig();
let images = loadImages();
let carouselTimer = null;
const REPLY_EMOJIS = ['😀', '😃', '😄', '😁', '😆', '😂', '🤣', '😊', '🙂', '😉', '😍', '🥰', '😘', '😋', '😎', '🤩', '🥳', '😏', '😒', '😔', '😕', '🙁', '😣', '🥺', '😢', '😭', '😤', '😠', '😡', '🤬', '🤯', '😳', '😱', '🤔', '🤭', '🤫', '😶', '🙄', '😮', '😴', '🤗', '👍', '👎', '👏', '🙏', '💪', '🔥', '✨', '🎉', '❤️', '💔', '✅', '❌', '⭐'];
let replyEmojiPicker = null;
let replyEmojiButton = null;
let replyFeaturesBound = false;
const REPLY_CSS = `
.reply-panel-head .lsb-reply-head-tools{display:flex;align-items:center;gap:6px;margin-right:auto}
.reply-panel-head .lsb-reply-emoji{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;min-width:28px;padding:0;border:1px solid var(--line,rgba(128,128,128,.25));border-radius:4px;background:#fff;color:var(--text,#333);cursor:pointer;font-size:16px;line-height:1}
.reply-panel-head .lsb-reply-upload{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;min-width:28px;padding:0;border:1px solid var(--line,rgba(128,128,128,.25));border-radius:4px;background:#fff;color:var(--text,#333);cursor:pointer;font-size:16px;line-height:1}
.reply-panel-head .lsb-reply-upload svg{width:18px;height:18px;display:block;overflow:visible}
.reply-panel-head .lsb-reply-upload input{display:none}
.reply-panel-head .lsb-reply-upload[data-uploading]{opacity:.55;cursor:wait}
.reply-panel-head .lsb-reply-preset{width:min(190px,28vw);height:28px;min-width:110px;padding:0 24px 0 8px;border:1px solid var(--line,rgba(128,128,128,.25));border-radius:4px;background:#fff;color:var(--text,#333);font:inherit;cursor:pointer;color-scheme:light}
.reply-panel-head .lsb-reply-preset option{background:#fff!important;color:#1f2937!important}
.reply-panel-head .lsb-reply-preset option:checked{background:#2563eb!important;color:#fff!important}
html.lsb-reply-dark .reply-panel-head .lsb-reply-preset{background:#25283a!important;color:#f4f7ff!important;color-scheme:dark}
html.lsb-reply-dark .reply-panel-head .lsb-reply-preset option{background:#25283a!important;color:#f4f7ff!important}
html.lsb-reply-dark .reply-panel-head .lsb-reply-preset option:checked{background:#2563eb!important;color:#fff!important}
.reply-panel-head .lsb-reply-emoji:hover,.reply-panel-head .lsb-reply-upload:hover,.reply-panel-head .lsb-reply-preset:hover{border-color:var(--brand,#516185);background:#f2f4f8}
.lsb-emoji-picker{position:fixed;z-index:2147483500;display:grid;grid-template-columns:repeat(8,30px);gap:4px;padding:8px;border:1px solid var(--line,rgba(128,128,128,.3));border-radius:6px;background:#fff;box-shadow:0 8px 24px rgba(0,0,0,.2)}
.lsb-emoji-picker[hidden]{display:none}
.lsb-emoji-picker button{width:30px;height:30px;padding:0;border:0;border-radius:4px;background:#fff;color:var(--text,#333);cursor:pointer;font-size:19px;line-height:1}
.lsb-emoji-picker button:hover{background:#eef2ff}
html.lsb-reply-dark .reply-panel-head .lsb-reply-emoji{background:#25283a!important;color:#f4f7ff!important}
html.lsb-reply-dark .reply-panel-head .lsb-reply-upload{background:#25283a!important;color:#f4f7ff!important}
html.lsb-reply-dark .reply-panel-head .lsb-reply-emoji:hover,html.lsb-reply-dark .reply-panel-head .lsb-reply-preset:hover{background:#343953!important}
html.lsb-reply-dark .reply-panel-head .lsb-reply-upload:hover{background:#343953!important}
html.lsb-reply-dark .lsb-emoji-picker{background:#25283a!important}
html.lsb-reply-dark .lsb-emoji-picker button{background:#25283a!important;color:#f4f7ff!important}
html.lsb-reply-dark .lsb-emoji-picker button:hover{background:#343953!important}
@media(max-width:720px){.reply-panel-head{flex-wrap:wrap}.reply-panel-head .lsb-reply-head-tools{order:3;flex:1 0 100%}.reply-panel-head .lsb-reply-preset{width:auto;flex:1}}
`;
function clampIndex() {
if (!images.length) {
config.activeIndex = 0;
return;
}
if (config.activeIndex < 0 || config.activeIndex >= images.length) {
config.activeIndex = 0;
}
}
function currentImage() {
clampIndex();
return images[config.activeIndex] || null;
}
// ── 背景层 + 样式注入 ─────────────────────────────────────────────────
function cssUrl(src) {
// data: 与普通 URL 都可能含引号/括号/换行,统一转义后放进 url("...")
return 'url("' + String(src).replace(/["\\]/g, '\\$&').replace(/\s+/g, '') + '")';
}
/** 极早期注入时文档可能连 documentElement 都还没有,取不到挂载点就返回 null */
function mountPoint() {
return document.head || document.body || document.documentElement || null;
}
function ensureBgRoot() {
const host = document.body || document.documentElement;
if (!host) return null;
let el = document.getElementById(ROOT_ID);
if (!el || !el.isConnected) {
el = document.createElement('div');
el.id = ROOT_ID;
host.appendChild(el);
} else if (document.body && el.parentNode !== document.body) {
// 早期挂在 documentElement 上,body 就绪后迁进去
document.body.appendChild(el);
}
return el;
}
// ── 明暗判定 ──────────────────────────────────────────────────────────
// 站点自带的暗夜模式由 themes 插件下发 CSS 变量覆盖(页面里那段
// <style data-themes-plugin>),没有 .dark 类名也没有 data-theme 属性可认,
// 所以只能反过来读站点变量的实际亮度来判断当前是明还是暗。
/** 解析 CSS 颜色为 [r,g,b],解析不出来返回 null */
function parseRgb(color) {
const s = String(color || '').trim();
if (!s) return null;
let m = s.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
let h = m[1];
if (h.length === 3 || h.length === 4) h = h.slice(0, 3).split('').map(c => c + c).join('');
if (h.length < 6) return null;
return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16));
}
m = s.match(/^rgba?\(([^)]+)\)$/i);
if (m) {
const p = m[1].split(/[,\s/]+/).filter(Boolean).map(Number);
if (p.length >= 3 && p.slice(0, 3).every(n => Number.isFinite(n))) return p.slice(0, 3);
}
return null;
}
function luminance(color) {
const rgb = parseRgb(color);
return rgb ? 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2] : null;
}
/** 当前是否应按深色渲染 */
function isDarkNow() {
if (config.theme === 'dark') return true;
if (config.theme === 'light') return false;
const cs = getComputedStyle(document.documentElement);
// --bg 是站点的页面底色,我们不覆盖它,可以放心当作明暗依据
const bg = luminance(cs.getPropertyValue('--bg'));
if (bg !== null) return bg < 128;
// 变量还没加载(document-start 极早期)时退而看正文色:浅字=深色主题
const text = luminance(cs.getPropertyValue('--text'));
if (text !== null) return text > 150;
return !!(window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches);
}
function buildCss() {
const img = currentImage();
if (!config.enabled || !img) return '';
const dark = isDarkNow();
const a = Number(config.panelAlpha);
// 深色模式下面板要用深色半透明,否则白底盖在深色文字上直接糊成一片
const base = dark ? '22,24,29' : '255,255,255';
const panel = `rgba(${base},${a.toFixed(3)})`;
// 输入框比卡片更需要对比度,单独抬高一档不透明度
const field = `rgba(${base},${Math.min(1, a + 0.18).toFixed(3)})`;
// 蒙版跟着主题走:浅色压白、深色压黑,都是为了让面板文字浮出来
const mask = `rgba(${dark ? '0,0,0' : '255,255,255'},${Number(config.maskAlpha).toFixed(3)})`;
const line = dark ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.10)';
const lineSoft = dark ? 'rgba(255,255,255,.08)' : 'rgba(0,0,0,.06)';
const hover = dark ? 'rgba(255,255,255,.06)' : 'rgba(0,0,0,.04)';
// checkbox 滑钮固定色:深色轨道配浅灰钮,浅色轨道配白钮
const swTrack = dark ? 'rgba(255,255,255,.16)' : '#e3e3e3';
const swKnob = dark ? '#c9ced6' : '#fff';
const drawer = dark ? '#1b1d22' : '#fff';
const blur = `blur(${Number(config.blur)}px)`;
const repeat = config.size === 'repeat' ? 'repeat' : 'no-repeat';
const size = config.size === 'repeat' ? 'auto' : config.size;
return `
/* 固定模式:背景层钉在视口上,滚动时图不动。
跟随模式:改成 absolute 并撑到整篇文档高度,图随内容一起滚。
(background-attachment 对 position:fixed 的元素无效,必须换定位方式) */
#${ROOT_ID}{
position:${config.fixed ? 'fixed' : 'absolute'};
${config.fixed ? 'inset:0;' : 'top:0;left:0;width:100%;min-height:100vh;height:100%;'}
z-index:-1;
pointer-events:none;
background-image:${cssUrl(img.src)};
background-size:${size};
background-position:${config.position};
background-repeat:${repeat};
filter:brightness(${Number(config.brightness)});
}
#${ROOT_ID}::after{
content:"";
position:absolute;
inset:0;
background:${mask};
}
html,body{background:transparent!important}
${config.fixed ? '' : '/* 跟随滚动时背景层要以 body 为定位基准,否则高度只有一屏 */\nbody{position:relative}'}
/* 站点所有面板都走 --panel,改这一个变量即可整体半透明。
--text / --text-muted 一律不动,交给站点自己的明暗主题决定,
这样切换暗夜模式时文字颜色始终是站点配好的那一套。 */
:root{
--panel:${panel}!important;
--line:${line}!important;
--line-soft:${lineSoft}!important;
}
.top,.forum-more-region,.home-shell,.main-panel,.box,.card,.list,
.user-header,.user-bio,.modal-panel,
.search-form,.reply-panel,.admin-list-panel,.bulk-bar,.side{
-webkit-backdrop-filter:${blur} saturate(140%);
backdrop-filter:${blur} saturate(140%);
}
/* 输入框比卡片更需要对比度,单独抬高一档。
必须排除 checkbox/radio/range/file/按钮:给它们设 background 会抹掉
Chrome 的原生控件外观(勾选框会变成空白方块,看不出选中状态)。 */
input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]):not([type=color]):not([type=button]):not([type=submit]):not([type=reset]),
select,textarea,.prompt-input,.notify-form textarea{
background:${field}!important;
}
/* 站点的 checkbox 是自绘开关:轨道用 --line-soft,滑钮用 radial-gradient(var(--panel))。
这两个变量被我们改成半透明后滑钮就看不见了,这里用固定色还原开关外观。 */
input[type=checkbox]{
background-color:${swTrack}!important;
background-image:radial-gradient(circle,${swKnob} 0 7px,transparent 7.5px)!important;
border-color:${line}!important;
-webkit-backdrop-filter:none!important;
backdrop-filter:none!important;
}
input[type=checkbox]:checked{
background-color:var(--brand,#2ecc71)!important;
border-color:var(--brand,#2ecc71)!important;
}
/* 原本 hover 用不透明的 --bg,会把背景图挡掉 */
.post-item:hover,.profile-file-row:hover,.post-entry:hover{
background:${hover}!important;
}
/* 移动端抽屉不参与半透明:本插件只针对桌面端,抽屉保持原样更稳妥 */
.mobile-menu-drawer,.mobile-menu-body,.mobile-menu-close{
background:${drawer}!important;
}
`;
}
function applyStyle() {
ensureBgRoot();
const host = mountPoint();
if (!host) return false;
let style = document.getElementById(CSS_ID);
// document-start 注入的节点可能在文档解析过程中被丢弃,
// 所以这里不仅认 id,还要确认它仍挂在当前文档里。
if (!style || !style.isConnected) {
style = document.createElement('style');
style.id = CSS_ID;
host.appendChild(style);
}
style.textContent = buildCss();
return true;
}
// ── 纯净模式 ──────────────────────────────────────────────────────────
// 站点原本是单栏:列表页点标题整页跳到 /topic/N。纯净模式把 .forum-layout
// 改成「左栏帖子列表 / 右栏帖子正文」,点击列表用 fetch 取回帖子页,抽出
// .main-panel 塞进右栏,不整页跳转。两栏各自滚动,外层页面基本不滚。
const READER_VIEW_ID = 'lsb-reader-view';
const READER_BAR_ID = 'lsb-reader-split';
const READER_MIN_W = 260;
const READER_MAX_W = 720;
let readerDirty = false; // 是否改动过站点自己的 DOM(关闭时需刷新还原)
let topicMoved = false; // 帖子页的正文是否已搬进右栏
let listUrl = ''; // 左栏当前对应的列表地址
let listLoading = false;
let readerModalEl = null;
let readerModalBodyEl = null;
let readerModalTitleEl = null;
let readerModalHistoryPushed = false;
let readerModalBaseUrl = '';
let readerModalBaseState = null;
let readerModalBodyOverflow = '';
function urlOf(u) {
try { return new URL(u || location.href, location.href); } catch (e) { return null; }
}
function isTopicPage(u) {
const x = urlOf(u);
return !!x && /^\/topic\/\d+/.test(x.pathname);
}
function isListPage(u) {
const x = urlOf(u);
if (!x) return false;
return x.pathname === '/' || x.pathname === '/index.php' || /^\/forum\/\d+\/?$/.test(x.pathname);
}
/** 只有列表页和帖子页才有阅读模式语义,其它页面(个人主页等)不介入。 */
function readerUsable() { return isTopicPage() || isListPage(); }
function readerOn() { return !!config.reader && !config.readerModal && document.documentElement.classList.contains(READER_CLASS); }
function readerModalOn() { return !!config.readerModal && document.documentElement.classList.contains(READER_MODAL_CLASS); }
function readerLayout() { return document.querySelector('.forum-layout'); }
function readerMain() { return document.querySelector('.forum-main'); }
function readerViewEl() { return document.getElementById(READER_VIEW_ID); }
function readerWidth() {
return Math.max(READER_MIN_W, Math.min(READER_MAX_W, Number(config.readerWidth) || 380));
}
function readerModalWidth() {
return Math.max(50, Math.min(96, Number(config.readerModalWidth) || 80));
}
function buildReaderCss() {
if (!config.reader && !config.readerModal) return '';
const dark = isDarkNow();
const active = dark ? 'rgba(255,255,255,.10)' : 'rgba(0,0,0,.05)';
const hover = dark ? 'rgba(255,255,255,.05)' : 'rgba(0,0,0,.03)';
const w = readerWidth();
const modalWidth = readerModalWidth();
const modalSurface = dark ? '#1b1d22' : '#fff';
// 两栏高度靠 measureReaderHeight() 实测后写进 --lsb-rd-h;这里给个保守初值,
// 避免样式先于测量生效时闪一下过高的两栏。
return `
html.${READER_CLASS}{--lsb-rd-h:calc(100vh - 110px)}
/* 双栏需要横向空间,放开站点 1100px 的居中限制 */
html.${READER_CLASS} .wrap{max-width:none}
html.${READER_CLASS} .home-shell{padding:12px}
/* 站点在有侧栏时用的是 display:grid + grid-template-columns:1fr 230px,
两列写死了;插入第三、第四个子元素会被挤到下一行。这里必须改回 flex,
否则右栏会掉到列表下面(而不是并排)。 */
html.${READER_CLASS} .home-shell .forum-layout,
html.${READER_CLASS} .forum-layout,
html.${READER_CLASS} .forum-layout-has-sidebar{
display:flex!important; grid-template-columns:none!important;
min-height:0;
gap:0; align-items:stretch;
}
html.${READER_CLASS} .forum-main{
flex:0 0 ${w}px; width:${w}px; min-width:0;
min-height:0; max-height:var(--lsb-rd-h);
height:var(--lsb-rd-h); overflow:auto; overscroll-behavior:contain;
padding-right:10px;
}
html.${READER_CLASS} #${READER_VIEW_ID}{
flex:1 1 auto; min-width:0; min-height:0; max-height:var(--lsb-rd-h);
height:var(--lsb-rd-h); overflow:auto; overscroll-behavior:contain;
padding-left:14px;
}
/* 保留滚轮和触控板滚动,只隐藏双栏内部的滚动条外观。 */
html.${READER_CLASS} .forum-main,
html.${READER_CLASS} #${READER_VIEW_ID},
html.${READER_CLASS} .sidebar{
scrollbar-width:none; -ms-overflow-style:none;
}
html.${READER_CLASS} .forum-main::-webkit-scrollbar,
html.${READER_CLASS} #${READER_VIEW_ID}::-webkit-scrollbar,
html.${READER_CLASS} .sidebar::-webkit-scrollbar{
display:none; width:0; height:0;
}
/* 右栏内层 .main-panel 自带 --panel 底色,右栏本体保持透明,避免半透明叠两层 */
html.${READER_CLASS} #${READER_VIEW_ID}>.main-panel{padding:0 2px}
html.${READER_CLASS} #${READER_BAR_ID}{
flex:0 0 7px; align-self:stretch; position:relative; cursor:col-resize;
}
html.${READER_CLASS} #${READER_BAR_ID}::before{
content:""; position:absolute; top:0; bottom:0; left:3px; width:1px;
background:var(--line,rgba(128,128,128,.25));
}
html.${READER_CLASS} #${READER_BAR_ID}:hover::before{
left:2px; width:3px; border-radius:2px; background:var(--brand,#516185);
}
html.${READER_CLASS} .lsb-rd-hint{
display:flex; align-items:center; justify-content:center;
height:100%; color:var(--text-subtle,#888); font-size:14px;
}
/* 窄栏里的列表:整条可点,用底色+左侧色条标出当前打开的帖子 */
html.${READER_CLASS} .forum-main .post-item{
padding:10px 8px; border-radius:6px; cursor:pointer;
}
html.${READER_CLASS} .forum-main .post-item:hover{background:${hover}!important}
html.${READER_CLASS} .forum-main .post-item.lsb-rd-active{
background:${active}!important; box-shadow:inset 2px 0 0 var(--brand,#516185);
}
/* 版块徽章在窄栏里挤掉标题,改用 meta 行里那枚(站点默认把它隐藏了) */
html.${READER_CLASS} .forum-main .post-tag{display:none}
html.${READER_CLASS} .forum-main .post-meta .post-forum-meta{display:inline-flex}
html.${READER_CLASS} .mobile-forum-strip{display:none}
${config.readerHideSidebar
? `html.${READER_CLASS} .sidebar{display:none}`
/* grid 改 flex 后侧栏丢了 230px 的列宽;min-height:0 让内部内容在侧栏内滚动 */
: `html.${READER_CLASS} .sidebar{
flex:0 0 230px; width:230px; min-width:0; min-height:0;
height:var(--lsb-rd-h); max-height:var(--lsb-rd-h);
margin-left:12px; align-self:stretch;
display:block!important; align-content:initial;
overflow-x:hidden; overflow-y:auto!important;
overscroll-behavior:contain;
}
/* 站点侧栏默认使用 Grid,固定高度后会把卡片轨道压扁并由卡片自身隐藏溢出;
改为块流后每张卡片按完整内容排列,滚动由侧栏统一承接。 */
html.${READER_CLASS} .sidebar>.sidebar-card{
height:auto; min-height:0; margin-bottom:12px;
}
html.${READER_CLASS} .sidebar>.sidebar-card:last-child{margin-bottom:0}
`}
/* 弹框阅读沿用帖子页原有内容和回复组件,仅把阅读区域放进独立弹框。 */
html.${READER_MODAL_CLASS} #${READER_MODAL_ID}{
position:fixed; inset:0; z-index:2147483550; display:flex;
align-items:center; justify-content:center; padding:18px;
background:rgba(0,0,0,.52);
}
html.${READER_MODAL_CLASS} #${READER_MODAL_ID}[hidden]{display:none!important}
html.${READER_MODAL_CLASS} #${READER_MODAL_ID} .lsb-reader-modal-dialog{
display:flex; flex-direction:column; width:min(${modalWidth}vw,calc(100vw - 36px));
height:min(88vh,calc(100vh - 36px)); min-height:280px;
overflow:hidden; border:1px solid var(--line,rgba(128,128,128,.25));
border-radius:8px; background:${modalSurface}!important; color:var(--text,#333);
box-shadow:0 18px 60px rgba(0,0,0,.34);
--panel:${modalSurface}!important;
backdrop-filter:none!important; -webkit-backdrop-filter:none!important;
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-head{
display:flex; align-items:center; gap:10px; flex:0 0 46px; min-width:0;
padding:0 12px 0 16px; border-bottom:1px solid var(--line,rgba(128,128,128,.25));
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-title{
min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
font-size:15px; font-weight:600;
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-close{
display:inline-flex; align-items:center; justify-content:center; flex:0 0 30px;
width:30px; height:30px; margin-left:auto; padding:0; border:0; border-radius:5px;
background:transparent; color:var(--text-subtle,#888); cursor:pointer;
font-size:22px; line-height:1;
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-close:hover{background:rgba(128,128,128,.12);color:var(--text,#333)}
html.${READER_MODAL_CLASS} .lsb-reader-modal-body{
min-height:0; flex:1 1 auto; overflow:auto; overscroll-behavior:contain;
padding:0 12px 18px; background:${modalSurface}!important;
scrollbar-width:none; -ms-overflow-style:none;
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-body::-webkit-scrollbar{display:none;width:0;height:0}
/* 弹框内帖子面板使用固定主题底色,不再继承全局面板透明度和毛玻璃效果。 */
html.${READER_MODAL_CLASS} .lsb-reader-modal-body>.main-panel{
padding:0 2px; background:${modalSurface}!important;
backdrop-filter:none!important; -webkit-backdrop-filter:none!important;
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-loading,
html.${READER_MODAL_CLASS} .lsb-reader-modal-error{
display:flex; align-items:center; justify-content:center; height:100%;
color:var(--text-subtle,#888); font-size:14px;
}
html.${READER_MODAL_CLASS} .lsb-reader-modal-error{color:#b42318}
`;
}
function applyReaderStyle() {
const host = mountPoint();
if (!host) return;
let st = document.getElementById(READER_CSS_ID);
if (!st || !st.isConnected) {
st = document.createElement('style');
st.id = READER_CSS_ID;
host.appendChild(st);
}
st.textContent = buildReaderCss();
}
/**
* 两栏高度要精确到「刚好占满视口剩余空间」,否则外层页面会多出一截滚动,
* 双栏跟着一起晃。顶栏高度、各层 padding、页脚高度随版本而变,写死数值不靠谱,
* 也不能只看「有没有溢出」——页脚会把空隙填掉,那样会在两个值之间来回摆。
* 这里直接量出「两栏之外的固定占位」,再用视口高度减掉它,一次就到位。
*/
function measureReaderHeight() {
const layout = readerLayout();
const root = document.documentElement;
if (!layout || !readerOn()) {
root.style.removeProperty('--lsb-rd-h');
return;
}
for (let i = 0; i < 3; i++) {
const main = readerMain();
const h = main ? main.getBoundingClientRect().height : 0;
// 整篇文档高度 − 两栏当前高度 = 顶栏 + 页脚 + 各层 padding 的总占位
const docH = document.body.getBoundingClientRect().bottom + window.scrollY;
// 留 1px 余量:各层高度常带小数,贴到分毫不差反而会多出 1px 滚动条
const target = Math.max(240, Math.floor(window.innerHeight - (docH - h)) - 1);
if (Math.abs(target - h) < 1) break;
root.style.setProperty('--lsb-rd-h', target + 'px');
}
}
function readerHint(msg) {
const view = readerViewEl();
if (!view) return;
view.textContent = '';
const d = document.createElement('div');
d.className = 'lsb-rd-hint';
d.textContent = msg || '从左侧选择一个帖子';
view.appendChild(d);
}
function ensureReaderModal() {
if (readerModalEl && readerModalEl.isConnected) return readerModalEl;
const modal = document.createElement('div');
modal.id = READER_MODAL_ID;
modal.hidden = true;
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('tabindex', '-1');
modal.innerHTML = `
<div class="lsb-reader-modal-dialog">
<div class="lsb-reader-modal-head">
<div class="lsb-reader-modal-title">帖子阅读</div>
<button type="button" class="lsb-reader-modal-close" aria-label="关闭弹框">×</button>
</div>
<div class="lsb-reader-modal-body"></div>
</div>`;
modal.addEventListener('click', event => {
if (event.target === modal) closeReaderModal(true);
});
modal.querySelector('.lsb-reader-modal-close')?.addEventListener('click', () => closeReaderModal(true));
document.body.appendChild(modal);
readerModalEl = modal;
readerModalBodyEl = modal.querySelector('.lsb-reader-modal-body');
readerModalTitleEl = modal.querySelector('.lsb-reader-modal-title');
return modal;
}
function closeReaderModal(restoreHistory) {
const wasOpen = !!(readerModalEl && !readerModalEl.hidden);
if (restoreHistory && readerModalHistoryPushed && readerModalBaseUrl) {
// 从列表点进弹框时只保留一个历史入口,关闭后恢复列表地址,避免留下无意义的帖子历史记录。
history.replaceState(readerModalBaseState, '', readerModalBaseUrl);
}
readerModalHistoryPushed = false;
readerModalBaseUrl = '';
readerModalBaseState = null;
if (readerModalEl) readerModalEl.hidden = true;
if (readerModalBodyEl) readerModalBodyEl.textContent = '';
if (readerModalTitleEl) readerModalTitleEl.textContent = '帖子阅读';
if (wasOpen && document.body) document.body.style.overflow = readerModalBodyOverflow;
readerModalBodyOverflow = '';
if (readerModalDocumentTitle) {
document.title = readerModalDocumentTitle;
readerModalDocumentTitle = '';
}
}
let readerModalLoadToken = 0;
let readerModalDocumentTitle = '';
async function openReaderModal(url, push) {
const modal = ensureReaderModal();
const target = urlOf(url);
if (!target || !readerModalBodyEl) return;
const firstOpen = modal.hidden;
if (firstOpen) {
readerModalDocumentTitle = document.title;
readerModalBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
}
modal.hidden = false;
if (push && !readerModalHistoryPushed) {
readerModalBaseUrl = location.href;
readerModalBaseState = history.state;
history.pushState({ lsbReaderModal: 1 }, '', target.href);
readerModalHistoryPushed = true;
} else if (push && readerModalHistoryPushed) {
// 弹框内切换帖子只替换当前阅读地址,关闭弹框时始终回到进入弹框前的列表地址。
history.replaceState({ lsbReaderModal: 1 }, '', target.href);
} else if (readerModalHistoryPushed) {
history.replaceState({ lsbReaderModal: 1 }, '', target.href);
}
readerModalTitleEl.textContent = '加载中…';
readerModalBodyEl.innerHTML = '<div class="lsb-reader-modal-loading">加载中…</div>';
readerModalEl.focus();
const token = ++readerModalLoadToken;
try {
const doc = await fetchDoc(target.href);
if (token !== readerModalLoadToken || modal.hidden) return;
const panel = pickPanel(doc);
readerModalBodyEl.textContent = '';
readerModalBodyEl.appendChild(document.importNode(panel, true));
enhanceReplyFeatures(readerModalBodyEl);
readerModalBodyEl.scrollTop = 0;
const title = doc.querySelector('title')?.textContent?.trim();
readerModalTitleEl.textContent = title || '帖子阅读';
if (title) document.title = title;
} catch (e) {
if (token !== readerModalLoadToken || modal.hidden) return;
readerModalTitleEl.textContent = '帖子阅读';
readerModalBodyEl.textContent = '';
const error = document.createElement('div');
error.className = 'lsb-reader-modal-error';
error.textContent = '加载失败:' + String(e.message || e);
readerModalBodyEl.appendChild(error);
toast('帖子加载失败:' + e.message, true);
}
}
function applyReaderModal() {
const on = !!config.readerModal && readerUsable();
document.documentElement.classList.toggle(READER_MODAL_CLASS, on);
if (!on) {
closeReaderModal(true);
return;
}
if (isTopicPage() && (!readerModalEl || readerModalEl.hidden)) {
openReaderModal(location.pathname + location.search, false);
}
}
function replyTextarea() {
return document.querySelector('#' + READER_MODAL_ID + ' #reply textarea[name="body"]')
|| document.querySelector('#' + READER_VIEW_ID + ' #reply textarea[name="body"]')
|| document.querySelector('#reply textarea[name="body"]');
}
function insertReplyText(textarea, text) {
if (!textarea || !text) return;
const start = Number.isInteger(textarea.selectionStart) ? textarea.selectionStart : textarea.value.length;
const end = Number.isInteger(textarea.selectionEnd) ? textarea.selectionEnd : start;
textarea.focus();
textarea.setRangeText(text, start, end, 'end');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
// imgur.la API 使用 multipart/form-data 上传二进制 source,并通过 X-API-Key 认证。
function uploadImgurImage(file) {
return new Promise((resolve, reject) => {
const apiKey = String(config.imgurApiKey || '').trim();
if (!apiKey) {
reject(new Error('请先在插件设置中填写 imgur.la API Key'));
return;
}
const form = new FormData();
form.append('source', file, file.name);
form.append('format', 'json');
GM_xmlhttpRequest({
method: 'POST',
url: IMGUR_API_URL,
headers: { 'X-API-Key': apiKey },
data: form,
timeout: 60000,
onload: response => {
let data;
try {
data = JSON.parse(response.responseText || '{}');
} catch (e) {
reject(new Error('图床返回了无法解析的结果'));
return;
}
const url = data.image?.url || data.url;
if (response.status >= 200 && response.status < 300 && url) {
resolve(url);
return;
}
const message = data.error?.message || data.status_txt || data.success?.message;
reject(new Error(message || ('上传失败(HTTP ' + response.status + ')')));
},
ontimeout: () => reject(new Error('上传超时,请稍后重试')),
onerror: () => reject(new Error('无法连接 imgur.la')),
});
});
}
// 多图按选择顺序逐张上传;每张成功后立即插入 Markdown,避免后续失败导致已上传链接丢失。
async function uploadReplyImages(files, button, targetTextarea) {
const list = Array.from(files || []).filter(file => /^image\//.test(file.type));
const textarea = targetTextarea || replyTextarea();
if (!list.length || !textarea) return;
if (button) {
button.disabled = true;
button.dataset.uploading = '1';
}
let successCount = 0;
try {
for (const file of list) {
try {
const url = await uploadImgurImage(file);
insertReplyText(textarea, `![${file.name.replace(/[\[\]]/g, '')}](${url})\n`);
successCount += 1;
} catch (e) {
toast('「' + file.name + '」上传失败:' + e.message, true);
}
}
if (successCount) toast('已上传 ' + successCount + ' 张图片并插入回复');
} finally {
if (button) {
button.disabled = false;
delete button.dataset.uploading;
}
}
}
function positionReplyEmojiPicker() {
if (!replyEmojiPicker || !replyEmojiButton) return;
const rect = replyEmojiButton.getBoundingClientRect();
const width = replyEmojiPicker.offsetWidth || 280;
const height = replyEmojiPicker.offsetHeight || 180;
replyEmojiPicker.style.left = Math.max(8, Math.min(window.innerWidth - width - 8, rect.left)) + 'px';
replyEmojiPicker.style.top = Math.max(8, rect.top - height - 8) + 'px';
}
function closeReplyEmojiPicker() {
if (replyEmojiPicker) replyEmojiPicker.hidden = true;
replyEmojiButton = null;
}
// 表情面板只创建一次,后续切换帖子时复用同一组表情按钮,避免动态右栏重复挂载。
function createReplyEmojiPicker() {
if (replyEmojiPicker) return replyEmojiPicker;
const picker = document.createElement('div');
picker.className = 'lsb-emoji-picker';
picker.hidden = true;
picker.setAttribute('aria-label', '选择表情');
REPLY_EMOJIS.forEach(emoji => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = emoji;
button.dataset.emoji = emoji;
button.setAttribute('aria-label', emoji);
picker.appendChild(button);
});
document.body.appendChild(picker);
replyEmojiPicker = picker;
return picker;
}
function enhanceReplyFeatures(root) {
const scope = root instanceof Element ? root : document;
const textarea = scope.querySelector?.('#reply textarea[name="body"]')
|| (scope.matches?.('#reply textarea[name="body"]') ? scope : null);
const panel = textarea?.closest('#reply');
const head = panel?.querySelector('.reply-panel-head');
if (!textarea || !head || head.querySelector('.lsb-reply-head-tools')) return;
// 回复辅助入口紧跟标题,正文编辑区只保留站点原有的 Markdown 工具栏和输入框。
const tools = document.createElement('div');
tools.className = 'lsb-reply-head-tools';
const emojiButton = document.createElement('button');
emojiButton.type = 'button';
emojiButton.className = 'lsb-reply-emoji';
emojiButton.textContent = '😀';
emojiButton.dataset.replyEmoji = '1';
emojiButton.title = '选择表情';
emojiButton.setAttribute('aria-label', '选择表情');
const uploadButton = document.createElement('button');
uploadButton.type = 'button';
uploadButton.className = 'lsb-reply-upload';
uploadButton.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.75" y="4" width="13.5" height="12.5" rx="1.8"></rect><circle cx="6.7" cy="8" r="1.15"></circle><path d="m4.5 14 3.05-3.05a1.25 1.25 0 0 1 1.77 0l1.45 1.45 1.25-1.25a1.25 1.25 0 0 1 1.77 0l.46.46"></path><path d="M17.5 11.5v7"></path><path d="m14.8 14.2 2.7-2.7 2.7 2.7"></path><path d="M14.8 20h5.4"></path></svg>';
uploadButton.dataset.replyUpload = '1';
uploadButton.title = '上传图片到 imgur.la';
uploadButton.setAttribute('aria-label', '上传图片到 imgur.la');
const uploadInput = document.createElement('input');
uploadInput.type = 'file';
uploadInput.accept = 'image/*';
uploadInput.multiple = true;
uploadInput.hidden = true;
uploadInput.dataset.replyUploadFile = '1';
document.body.appendChild(uploadInput);
uploadButton.addEventListener('click', event => {
event.preventDefault();
uploadInput.click();
});
uploadInput.addEventListener('change', () => {
uploadReplyImages(uploadInput.files, uploadButton);
uploadInput.value = '';
});
const presetSelect = document.createElement('select');
presetSelect.className = 'lsb-reply-preset';
presetSelect.dataset.replyPreset = '1';
presetSelect.setAttribute('aria-label', '快捷回复');
const placeholderOption = document.createElement('option');
placeholderOption.value = '';
placeholderOption.textContent = '快捷回复';
presetSelect.appendChild(placeholderOption);
String(config.replyPresets || '').split(/\r?\n/).map(text => text.trim()).filter(Boolean).forEach(text => {
const option = document.createElement('option');
option.value = text;
option.textContent = text;
presetSelect.appendChild(option);
});
const manageOption = document.createElement('option');
manageOption.value = '__manage__';
manageOption.textContent = '管理预设…';
presetSelect.appendChild(manageOption);
// 首项仅作为快捷回复标题,保持空值,避免初始化时误填入第一条真实预设。
presetSelect.selectedIndex = 0;
tools.append(emojiButton, uploadButton, presetSelect);
head.querySelector('h3')?.after(tools);
presetSelect.closest('html')?.classList.toggle('lsb-reply-dark', isDarkNow());
}
function bindReplyFeatures() {
if (replyFeaturesBound) return;
replyFeaturesBound = true;
// 事件统一委托到 document,保证纯净模式 fetch 导入的回复区也能立即使用预设和表情。
document.addEventListener('click', event => {
const target = event.target instanceof Element ? event.target : null;
const emoji = target?.closest('[data-emoji]');
if (emoji && replyEmojiPicker?.contains(emoji)) {
event.preventDefault();
insertReplyText(replyTextarea(), emoji.dataset.emoji || '');
closeReplyEmojiPicker();
return;
}
const emojiButton = target?.closest('[data-reply-emoji]');
if (emojiButton) {
event.preventDefault();
const picker = createReplyEmojiPicker();
if (picker.hidden || replyEmojiButton !== emojiButton) {
replyEmojiButton = emojiButton;
picker.hidden = false;
positionReplyEmojiPicker();
} else {
closeReplyEmojiPicker();
}
return;
}
if (replyEmojiPicker && !replyEmojiPicker.contains(target) && !target?.closest('[data-reply-emoji]')) {
closeReplyEmojiPicker();
}
}, true);
document.addEventListener('change', event => {
const select = event.target instanceof Element ? event.target.closest('[data-reply-preset]') : null;
if (!select || !select.value) return;
if (select.value === '__manage__') {
select.value = '';
togglePanel(true);
const presets = panelEl?.querySelector('[data-role=reply-presets]');
presets?.focus();
return;
}
const textarea = replyTextarea();
if (!textarea) return;
// 预设话术代表完整回复,选择后替换当前草稿,避免误把多条模板拼接到一起。
textarea.value = select.value;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
});
document.addEventListener('paste', event => {
const textarea = event.target instanceof Element
? event.target.closest('#reply textarea[name="body"]')
: null;
if (!textarea || !event.clipboardData) return;
const itemFiles = Array.from(event.clipboardData.items || [])
.filter(item => item.kind === 'file' && /^image\//.test(item.type))
.map(item => item.getAsFile())
.filter(Boolean);
const imageFiles = itemFiles.length
? itemFiles
: Array.from(event.clipboardData.files || []).filter(file => /^image\//.test(file.type));
if (!imageFiles.length) return;
// 剪贴板含图片时由图床上传接管粘贴,避免浏览器同时写入图片对应的本地路径或 HTML 文本。
event.preventDefault();
toast('正在上传剪贴板图片…');
uploadReplyImages(imageFiles, null, textarea);
});
window.addEventListener('resize', positionReplyEmojiPicker);
window.addEventListener('scroll', positionReplyEmojiPicker, true);
if (window.MutationObserver && document.body) {
new MutationObserver(mutations => mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) enhanceReplyFeatures(node);
});
})).observe(document.body, { childList: true, subtree: true });
}
}
/** 建左右两栏的骨架:分隔条与右栏紧跟在 .forum-main 之后,站点侧栏留在最右 */
function ensureReaderShell() {
const layout = readerLayout();
const main = readerMain();
if (!layout || !main) return null;
let bar = document.getElementById(READER_BAR_ID);
if (!bar || !bar.isConnected) {
bar = document.createElement('div');
bar.id = READER_BAR_ID;
bar.title = '拖动调整左栏宽度';
bindSplitter(bar);
}
let view = readerViewEl();
if (!view || !view.isConnected) {
view = document.createElement('div');
view.id = READER_VIEW_ID;
}
if (main.nextElementSibling !== bar) main.after(bar);
if (bar.nextElementSibling !== view) bar.after(view);
return view;
}
async function fetchDoc(url) {
// 不带 X-Requested-With:站点对 AJAX 请求会返回 JSON 片段,这里要的是整页 HTML
const res = await fetch(urlOf(url).href, { credentials: 'same-origin' });
if (!res.ok) throw new Error('HTTP ' + res.status);
return new DOMParser().parseFromString(await res.text(), 'text/html');
}
function pickPanel(doc) {
const p = doc.querySelector('.forum-main .main-panel') || doc.querySelector('.main-panel');
if (!p) throw new Error('页面结构与预期不符');
return p;
}
/** 高亮左栏中对应的条目 */
function markActive(pathname) {
const main = readerMain();
if (!main) return;
main.querySelectorAll('.post-item.lsb-rd-active').forEach(n => n.classList.remove('lsb-rd-active'));
if (!pathname) return;
main.querySelectorAll('.post-item a.post-title').forEach(a => {
const u = urlOf(a.getAttribute('href'));
if (u && u.pathname === pathname) a.closest('.post-item').classList.add('lsb-rd-active');
});
}
async function openTopic(url, push) {
const view = ensureReaderShell();
if (!view) return;
readerHint('加载中…');
try {
const doc = await fetchDoc(url);
const panel = pickPanel(doc);
view.textContent = '';
view.appendChild(document.importNode(panel, true));
enhanceReplyFeatures(view);
view.scrollTop = 0;
const t = doc.querySelector('title');
if (t) document.title = t.textContent;
if (push !== false) history.pushState({ lsbReader: 1 }, '', urlOf(url).href);
markActive(urlOf(url).pathname);
} catch (e) {
readerHint('加载失败:' + e.message);
toast('帖子加载失败:' + e.message, true);
}
}
async function loadList(url, push) {
const main = readerMain();
if (!main || listLoading) return;
listLoading = true;
try {
const doc = await fetchDoc(url);
const panel = pickPanel(doc);
main.textContent = '';
main.appendChild(document.importNode(panel, true));
main.scrollTop = 0;
listUrl = urlOf(url).href;
readerDirty = true; // 左栏内容已不是服务端这次渲染的那份
if (push) history.pushState({ lsbReader: 1 }, '', listUrl);
markActive(isTopicPage() ? urlOf().pathname : null);
measureReaderHeight();
} catch (e) {
toast('列表加载失败:' + e.message, true);
} finally {
listLoading = false;
}
}
/** 帖子页直接打开时:把正文搬到右栏,左栏补上所属版块的列表 */
function listUrlForTopic() {
const a = document.querySelector('#' + READER_VIEW_ID + ' .breadcrumb a[href^="/forum/"]');
return a ? a.getAttribute('href') : '/';
}
function applyReader() {
const on = !!config.reader && !config.readerModal && readerUsable();
document.documentElement.classList.toggle(READER_CLASS, on);
applyReaderStyle();
if (!on) {
teardownReader();
return;
}
const view = ensureReaderShell();
if (!view) return;
if (isTopicPage() && !topicMoved) {
const panel = document.querySelector('.forum-main .main-panel');
if (panel) {
view.textContent = '';
view.appendChild(panel);
enhanceReplyFeatures(view);
topicMoved = true;
readerDirty = true;
loadList(listUrlForTopic());
}
} else if (!view.firstChild) {
readerHint();
listUrl = location.href;
}
markActive(isTopicPage() ? urlOf().pathname : null);
measureReaderHeight();
}
function teardownReader() {
const view = readerViewEl();
const bar = document.getElementById(READER_BAR_ID);
if (view) view.remove();
if (bar) bar.remove();
document.documentElement.style.removeProperty('--lsb-rd-h');
document.querySelectorAll('.post-item.lsb-rd-active')
.forEach(n => n.classList.remove('lsb-rd-active'));
// 搬过 DOM 或换过左栏内容,页面已经不是服务端渲染的那份,只能刷新还原
if (readerDirty) {
readerDirty = false;
topicMoved = false;
location.reload();
}
}
function bindSplitter(bar) {
bar.addEventListener('mousedown', e => {
if (e.button !== 0) return;
e.preventDefault();
const main = readerMain();
if (!main) return;
const startX = e.clientX;
const startW = main.getBoundingClientRect().width;
const move = ev => {
config.readerWidth = Math.round(
Math.max(READER_MIN_W, Math.min(READER_MAX_W, startW + ev.clientX - startX)));
applyReaderStyle();
};
const up = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
document.body.style.userSelect = '';
saveConfig(config);
measureReaderHeight();
if (panelEl && panelEl.classList.contains('lsb-open')) syncPanel();
};
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
});
}
function bindReaderNav() {
// 用捕获阶段:要在站点自己的委托处理之前拦下导航
document.addEventListener('click', e => {
const modalOn = readerModalOn();
if ((!readerOn() && !modalOn) || e.defaultPrevented) return;
// 新标签页 / 中键等原生行为一律放过
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = e.target.closest && e.target.closest('a[href]');
if (!a || (a.target && a.target !== '_self')) return;
const main = readerMain();
const view = readerViewEl();
const modal = readerModalEl;
const inModal = modalOn && modal && !modal.hidden && modal.contains(a);
const inList = !inModal && main && main.contains(a);
const inView = !inModal && view && view.contains(a);
if (!inList && !inView && !inModal) return;
const u = urlOf(a.getAttribute('href'));
if (!u || u.origin !== location.origin) return;
// 引用回复由站点脚本通过 .quote-reply 事件填充回复框;纯净模式不能把它当作楼层导航拦截。
if ((inView || inModal) && a.matches('.quote-reply')) return;
if (isTopicPage(u.href)) {
// 右栏里指向本帖的楼层永久链接:原地滚到该楼层,不必重新请求
if ((inView || inModal) && u.pathname === urlOf().pathname && /(^|&)floor=/.test(u.search.slice(1))) {
e.preventDefault();
const entry = a.closest('.post-entry');
if (entry) entry.scrollIntoView({ block: 'start' });
return;
}
e.preventDefault();
if (modalOn) openReaderModal(u.pathname + u.search, true);
else openTopic(u.pathname + u.search);
return;
}
if (readerOn() && inList && isListPage(u.href)) {
e.preventDefault();
// 右栏正开着帖子时不动地址栏(那里应保留帖子链接,方便刷新/分享);
// 右栏是空态时换列表则同步地址,刷新后还能回到同一个列表。
loadList(u.pathname + u.search, !isTopicPage());
}
}, true);
window.addEventListener('popstate', () => {
if (readerModalOn()) {
if (isTopicPage()) openReaderModal(location.pathname + location.search, false);
else closeReaderModal(false);
return;
}
if (!readerOn()) return;
if (isTopicPage()) {
openTopic(location.pathname + location.search, false);
} else {
readerHint();
markActive(null);
if (isListPage() && listUrl && location.href !== listUrl) loadList(location.href);
}
});
// 视口变化后两栏高度要重新贴合,否则会多出或缺一截
let rzTimer = null;
window.addEventListener('resize', () => {
if (!readerOn()) return;
clearTimeout(rzTimer);
rzTimer = setTimeout(measureReaderHeight, 120);
});
window.addEventListener('keydown', e => {
if (e.key === 'Escape' && readerModalOn() && readerModalEl && !readerModalEl.hidden) {
e.preventDefault();
closeReaderModal(true);
}
});
}
// ── 轮播 ──────────────────────────────────────────────────────────────
function stopCarousel() {
if (carouselTimer) {
clearInterval(carouselTimer);
carouselTimer = null;
}
}
function nextIndex() {
if (images.length < 2) return config.activeIndex;
if (config.shuffle) {
let i = config.activeIndex;
while (i === config.activeIndex) i = Math.floor(Math.random() * images.length);
return i;
}
return (config.activeIndex + 1) % images.length;
}
function startCarousel() {
stopCarousel();
if (!config.enabled || !config.carousel || images.length < 2) return;
const sec = Math.max(5, Number(config.interval) || 60);
carouselTimer = setInterval(() => {
config.activeIndex = nextIndex();
saveConfig(config);
applyStyle();
syncPanel();
}, sec * 1000);
}
function refresh() {
applyStyle();
applyReaderStyle();
syncPanelTheme();
startCarousel();
}
// ── 图片处理 ──────────────────────────────────────────────────────────
function nextId() {
return 'img_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}
/** 本地文件 → 等比压缩后的 data URL,避免超大 base64 塞满油猴存储 */
function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('读取文件失败'));
reader.onload = () => {
const raw = String(reader.result);
const im = new Image();
im.onerror = () => reject(new Error('图片解码失败'));
im.onload = () => {
const scale = Math.min(1, MAX_EDGE / Math.max(im.width, im.height));
if (scale >= 1 && raw.length < 1.5e6) {
resolve(raw); // 尺寸和体积都不大,保留原图(含 PNG 透明通道)
return;
}
const cv = document.createElement('canvas');
cv.width = Math.round(im.width * scale);
cv.height = Math.round(im.height * scale);
const ctx = cv.getContext('2d');
// 压成 JPEG 会丢透明通道,先铺白底避免变黑块
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, cv.width, cv.height);
ctx.drawImage(im, 0, 0, cv.width, cv.height);
resolve(cv.toDataURL('image/jpeg', JPEG_QUALITY));
};
im.src = raw;
};
reader.readAsDataURL(file);
});
}
async function addLocalFiles(fileList) {
const files = Array.from(fileList || []).filter(f => /^image\//.test(f.type));
if (!files.length) return 0;
for (const f of files) {
try {
const src = await fileToDataUrl(f);
images.push({ id: nextId(), name: f.name, type: 'local', src });
} catch (e) {
toast('「' + f.name + '」添加失败:' + e.message, true);
}
}
saveImages(images);
return files.length;
}
function addUrl(url) {
const u = String(url || '').trim();
if (!/^https?:\/\//i.test(u)) {
toast('请填写 http/https 开头的图片直链', true);
return false;
}
images.push({ id: nextId(), name: u.split('/').pop().slice(0, 40) || u, type: 'url', src: u });
saveImages(images);
return true;
}
function removeImage(id) {
const i = images.findIndex(x => x.id === id);
if (i < 0) return;
images.splice(i, 1);
if (config.activeIndex >= images.length) config.activeIndex = Math.max(0, images.length - 1);
else if (config.activeIndex > i) config.activeIndex -= 1;
saveImages(images);
saveConfig(config);
}
// ── 设置面板样式 ──────────────────────────────────────────────────────
// 面板样式全部用 #lsb-bg-settings 前缀 + 自有变量,防止被站点 CSS 或本脚本
// 注入的半透明规则影响(设置面板必须始终清晰可读)。
// 明暗两套色值挂在 .lsb-dark 上切换,不读站点变量——站点变量随时可能被
// themes 插件改写,面板必须自成一体。
const PANEL_CSS = `
/* 直接隐藏站点底部运行信息栏,避免页面底部占用额外高度。 */
footer.footer{display:none!important}
#${PANEL_ID},#lsb-bg-toggle,#${NAV_TOGGLE_ID}{
--lsb-surface:#fff;
--lsb-surface-2:#fafafa;
--lsb-surface-3:#f0f0f0;
--lsb-fg:#222;
--lsb-fg-muted:#666;
--lsb-fg-subtle:#999;
--lsb-fg-faint:#aaa;
--lsb-border:#e2e2e2;
--lsb-border-soft:#eee;
--lsb-border-input:#ddd;
--lsb-accent:#2ecc71;
--lsb-accent-hover:#27ae60;
--lsb-accent-soft:#f3fdf7;
--lsb-thumb-bg:#f2f2f2;
}
#${PANEL_ID}.lsb-dark,#lsb-bg-toggle.lsb-dark,#${NAV_TOGGLE_ID}.lsb-dark{
--lsb-surface:#22242a;
--lsb-surface-2:#2a2d34;
--lsb-surface-3:#34383f;
--lsb-fg:#e8eaed;
--lsb-fg-muted:#a8adb5;
--lsb-fg-subtle:#868c95;
--lsb-fg-faint:#6f757e;
--lsb-border:#3a3e46;
--lsb-border-soft:#31353c;
--lsb-border-input:#43484f;
--lsb-accent-soft:#1d3327;
--lsb-thumb-bg:#2e323a;
}
#${PANEL_ID}{
position:fixed; z-index:2147483600; top:58px; right:12px;
width:min(340px,calc(100vw - 24px)); max-height:calc(100vh - 70px);
display:none; flex-direction:column; overflow:hidden;
background:var(--lsb-surface)!important; color:var(--lsb-fg);
border:1px solid var(--lsb-border); border-radius:10px;
box-shadow:0 18px 48px rgba(0,0,0,.28);
font:13px/1.5 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei","Segoe UI",Arial,sans-serif;
backdrop-filter:none!important; -webkit-backdrop-filter:none!important;
/* 让面板内的原生控件(滑块轨道、下拉箭头、勾选框)跟着明暗走 */
color-scheme:light;
}
#${PANEL_ID}.lsb-dark{color-scheme:dark}
#${PANEL_ID}.lsb-open{display:flex}
#${PANEL_ID} *{box-sizing:border-box}
#${PANEL_ID} .lsb-head{
display:flex; align-items:center; gap:8px; padding:10px 12px;
border-bottom:1px solid var(--lsb-border-soft); font-weight:600;
}
#${PANEL_ID} .lsb-head .lsb-back{
display:none; width:24px; height:24px; padding:0; border:0;
background:transparent; color:var(--lsb-fg-muted); cursor:pointer;
font-size:18px; line-height:1;
}
#${PANEL_ID} .lsb-head .lsb-back.lsb-visible{display:inline-flex; align-items:center; justify-content:center}
#${PANEL_ID} .lsb-head .lsb-spacer{flex:1}
#${PANEL_ID} .lsb-x{
border:none; background:transparent; cursor:pointer; font-size:18px;
line-height:1; color:var(--lsb-fg-subtle); padding:2px 4px;
}
#${PANEL_ID} .lsb-body{padding:12px; overflow:auto; display:grid; gap:12px}
#${PANEL_ID} .lsb-view{display:grid; gap:12px}
#${PANEL_ID} .lsb-view[hidden]{display:none}
#${PANEL_ID} .lsb-menu-item{
display:flex; align-items:center; width:100%; min-height:42px; padding:0 12px;
border:1px solid var(--lsb-border); border-radius:6px;
background:var(--lsb-surface-2); color:var(--lsb-fg); cursor:pointer;
text-align:left; font:inherit; transition:background .15s ease, border-color .15s ease;
}
#${PANEL_ID} .lsb-menu-item:hover{background:var(--lsb-surface-3); border-color:var(--lsb-accent)}
#${PANEL_ID} .lsb-menu-item .lsb-menu-desc{margin-left:auto; color:var(--lsb-fg-subtle); font-size:12px}
#${PANEL_ID} .lsb-menu-item .lsb-menu-arrow{margin-left:10px; color:var(--lsb-fg-subtle); font-size:18px; line-height:1}
#${PANEL_ID} .lsb-row{display:flex; align-items:center; gap:8px}
#${PANEL_ID} .lsb-row>label{flex:0 0 68px; color:var(--lsb-fg-muted)}
#${PANEL_ID} .lsb-row input[type=range]{flex:1; min-width:0; accent-color:var(--lsb-accent)}
#${PANEL_ID} .lsb-val{
flex:0 0 42px; text-align:right; color:var(--lsb-fg-subtle);
font-variant-numeric:tabular-nums;
}
#${PANEL_ID} input[type=text],#${PANEL_ID} input[type=number],#${PANEL_ID} select{
flex:1; min-width:0; height:30px; padding:0 8px;
border:1px solid var(--lsb-border-input)!important; border-radius:5px;
background:var(--lsb-surface)!important; color:var(--lsb-fg)!important;
}
#${PANEL_ID} .lsb-presets{
width:100%; min-height:82px; resize:vertical; padding:7px 8px;
border:1px solid var(--lsb-border-input)!important; border-radius:5px;
background:var(--lsb-surface)!important; color:var(--lsb-fg)!important;
font:inherit; line-height:1.5;
}
#${PANEL_ID} input::placeholder{color:var(--lsb-fg-faint)}
#${PANEL_ID} .lsb-btn{
height:30px; padding:0 12px; border:1px solid var(--lsb-border-input); border-radius:5px;
background:var(--lsb-surface-2); color:var(--lsb-fg); cursor:pointer; white-space:nowrap;
}
#${PANEL_ID} .lsb-btn:hover{background:var(--lsb-surface-3)}
#${PANEL_ID} .lsb-btn.lsb-primary{
background:var(--lsb-accent); border-color:var(--lsb-accent); color:#fff;
}
#${PANEL_ID} .lsb-btn.lsb-primary:hover{background:var(--lsb-accent-hover)}
#${PANEL_ID} .lsb-drop{
border:1px dashed var(--lsb-border-input); border-radius:8px; padding:14px;
text-align:center; color:var(--lsb-fg-subtle); cursor:pointer; transition:.15s ease;
}
#${PANEL_ID} .lsb-drop.lsb-over{
border-color:var(--lsb-accent); background:var(--lsb-accent-soft); color:var(--lsb-accent);
}
#${PANEL_ID} .lsb-grid{display:grid; grid-template-columns:repeat(3,1fr); gap:6px}
#${PANEL_ID} .lsb-thumb{
position:relative; padding-top:64%; border-radius:6px; overflow:hidden;
border:2px solid transparent; cursor:pointer;
background:var(--lsb-thumb-bg) center/cover no-repeat;
}
#${PANEL_ID} .lsb-thumb.lsb-active{border-color:var(--lsb-accent)}
#${PANEL_ID} .lsb-thumb .lsb-del{
position:absolute; top:2px; right:2px; width:18px; height:18px; border:none;
border-radius:50%; background:rgba(0,0,0,.55); color:#fff; font-size:12px;
line-height:18px; padding:0; cursor:pointer; opacity:0; transition:.15s;
}
#${PANEL_ID} .lsb-thumb:hover .lsb-del{opacity:1}
#${PANEL_ID} .lsb-sec{
border-top:1px solid var(--lsb-border-soft); padding-top:10px;
color:var(--lsb-fg-subtle); font-size:12px;
}
#${PANEL_ID} .lsb-empty{color:var(--lsb-fg-faint); text-align:center; padding:8px 0}
#${PANEL_ID} .lsb-tip{color:var(--lsb-fg-faint); font-size:12px}
/* 面板内的勾选框不走站点自绘样式,用原生外观即可(color-scheme 已处理明暗) */
#${PANEL_ID} input[type=checkbox]{accent-color:var(--lsb-accent)}
#lsb-bg-toggle{
position:fixed; z-index:2147483600; right:12px; bottom:58px;
width:38px; height:38px; border-radius:50%;
border:1px solid var(--lsb-border);
background:#fff!important; color:var(--lsb-accent);
cursor:pointer; font-size:17px; line-height:1;
box-shadow:0 6px 18px rgba(0,0,0,.22);
backdrop-filter:none!important; -webkit-backdrop-filter:none!important;
}
#lsb-bg-toggle:hover{color:var(--lsb-accent); background:#f5fff8!important}
#lsb-bg-toggle svg{width:28px; height:20px; display:block}
#${NAV_TOGGLE_ID}{
grid-column:4; grid-row:1; justify-self:end;
display:inline-flex; align-items:center; justify-content:center;
width:30px; height:30px; padding:0; border:1px solid var(--lsb-border);
border-radius:var(--radius-sm,6px); background:#fff!important;
color:var(--lsb-accent); cursor:pointer; line-height:1;
transition:color .15s ease, background .15s ease, border-color .15s ease;
}
#${NAV_TOGGLE_ID}:hover{color:var(--lsb-accent); border-color:var(--lsb-accent); background:#f5fff8!important}
#${NAV_TOGGLE_ID} svg{width:26px; height:18px; display:block}
#lsb-bg-toast{
position:fixed; z-index:2147483601; left:50%; bottom:76px; transform:translateX(-50%);
max-width:80vw; padding:8px 14px; border-radius:6px; font-size:13px;
background:rgba(17,24,39,.92); color:#fff; opacity:0; transition:opacity .25s ease;
pointer-events:none;
}
#lsb-bg-toast.lsb-show{opacity:1}
#lsb-bg-toast.lsb-err{background:rgba(185,28,28,.94)}
`;
// ── 设置面板 DOM ──────────────────────────────────────────────────────
let panelEl = null;
let toggleEl = null;
let navToggleEl = null;
let toastEl = null;
let toastTimer = null;
let panelView = 'main';
function toast(msg, isError) {
if (!toastEl) {
toastEl = document.createElement('div');
toastEl.id = 'lsb-bg-toast';
document.body.appendChild(toastEl);
}
toastEl.textContent = msg;
toastEl.className = 'lsb-show' + (isError ? ' lsb-err' : '');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastEl.className = ''; }, 2400);
}
const PANEL_HTML = `
<div class="lsb-head">
<button type="button" class="lsb-back" data-act="back" aria-label="返回主菜单">‹</button>
<span data-role="title">LINUAX SB READ</span><span class="lsb-spacer"></span>
<button type="button" class="lsb-x" data-act="close" aria-label="关闭">×</button>
</div>
<div class="lsb-body">
<div class="lsb-view" data-view="main">
<div class="lsb-sec">功能</div>
<button type="button" class="lsb-menu-item" data-act="images">
<span>图片设置</span><span class="lsb-menu-desc">图库、效果、轮播</span><span class="lsb-menu-arrow">›</span>
</button>
<div class="lsb-sec">阅读模式</div>
<div class="lsb-row">
<label for="lsb-reader">双栏阅读</label>
<input type="checkbox" id="lsb-reader" data-cfg="reader">
<span class="lsb-spacer"></span>
<span class="lsb-tip">左列表 / 右正文</span>
</div>
<div class="lsb-row">
<label for="lsb-reader-modal">弹框阅读</label>
<input type="checkbox" id="lsb-reader-modal" data-cfg="readerModal">
<span class="lsb-spacer"></span>
<span class="lsb-tip">点击帖子弹框打开</span>
</div>
<div class="lsb-row">
<label>弹框宽度</label>
<input type="range" min="50" max="96" step="1" data-cfg="readerModalWidth">
<span class="lsb-val" data-val="readerModalWidth"></span>
</div>
<div class="lsb-row">
<label>左栏宽度</label>
<input type="range" min="260" max="720" step="10" data-cfg="readerWidth">
<span class="lsb-val" data-val="readerWidth"></span>
</div>
<div class="lsb-row">
<label for="lsb-rd-side">隐藏侧栏</label>
<input type="checkbox" id="lsb-rd-side" data-cfg="readerHideSidebar">
</div>
<div class="lsb-sec">快捷回复</div>
<textarea class="lsb-presets" data-role="reply-presets" aria-label="快捷回复预设" placeholder="每行填写一条回复"></textarea>
<span class="lsb-tip">每行一条,保存后显示在回复标题旁</span>
<div class="lsb-row">
<label for="lsb-imgur-key">图床 API Key</label>
<input type="text" id="lsb-imgur-key" data-cfg="imgurApiKey" autocomplete="off" spellcheck="false" placeholder="imgur.la API Key">
</div>
<span class="lsb-tip">回复框中的图片按钮会上传到 imgur.la;可替换为自己的 Key</span>
<div class="lsb-sec"></div>
<div class="lsb-row">
<button type="button" class="lsb-btn" data-act="reset">恢复默认</button>
</div>
</div>
<div class="lsb-view" data-view="images" hidden>
<div class="lsb-row">
<label for="lsb-enabled">启用背景</label>
<input type="checkbox" id="lsb-enabled" data-cfg="enabled">
<span class="lsb-spacer"></span>
<button type="button" class="lsb-btn" data-act="next">切换下一张</button>
</div>
<div class="lsb-drop" data-act="pick">
点击选择本地图片,或把图片拖进来
<input type="file" accept="image/*" multiple hidden data-role="file">
</div>
<div class="lsb-row">
<input type="text" data-role="url" placeholder="粘贴图片直链 https://...">
<button type="button" class="lsb-btn lsb-primary" data-act="add-url">添加</button>
</div>
<div class="lsb-grid" data-role="grid"></div>
<div class="lsb-sec">显示效果</div>
<div class="lsb-row">
<label>配色</label>
<select data-cfg="theme">
<option value="auto">跟随站点明暗</option>
<option value="light">强制浅色</option>
<option value="dark">强制深色</option>
</select>
</div>
<div class="lsb-row"><label>透明度</label><input type="range" min="0" max="1" step="0.02" data-cfg="panelAlpha"><span class="lsb-val" data-val="panelAlpha"></span></div>
<div class="lsb-row"><label>模糊</label><input type="range" min="0" max="40" step="1" data-cfg="blur"><span class="lsb-val" data-val="blur"></span></div>
<div class="lsb-row"><label>蒙版</label><input type="range" min="0" max="0.8" step="0.02" data-cfg="maskAlpha"><span class="lsb-val" data-val="maskAlpha"></span></div>
<div class="lsb-row"><label>亮度</label><input type="range" min="0.3" max="1.6" step="0.02" data-cfg="brightness"><span class="lsb-val" data-val="brightness"></span></div>
<div class="lsb-row">
<label>填充</label>
<select data-cfg="size"><option value="cover">铺满裁切</option><option value="contain">完整显示</option><option value="repeat">平铺</option></select>
</div>
<div class="lsb-row">
<label>位置</label>
<select data-cfg="position"><option value="center">居中</option><option value="top center">顶部</option><option value="bottom center">底部</option><option value="left center">左侧</option><option value="right center">右侧</option></select>
</div>
<div class="lsb-row"><label for="lsb-fixed">固定不滚动</label><input type="checkbox" id="lsb-fixed" data-cfg="fixed"></div>
<div class="lsb-sec">轮播</div>
<div class="lsb-row"><label for="lsb-carousel">自动轮播</label><input type="checkbox" id="lsb-carousel" data-cfg="carousel"><label for="lsb-shuffle" style="flex:0 0 auto">随机</label><input type="checkbox" id="lsb-shuffle" data-cfg="shuffle"></div>
<div class="lsb-row"><label>间隔(秒)</label><input type="number" min="5" step="5" data-cfg="interval"></div>
<div class="lsb-sec"></div>
<div class="lsb-row"><button type="button" class="lsb-btn" data-act="clear">清空图库</button></div>
</div>
</div>`;
function renderGrid() {
const grid = panelEl.querySelector('[data-role=grid]');
grid.textContent = '';
if (!images.length) {
const p = document.createElement('div');
p.className = 'lsb-empty';
p.style.gridColumn = '1 / -1';
p.textContent = '图库为空,先添加一张图片';
grid.appendChild(p);
return;
}
clampIndex();
images.forEach((img, i) => {
const cell = document.createElement('div');
cell.className = 'lsb-thumb' + (i === config.activeIndex ? ' lsb-active' : '');
cell.style.backgroundImage = cssUrl(img.src);
cell.title = img.name || '';
cell.dataset.id = img.id;
const del = document.createElement('button');
del.type = 'button';
del.className = 'lsb-del';
del.dataset.del = img.id;
del.textContent = '×';
del.title = '删除';
cell.appendChild(del);
grid.appendChild(cell);
});
}
/** 数值型配置的显示文本:px 类带单位,比例类保留两位小数 */
function formatVal(key, v) {
const n = Number(v);
if (key === 'blur') return n + 'px';
if (key === 'readerWidth') return n + 'px';
if (key === 'readerModalWidth') return n + '%';
return n.toFixed(2);
}
/** 把 config 回填到控件上(轮播换图后也要同步高亮) */
function syncPanel() {
if (!panelEl) return;
panelEl.querySelectorAll('[data-cfg]').forEach(el => {
const key = el.dataset.cfg;
if (el.type === 'checkbox') el.checked = !!config[key];
else el.value = config[key];
});
panelEl.querySelectorAll('[data-val]').forEach(el => {
const key = el.dataset.val;
el.textContent = formatVal(key, config[key]);
});
const presets = panelEl.querySelector('[data-role=reply-presets]');
if (presets && document.activeElement !== presets) presets.value = config.replyPresets || '';
const readerMode = panelEl.querySelector('[data-cfg="reader"]');
const readerModalMode = panelEl.querySelector('[data-cfg="readerModal"]');
if (readerMode) readerMode.checked = !!config.reader;
if (readerModalMode) readerModalMode.checked = !!config.readerModal;
syncPanelTheme();
renderGrid();
}
/** 设置面板自己也要跟着明暗切换,否则暗夜模式下白底面板很刺眼 */
function syncPanelTheme() {
const dark = isDarkNow();
if (panelEl) panelEl.classList.toggle('lsb-dark', dark);
if (toggleEl) toggleEl.classList.toggle('lsb-dark', dark);
if (navToggleEl) navToggleEl.classList.toggle('lsb-dark', dark);
}
// 图片资源与背景效果属于高频率较低的高级设置,进入二级视图后再展示,主菜单只保留常用开关。
function setPanelView(view) {
if (!panelEl) return;
panelView = view === 'images' ? 'images' : 'main';
panelEl.querySelectorAll('[data-view]').forEach(el => {
el.hidden = el.dataset.view !== panelView;
});
const back = panelEl.querySelector('[data-act=back]');
const title = panelEl.querySelector('[data-role=title]');
if (back) back.classList.toggle('lsb-visible', panelView === 'images');
if (title) title.textContent = panelView === 'images' ? '图片设置' : 'LINUAX SB READ';
}
// 顶栏入口使用独立图标,点击后复用同一设置面板,避免用户必须寻找油猴菜单。
function createNavToggle() {
const button = document.createElement('button');
button.id = NAV_TOGGLE_ID;
button.type = 'button';
button.title = '背景设置';
button.setAttribute('aria-label', '打开背景设置');
button.setAttribute('aria-controls', PANEL_ID);
button.setAttribute('aria-expanded', 'false');
button.innerHTML = PLUGIN_ICON_SVG;
button.addEventListener('click', () => togglePanel());
return button;
}
/** 顶栏把搜索框放在第 5 个网格列;图标固定占据其左侧空列,避免挤动版块导航。 */
function injectNavToggle() {
const search = document.querySelector('.top .bar .search-form, .bar .search-form');
if (!search || !search.parentElement) return false;
let button = document.getElementById(NAV_TOGGLE_ID);
if (!button || !button.isConnected) {
button = createNavToggle();
search.parentElement.insertBefore(button, search);
} else if (button.nextElementSibling !== search) {
search.parentElement.insertBefore(button, search);
}
navToggleEl = button;
button.setAttribute('aria-expanded', String(!!(panelEl && panelEl.classList.contains('lsb-open'))));
syncPanelTheme();
if (toggleEl) toggleEl.style.display = 'none';
return true;
}
function buildPanel() {
// 非 HTML 文档(直接打开 SVG/XML 等)没有 body,挂不了 UI,直接放弃
if (!document.body) return;
GM_addStyle(PANEL_CSS);
toggleEl = document.createElement('button');
toggleEl.id = 'lsb-bg-toggle';
toggleEl.type = 'button';
toggleEl.title = '背景设置';
toggleEl.setAttribute('aria-label', '背景设置');
toggleEl.setAttribute('aria-controls', PANEL_ID);
toggleEl.setAttribute('aria-expanded', 'false');
toggleEl.innerHTML = PLUGIN_ICON_SVG;
document.body.appendChild(toggleEl);
panelEl = document.createElement('div');
panelEl.id = PANEL_ID;
panelEl.innerHTML = PANEL_HTML;
document.body.appendChild(panelEl);
toggleEl.addEventListener('click', togglePanel);
bindPanel();
syncPanel();
// 搜索框可能由站点脚本延迟生成;找到顶部结构就隐藏右下角兜底入口。
toggleEl.style.display = injectNavToggle() ? 'none' : '';
// 顶栏由站点脚本异步重绘时重新挂载入口,避免切换页面后图标消失。
const headerObserver = new MutationObserver(() => {
toggleEl.style.display = injectNavToggle() ? 'none' : '';
});
headerObserver.observe(document.body, { childList: true, subtree: true });
}
// 顶部图标与兜底按钮共用同一面板状态,打开时同步控件值,确保轮播切图或主题变化后面板仍显示最新配置。
function togglePanel(force) {
if (!panelEl) return;
const open = typeof force === 'boolean' ? force : !panelEl.classList.contains('lsb-open');
panelEl.classList.toggle('lsb-open', open);
if (open) setPanelView('main');
if (toggleEl) toggleEl.setAttribute('aria-expanded', String(open));
if (navToggleEl) navToggleEl.setAttribute('aria-expanded', String(open));
if (open) syncPanel();
}
function bindPanel() {
const fileInput = panelEl.querySelector('[data-role=file]');
const urlInput = panelEl.querySelector('[data-role=url]');
const drop = panelEl.querySelector('[data-act=pick]');
const presetsInput = panelEl.querySelector('[data-role=reply-presets]');
// 话术按行保存;更新后重建当前回复区的下拉框,让新预设无需刷新即可使用。
presetsInput.addEventListener('input', () => {
config.replyPresets = presetsInput.value;
saveConfig(config);
document.querySelectorAll('.lsb-reply-head-tools').forEach(node => node.remove());
enhanceReplyFeatures(document);
});
// 滑块/下拉/勾选:input 事件即时预览,避免松手才生效的迟滞感
panelEl.addEventListener('input', e => {
const el = e.target.closest('[data-cfg]');
if (!el) return;
const key = el.dataset.cfg;
config[key] = el.type === 'checkbox' ? el.checked
: (el.type === 'number' || el.type === 'range') ? Number(el.value)
: el.value;
if (key === 'reader' && config.reader) config.readerModal = false;
if (key === 'readerModal' && config.readerModal) config.reader = false;
saveConfig(config);
refresh();
// 阅读模式和侧栏可见性会改动页面结构,保存配置后立即应用新的阅读布局。
if (key === 'reader' || key === 'readerModal' || key === 'readerHideSidebar') {
// 双栏已经搬动过原页面 DOM,切换为弹框时交给新页面初始化,避免在当前文档中半途还原。
if (key === 'readerModal' && config.readerModal && readerDirty) {
location.reload();
return;
}
// 从弹框切回双栏时先还原地址和页面滚动,再让双栏接管原页面 DOM。
if (config.reader) applyReaderModal();
applyReader();
if (config.readerModal) applyReaderModal();
syncPanel();
}
const out = panelEl.querySelector(`[data-val="${key}"]`);
if (out) out.textContent = formatVal(key, config[key]);
});
panelEl.addEventListener('change', e => {
if (e.target.closest('select')) refresh();
});
panelEl.addEventListener('click', async e => {
const del = e.target.closest('[data-del]');
if (del) {
e.stopPropagation();
removeImage(del.dataset.del);
refresh();
syncPanel();
return;
}
const thumb = e.target.closest('.lsb-thumb');
if (thumb) {
const i = images.findIndex(x => x.id === thumb.dataset.id);
if (i >= 0) {
config.activeIndex = i;
saveConfig(config);
refresh();
syncPanel();
}
return;
}
const act = e.target.closest('[data-act]');
if (!act) return;
switch (act.dataset.act) {
case 'close':
togglePanel(false);
break;
case 'images':
setPanelView('images');
syncPanel();
break;
case 'back':
setPanelView('main');
break;
case 'pick':
fileInput.click();
break;
case 'add-url':
if (addUrl(urlInput.value)) {
config.activeIndex = images.length - 1;
config.enabled = true;
saveConfig(config);
urlInput.value = '';
refresh();
syncPanel();
toast('已添加图片');
}
break;
case 'next':
if (images.length < 2) { toast('至少需要两张图片', true); break; }
config.activeIndex = nextIndex();
saveConfig(config);
refresh();
syncPanel();
break;
case 'reset':
config = Object.assign({}, DEFAULTS, { activeIndex: config.activeIndex });
saveConfig(config);
refresh();
// 恢复默认时若当前正在弹框阅读,先恢复原始地址再启用默认双栏。
if (config.reader) applyReaderModal();
applyReader();
if (config.readerModal) applyReaderModal();
syncPanel();
toast('已恢复默认参数');
break;
case 'clear':
if (!images.length) break;
if (!confirm('确定清空图库中的 ' + images.length + ' 张图片?')) break;
images = [];
config.activeIndex = 0;
saveImages(images);
saveConfig(config);
refresh();
syncPanel();
toast('图库已清空');
break;
}
});
fileInput.addEventListener('change', async () => {
const n = await addLocalFiles(fileInput.files);
fileInput.value = '';
if (!n) return;
config.activeIndex = images.length - 1;
config.enabled = true;
saveConfig(config);
refresh();
syncPanel();
toast('已添加 ' + n + ' 张图片');
});
urlInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
panelEl.querySelector('[data-act=add-url]').click();
}
});
['dragenter', 'dragover'].forEach(t => drop.addEventListener(t, e => {
e.preventDefault();
drop.classList.add('lsb-over');
}));
['dragleave', 'drop'].forEach(t => drop.addEventListener(t, e => {
e.preventDefault();
drop.classList.remove('lsb-over');
}));
drop.addEventListener('drop', async e => {
const n = await addLocalFiles(e.dataTransfer && e.dataTransfer.files);
if (!n) return;
config.activeIndex = images.length - 1;
config.enabled = true;
saveConfig(config);
refresh();
syncPanel();
toast('已添加 ' + n + ' 张图片');
});
}
// ── 跟随站点明暗切换 ──────────────────────────────────────────────────
// 站点切换暗夜模式时只会改 CSS 变量(新增/替换 <style>、或在 <html> 上挂
// 内联样式与属性),不会通知我们。这里监听这些变化,发现明暗真的翻转了
// 才重建样式——只比对布尔结果,避免被自己注入的样式反复触发。
let lastDark = null;
let themeTimer = null;
function checkTheme() {
const dark = isDarkNow();
document.documentElement.classList.toggle('lsb-reply-dark', dark);
if (dark === lastDark) return;
lastDark = dark;
applyStyle();
applyReaderStyle(); // 纯净模式的选中态/悬停底色也分明暗
syncPanelTheme();
}
function watchTheme() {
lastDark = isDarkNow();
const schedule = () => {
clearTimeout(themeTimer);
themeTimer = setTimeout(checkTheme, 60);
};
const mo = new MutationObserver(records => {
for (const r of records) {
// 忽略本脚本自己的节点,否则 applyStyle 会触发下一轮观察
const t = r.target;
if (t && (t.id === CSS_ID || t.id === ROOT_ID || t.id === PANEL_ID
|| t.id === READER_CSS_ID || t.id === READER_VIEW_ID || t.id === READER_MODAL_ID)) continue;
const self = n => n.id === CSS_ID || n.id === ROOT_ID || n.id === PANEL_ID
|| n.id === READER_CSS_ID || n.id === READER_VIEW_ID || n.id === READER_BAR_ID
|| n.id === READER_MODAL_ID;
const added = Array.from(r.addedNodes || []);
if (added.length && added.every(self)) continue;
schedule();
return;
}
});
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class', 'data-theme', 'data-color-scheme'],
});
if (document.head) mo.observe(document.head, { childList: true, subtree: true, characterData: true });
if (document.body) mo.observe(document.body, { attributes: true, attributeFilter: ['style', 'class'] });
if (window.matchMedia) {
const mq = matchMedia('(prefers-color-scheme: dark)');
// Safari 14 之前只有 addListener
if (mq.addEventListener) mq.addEventListener('change', schedule);
else if (mq.addListener) mq.addListener(schedule);
}
// 站点可能在自己脚本里延迟写入主题,兜一次晚检查
setTimeout(checkTheme, 1200);
}
// ── 启动 ──────────────────────────────────────────────────────────────
// 尽早注入样式,避免先闪一下原始灰底再变成背景图。
// document-start 时 <head> 可能还不存在,这种情况下退到 DOMContentLoaded。
applyStyle();
applyReaderStyle();
function init() {
// 再跑一次:早期注入的 style / 背景层若没挂上或被文档解析覆盖,这里补回来
applyStyle();
buildPanel();
GM_addStyle(REPLY_CSS);
bindReplyFeatures();
enhanceReplyFeatures(document);
startCarousel();
watchTheme();
applyReader();
applyReaderModal();
bindReaderNav();
}
if (document.body) {
init();
} else {
document.addEventListener('DOMContentLoaded', init, { once: true });
}
GM_registerMenuCommand('打开背景设置', () => togglePanel(true));
GM_registerMenuCommand('启用 / 关闭背景', () => {
config.enabled = !config.enabled;
saveConfig(config);
refresh();
syncPanel();
toast(config.enabled ? '背景已启用' : '背景已关闭');
});
GM_registerMenuCommand('切换双栏阅读', () => {
config.reader = !config.reader;
if (config.reader) config.readerModal = false;
saveConfig(config);
refresh();
if (config.reader) applyReaderModal();
applyReader();
if (!config.reader) applyReaderModal();
syncPanel();
if (!config.reader && !readerDirty) toast('双栏阅读已关闭');
else if (config.reader) toast(readerUsable() ? '双栏阅读已开启' : '当前页面不支持双栏阅读');
});
GM_registerMenuCommand('切换弹框阅读', () => {
config.readerModal = !config.readerModal;
if (config.readerModal) config.reader = false;
saveConfig(config);
refresh();
// 双栏模式改动过原页面 DOM,切换弹框模式必须刷新后从服务端原始页面开始。
if (config.readerModal && readerDirty) {
location.reload();
return;
}
applyReader();
applyReaderModal();
syncPanel();
toast(config.readerModal ? (readerUsable() ? '弹框阅读已开启' : '当前页面不支持弹框阅读') : '弹框阅读已关闭');
});
})();