// ==UserScript==
// @name 骨碌碌沉浸式全能助手Beta V3.94
// @namespace Violentmonkey Scripts
// @match *://gululu.world/*
// @match *://www.gululu.world/*
// @match *://create.gululu.world/*
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @require https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js
// @version 3.94
// @author 无限悾涧
// @description Reader: 手机模式 / 评论弹幕 / 迷雾 / 音效 / 自动音乐 / 氛围背景 / 视效 / 内存清理 | Editor: 快捷指令 / 调色盘 / 插入多媒体 | Chat: 图片压缩
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// 📱 移动端适配:由于官方强制重定向,用户通常在桌面模式下访问,因此移除自动检测,改为手动配置
// 🕵️ 环境识别与白名单判定
const HOST = window.location.host;
const PATH = window.location.pathname;
const IS_EDITOR = HOST.includes('create.gululu.world');
const IS_BOOK_READER = PATH.startsWith('/book/');
const IS_CHAT = PATH.startsWith('/chat');
// 🛠️ 性能工具 (Performance Utils)
const throttle = (func, limit) => {
let lastFunc;
let lastRan;
return function() {
const context = this;
const args = arguments;
if (!lastRan) {
func.apply(context, args);
lastRan = Date.now();
} else {
clearTimeout(lastFunc);
lastFunc = setTimeout(function() {
if ((Date.now() - lastRan) >= limit) {
func.apply(context, args);
lastRan = Date.now();
}
}, limit - (Date.now() - lastRan));
}
}
};
const DEBUG_MODE = false;
const log = (...args) => {
if (DEBUG_MODE) console.log('%c[GULULU]', 'background: #0984e3; color: white; padding: 2px 4px;', ...args);
};
// --- 全局配置管理 (提前定义以支持全局手机模式) ---
const defaultConfig = {
diceMaskEnabled: true,
foldingEnabled: true,
soundEnabled: true,
fogModeEnabled: true,
danmakuEnabled: false,
nightModeEnabled: false,
mobileModeEnabled: false,
vfxEnabled: true,
autoMusicEnabled: true,
cleanupEnabled: false
};
function loadConfig() {
const s = localStorage.getItem('gululu_global_config_v6');
return s ? { ...defaultConfig, ...JSON.parse(s) } : defaultConfig;
}
// 📱 切换手机模式 (全局函数)
// 注意:新版 Mobile Reforged 脚本已接管手机界面,此处仅负责切换标记
function toggleMobileMode(enabled) {
// 仅设置 Meta 标签以优化视口(如果新脚本没加载,这个也没坏处)
let meta = document.querySelector('meta[name="viewport"]');
if (enabled) {
if (!meta) {
meta = document.createElement('meta');
meta.name = "viewport";
document.head.appendChild(meta);
}
meta.content = "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover";
} else {
if (meta) meta.content = "";
}
// ⚠️ 移除旧版 Zoom CSS 注入,防止与 Reforged 脚本冲突
let mobileStyle = document.getElementById('g-mobile-style');
if (mobileStyle) mobileStyle.remove();
}
// 🚀 立即应用全局配置 (无论在哪个页面)
let globalConfig = loadConfig();
if (globalConfig.mobileModeEnabled) toggleMobileMode(true);
// 📱 全局手机模式切换按钮 (任何页面可见)
const createMobileToggleBtn = () => {
if (document.getElementById('g-mobile-btn')) return;
const btn = document.createElement('div');
btn.id = 'g-mobile-btn';
btn.innerText = globalConfig.mobileModeEnabled ? '📱' : '💻';
btn.title = "切换 手机/电脑 布局";
btn.style.cssText = `
position: fixed; bottom: 20px; left: 20px; width: 36px; height: 36px;
background: rgba(255,255,255,0.9); border-radius: 50%; box-shadow: 0 2px 10px rgba(0,0,0,0.15);
text-align: center; line-height: 36px; cursor: pointer; z-index: 10000;
font-size: 18px; user-select: none; border: 1px solid #ccc;
`;
document.body.appendChild(btn);
btn.addEventListener('click', () => {
// 重新读取最新配置
globalConfig = loadConfig();
const newState = !globalConfig.mobileModeEnabled;
globalConfig.mobileModeEnabled = newState;
// 保存配置到 localStorage
localStorage.setItem('gululu_global_config_v6', JSON.stringify(globalConfig));
// ⚡ 核心修复:切换模式后强制刷新页面,以便唤醒/关闭 Mobile Reforged 脚本
location.reload();
});
// --- 新增:手机模式下的搜索按钮 ---
const searchBtn = document.createElement('div');
searchBtn.id = 'g-mobile-search-btn';
searchBtn.innerText = '🔍';
searchBtn.title = "展开/折叠搜索栏";
searchBtn.style.cssText = `
position: fixed; bottom: 20px; left: 70px; width: 36px; height: 36px;
background: rgba(255,255,255,0.9); border-radius: 50%; box-shadow: 0 2px 10px rgba(0,0,0,0.15);
text-align: center; line-height: 36px; cursor: pointer; z-index: 10000;
font-size: 18px; user-select: none; border: 1px solid #ccc;
display: ${globalConfig.mobileModeEnabled ? 'block' : 'none'};
`;
document.body.appendChild(searchBtn);
searchBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isSearching = document.body.classList.contains('g-mobile-searching');
if (isSearching) {
document.body.classList.remove('g-mobile-searching');
searchBtn.innerText = '🔍';
} else {
document.body.classList.add('g-mobile-searching');
searchBtn.innerText = '❌';
// 尝试聚焦输入框
setTimeout(() => {
const input = document.querySelector('.SearchBar_input__whYqF');
if(input) input.focus();
}, 100);
}
});
};
// 只要不是编辑器模式(编辑器太复杂,不建议手机操作),就显示这个按钮
if (!IS_EDITOR) {
// 延迟一点加载,避免和页面元素冲突
setTimeout(createMobileToggleBtn, 500);
}
// ⛔️ 白名单检查 (拦截非功能页面,防止首页执行后续阅读器逻辑,但在手机模式初始化之后执行)
if (!IS_EDITOR && !IS_BOOK_READER && !IS_CHAT) {
return;
}
// ============================================================
// ✍️ 【创作模式 (Editor Mode)】
// ============================================================
if (IS_EDITOR) {
log("✏️ 创作模式已激活");
const EDITOR_STYLE = `
#g-author-btn {
position: fixed; top: 400px; left: 10px;
width: 40px; height: 40px; background: #6c5ce7; color: white;
border-radius: 8px; box-shadow: 0 4px 10px rgba(108, 92, 231, 0.4);
text-align: center; line-height: 40px; cursor: pointer;
z-index: 9999; font-size: 20px; transition: all 0.2s;
user-select: none;
}
#g-author-btn:hover { transform: scale(1.1); background: #5649c0; }
#g-author-btn:active { transform: scale(0.95); }
#g-author-btn::after {
content: "选中文本后点击";
position: absolute; left: 50px; top: 5px;
background: #333; color: #fff; padding: 5px 10px;
border-radius: 4px; font-size: 12px; white-space: nowrap;
opacity: 0; pointer-events: none; transition: opacity 0.2s;
width: max-content;
}
#g-author-btn:hover::after { opacity: 1; }
.g-editor-btn-sub {
position: fixed; left: 10px;
width: 40px; height: 40px; color: white;
border-radius: 8px; text-align: center; line-height: 40px;
cursor: pointer; z-index: 9999; font-size: 20px; transition: all 0.2s;
user-select: none; box-shadow: 0 4px 10px rgba(0,0,0,0.2);
}
#g-secret-btn { top: 450px; background: #d63031; }
#g-secret-btn:hover { transform: scale(1.1); background: #ff7675; }
#g-clue-btn { top: 500px; background: #00b894; }
#g-clue-btn:hover { transform: scale(1.1); background: #55efc4; }
#g-color-btn { top: 550px; background: #e17055; }
#g-color-btn:hover { transform: scale(1.1); background: #fab1a0; }
#g-music-btn { top: 600px; background: #e84393; }
#g-music-btn:hover { transform: scale(1.1); background: #fd79a8; }
/* 隐藏的颜色输入框 */
#g-color-input {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
opacity: 0; cursor: pointer; padding: 0; margin: 0;
}
`;
const style = document.createElement('style');
style.innerHTML = EDITOR_STYLE;
document.head.appendChild(style);
// 全局选区缓存 (用于调色板)
let savedRange = null;
let currentEditingId = null; // ⚡ 锚点ID,对抗 React 重绘
let lastSelectionUpdate = 0;
const createEditorBtn = () => {
if (document.getElementById('g-author-btn')) return;
// 1. 折叠按钮
const btn = document.createElement('div');
btn.id = 'g-author-btn'; btn.innerHTML = '📦';
document.body.appendChild(btn);
btn.addEventListener('mousedown', (e) => { e.preventDefault(); wrapSelection('fold'); });
// 2. 秘密按钮 (加密)
const secretBtn = document.createElement('div');
secretBtn.id = 'g-secret-btn'; secretBtn.className = 'g-editor-btn-sub';
secretBtn.innerHTML = '🔒'; secretBtn.title = "埋藏秘密 (AES加密)";
document.body.appendChild(secretBtn);
secretBtn.addEventListener('mousedown', (e) => { e.preventDefault(); wrapSelection('secret'); });
// 3. 线索按钮 (钥匙)
const clueBtn = document.createElement('div');
clueBtn.id = 'g-clue-btn'; clueBtn.className = 'g-editor-btn-sub';
clueBtn.innerHTML = '🔑'; clueBtn.title = "放置线索";
document.body.appendChild(clueBtn);
clueBtn.addEventListener('mousedown', (e) => { e.preventDefault(); wrapSelection('clue'); });
// 4. 调色板 (自定义颜色)
const colorBtn = document.createElement('div');
colorBtn.id = 'g-color-btn'; colorBtn.className = 'g-editor-btn-sub';
colorBtn.innerHTML = '🎨'; colorBtn.title = "自定义文字颜色";
colorBtn.style.position = 'fixed';
document.body.appendChild(colorBtn);
// 5. 音乐盒按钮
const musicBtn = document.createElement('div');
musicBtn.id = 'g-music-btn'; musicBtn.className = 'g-editor-btn-sub';
musicBtn.innerHTML = '🎵'; musicBtn.title = "插入背景音乐 (外链)";
document.body.appendChild(musicBtn);
musicBtn.addEventListener('mousedown', (e) => { e.preventDefault(); wrapSelection('music'); });
// 6. 特效按钮
const vfxBtn = document.createElement('div');
vfxBtn.id = 'g-vfx-btn'; vfxBtn.className = 'g-editor-btn-sub';
vfxBtn.style.top = '650px'; vfxBtn.style.background = '#0984e3';
vfxBtn.innerHTML = '🎬'; vfxBtn.title = "插入环境视效";
document.body.appendChild(vfxBtn);
vfxBtn.addEventListener('mousedown', (e) => { e.preventDefault(); wrapSelection('vfx'); });
// 7. 背景按钮
const bgBtn = document.createElement('div');
bgBtn.id = 'g-bg-set-btn'; bgBtn.className = 'g-editor-btn-sub';
bgBtn.style.top = '700px'; bgBtn.style.background = '#636e72';
bgBtn.innerHTML = '🌄'; bgBtn.title = "设置氛围背景 / 移除背景";
document.body.appendChild(bgBtn);
bgBtn.addEventListener('mousedown', (e) => { e.preventDefault(); wrapSelection('bg'); });
// 8. 超级空行按钮
const emptyBtn = document.createElement('div');
emptyBtn.id = 'g-empty-btn'; emptyBtn.className = 'g-editor-btn-sub';
emptyBtn.style.top = '750px'; emptyBtn.style.background = '#a29bfe';
emptyBtn.innerHTML = '🈳'; emptyBtn.title = "插入超级空行 (防吞)";
document.body.appendChild(emptyBtn);
emptyBtn.addEventListener('mousedown', (e) => { e.preventDefault(); wrapSelection('emptyline'); });
// 9. 引用跳转按钮
const quoteBtn = document.createElement('div');
quoteBtn.id = 'g-quote-btn'; quoteBtn.className = 'g-editor-btn-sub';
quoteBtn.style.top = '800px'; quoteBtn.style.background = '#00cec9';
quoteBtn.innerHTML = '❝'; quoteBtn.title = "插入引用跳转";
document.body.appendChild(quoteBtn);
quoteBtn.addEventListener('mousedown', (e) => { e.preventDefault(); wrapSelection('quote'); });
const colorInput = document.createElement('input');
colorInput.type = 'color'; colorInput.id = 'g-color-input';
colorBtn.appendChild(colorInput);
// 关键:点击前保存选区
// 🔍 调试版:Mousedown 监听
colorInput.addEventListener('mousedown', (e) => {
//console.log("--- [DEBUG] Mousedown 触发 ---");
const sel = window.getSelection();
// 1. 打印旧 savedRange 状态
if (savedRange) {
console.log("Old SavedRange:", savedRange);
console.log(" -> Collapsed:", savedRange.collapsed);
console.log(" -> Connected:", savedRange.commonAncestorContainer.isConnected);
console.log(" -> Container:", savedRange.commonAncestorContainer);
} else {
console.log("Old SavedRange: NULL");
}
// 2. 打印当前选区状态
if (sel.rangeCount > 0) {
const currentRange = sel.getRangeAt(0);
// 3. 执行判断逻辑
const isEditor = (currentRange.commonAncestorContainer.nodeType === 3 ? currentRange.commonAncestorContainer.parentElement : currentRange.commonAncestorContainer).closest('.ProseMirror');
if (!isEditor) {
//console.warn("[DEBUG] 忽略:选区不在编辑器内");
return;
}
// 简单直接:只要有选区就保存
// 因为我们现在只在 change 时执行,中间的拖动不会触发逻辑,所以不需要复杂的防抖
if (!currentRange.collapsed) {
savedRange = currentRange.cloneRange();
//console.log("[GULULU] 选区已保存:", savedRange);
} else {
// 如果是光标,且 savedRange 还在,就不覆盖(防止点击色盘本身导致失焦)
if (savedRange && savedRange.commonAncestorContainer.isConnected) {
//console.log("[GULULU] 保留旧选区");
} else {
//console.warn("[GULULU] 当前无有效选区");
}
}
// 清理旧的 anchor ID
currentEditingId = null;
} else {
//console.warn("[DEBUG] 当前无选区");
}
});
// ⚡ 核心修改:改用 change 事件(选定颜色关闭面板后触发),避免连续修改导致的 DOM 混乱
colorInput.addEventListener('change', (e) => {
//console.log("--- [DEBUG] Change 触发 (颜色选定) ---");
if (savedRange) {
// 恢复选区
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(savedRange);
// 执行一次性修改
wrapSelection('color', e.target.value, savedRange);
// 修改完成后,清空 savedRange,强制用户下次必须重新选择文本(避免误操作)
// 或者保留它以便再次点击?建议保留,但因为 DOM 变了,下次点击 mousedown 会重新获取
} else {
alert("选区丢失,请重新选择文本");
}
});
};
const wrapSelection = (type, param, rangeOverride) => {
//console.log("--- [DEBUG] wrapSelection Start ---");
let selection, rawText, editorDiv;
let tempId = null;
let cleanText = "";
let htmlContent = "";
const esc = (s) => s.replace(/&/g, "&").replace(//g, ">").replace(/\r?\n/g, "
");
// 1. 获取选区对象
if (rangeOverride) {
selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(rangeOverride);
rawText = rangeOverride.toString();
const anchor = rangeOverride.commonAncestorContainer;
editorDiv = (anchor.nodeType === 3 ? anchor.parentElement : anchor).closest('.ProseMirror');
} else {
selection = window.getSelection();
const anchor = selection.anchorNode;
editorDiv = anchor && (anchor.nodeType === 3 ? anchor.parentElement : anchor).closest('.ProseMirror');
rawText = selection.toString();
}
cleanText = rawText;
//console.log("[DEBUG] 当前选中文本:", rawText);
// ⚡ 颜色修改逻辑 (DOM Walker 方案)
if (type === 'color') {
const anchor = selection.anchorNode;
let parentSpan = (anchor.nodeType === 3 ? anchor.parentElement : anchor).closest('span[style*="color"]');
const rng = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
// 尝试查找 parentSpan
if (!parentSpan && rng) {
if (rng.startContainer.nodeType === 1 && rng.endOffset - rng.startOffset === 1) {
const child = rng.startContainer.childNodes[rng.startOffset];
if (child && child.tagName === 'SPAN' && child.style.color) parentSpan = child;
}
}
if (!parentSpan && rng && rng.collapsed && rng.startContainer.nodeType === 1) {
const prev = rng.startContainer.childNodes[rng.startOffset - 1];
if (prev && prev.tagName === 'SPAN' && prev.style.color) parentSpan = prev;
}
// 判定是否为修改模式
const isInside = parentSpan && rng && (rng.collapsed || parentSpan.contains(rng.commonAncestorContainer));
// 准备处理的 DOM 片段
let fragment;
if (isInside) {
//console.log("[DEBUG] 模式: 修改现有颜色 (Whole Span)");
// 选中整个旧 span 的内容进行克隆
const range = document.createRange();
range.selectNodeContents(parentSpan);
fragment = range.cloneContents();
// 选中整个 span 以便稍后删除
const delRange = document.createRange();
delRange.selectNode(parentSpan);
selection.removeAllRanges();
selection.addRange(delRange);
} else {
//console.log("[DEBUG] 模式: 新增颜色 (Selection)");
if (selection.rangeCount === 0) return;
fragment = selection.getRangeAt(0).cloneContents();
}
// --- 核心:DOM Walker 遍历并上色 ---
// 创建临时容器来处理 DOM
const div = document.createElement('div');
div.appendChild(fragment);
// 只有当有内容时才处理
if (div.textContent.trim() || div.querySelector('img')) {
const walker = document.createTreeWalker(div, NodeFilter.SHOW_TEXT, null, false);
const textNodes = [];
let node;
while(node = walker.nextNode()) textNodes.push(node);
tempId = "g-c-" + Date.now();
let first = true;
textNodes.forEach(textNode => {
// 忽略空文本节点,避免产生无意义的 span
if (textNode.textContent.length === 0) return;
const span = document.createElement('span');
span.style.color = param;
span.textContent = textNode.textContent;
if (first) { span.id = tempId; first = false; }
textNode.parentNode.replaceChild(span, textNode);
});
htmlContent = div.innerHTML;
}
// 执行粘贴
if (htmlContent) {
//console.log("[DEBUG] 生成的 HTML (DOM):", htmlContent);
// 如果是修改模式,先删除旧的
if (isInside) {
document.execCommand('delete');
}
const dt = new DataTransfer();
dt.setData('text/html', htmlContent);
dt.setData('text/plain', div.innerText); // 使用处理后的纯文本
const evt = new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: dt });
document.activeElement.dispatchEvent(evt);
// 恢复选区 (可选)
setTimeout(() => {
if (tempId) {
const el = document.getElementById(tempId);
if (el) {
const newRange = document.createRange();
newRange.selectNodeContents(el);
newRange.collapse(false); // 光标放在末尾
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(newRange);
}
}
}, 50);
}
return; // 颜色处理完毕,直接返回
}
if (!editorDiv) {
alert("⚠️ 请先在文本编辑器内选中一点东西!");
return;
}
// 样式策略:回归最简单的透明,去掉 font-size: 0 以防被清洗
const transStyle = 'color: transparent;';
if (type === 'fold') {
let title = prompt("请输入折叠栏标题:", "详细内容");
if (title === null) return;
if (title.trim() === "") title = "详细内容";
// ⚡ 修复:获取 HTML 内容以保留格式 (加粗、颜色等)
let innerHtml = "";
if (selection.rangeCount > 0) {
const div = document.createElement('div');
div.appendChild(selection.getRangeAt(0).cloneContents());
innerHtml = div.innerHTML;
}
if (!innerHtml || innerHtml.trim() === "") innerHtml = "在这里输入内容...";
// ⚡ 优化:使用 P 标签包裹,依靠编辑器自身的块级处理来换行,不再手动插入 BR
// 这样既能保证标签独占一行(Reader模式隐藏所需),又不会产生额外空行
const prefix = `
<折叠>[${esc(title)}]
`;
const suffix = `</折叠结束>
`;
// 组合 HTML (保留原格式)
htmlContent = prefix + innerHtml + suffix;
// 更新纯文本数据
cleanText = `\n<折叠>[${title}]\n${cleanText}\n折叠结束>\n`;
}
else if (type === 'secret') {
if (!cleanText) { alert("⚠️ 必须先选中要加密的秘密内容!"); return; }
let title = prompt("给这个秘密起个名字(ID):", "未解之谜");
if (!title) return;
let pwd = prompt("设置解锁密码:");
if (!pwd) return;
try {
const encrypted = CryptoJS.AES.encrypt(cleanText, pwd).toString();
// 秘密:前后加零宽空格,防止被合并。整体包裹在透明 span 中。
htmlContent = `<秘密>[${esc(title)}]${encrypted}</秘密>`;
} catch(e) {
alert("加密失败"); return;
}
}
else if (type === 'clue') {
let title = prompt("这是哪个秘密的线索(输入秘密名字):", "未解之谜");
if (!title) return;
let pwd = prompt("输入对应的密码:");
if (!pwd) return;
// 线索
htmlContent = `<发现秘密>[${esc(title)}]${esc(pwd)}</发现秘密>`;
}
else if (type === 'music') {
// 1. 保存当前选区 (防止弹窗导致焦点丢失)
const savedSel = window.getSelection();
if (savedSel.rangeCount === 0) { alert("请先在编辑器中点击光标位置"); return; }
const savedRange = savedSel.getRangeAt(0).cloneRange();
// 定义弹窗函数
const createMusicDialog = (callback) => {
const mask = document.createElement('div');
mask.style.cssText = `position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:10000;display:flex;justify-content:center;align-items:center;`;
const box = document.createElement('div');
box.style.cssText = `background:#fff;padding:20px;border-radius:8px;width:300px;box-shadow:0 4px 12px rgba(0,0,0,0.2);`;
box.innerHTML = `
插入音乐
取消
`;
mask.appendChild(box);
document.body.appendChild(mask);
const close = () => mask.remove();
box.querySelector('#g-music-cancel').onclick = close;
const handle = (isAuto) => {
const title = box.querySelector('#g-music-title').value.trim();
const url = box.querySelector('#g-music-url').value.trim();
if(!title || !url) { alert("请填写完整信息"); return; }
callback(title, url, isAuto);
close();
};
box.querySelector('#g-music-manual').onclick = () => handle(false);
box.querySelector('#g-music-auto').onclick = () => handle(true);
// 停止按钮逻辑:直接插入独立标签
box.querySelector('#g-music-stop').onclick = () => {
// ⚡ 关键:先恢复选区,确保粘贴到编辑器中
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(savedRange);
// 直接构造 HTML 并插入,不走 callback
const html = `<停止音乐>`;
const dt = new DataTransfer();
dt.setData('text/html', html);
dt.setData('text/plain', '<停止音乐>');
const evt = new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: dt });
document.activeElement.dispatchEvent(evt);
close();
};
// 聚焦输入框
setTimeout(() => box.querySelector('#g-music-title').focus(), 50);
};
createMusicDialog((title, url, isAuto) => {
// 2. 恢复选区
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(savedRange);
const tagStart = isAuto ? '<自动音乐>' : '<音乐>';
const tagEnd = isAuto ? '</自动音乐结束>' : '</音乐结束>';
// 构造结构
const html = `${tagStart}${esc(title)} ♪${esc(url)}${tagEnd}`;
// 执行粘贴
const dt = new DataTransfer();
dt.setData('text/html', html);
dt.setData('text/plain', `${isAuto?'<自动音乐>':'<音乐>'}${title} ♪${url}${isAuto?'自动音乐结束>':'音乐结束>'}`);
const evt = new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: dt });
document.activeElement.dispatchEvent(evt);
});
return; // 异步处理,直接返回
}
else if (type === 'vfx') {
let effect = prompt("请输入特效类型 (下雨/下雪/打雷/地震/狂风/停止):", "下雨");
if (!effect) return;
// 构造结构:使用显眼的样式包裹标签,以便作者在编辑器中看到
// 解析器会移除 <特效:xxx> 标签,留下的空 span 不可见
const badgeStyle = "background:#0984e3;color:white;padding:2px 6px;border-radius:4px;font-size:12px;font-weight:bold;";
htmlContent = `<特效:${esc(effect)}>`;
}
else if (type === 'bg') {
const choice = prompt("请输入操作类型:\n1. 设置背景 (需要选中图片)\n2. 移除背景", "1");
if (choice === '1') {
const sel = window.getSelection();
if (sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
// 1. 精确查找图片节点 (避免 querySelector 误伤第一张图)
let img = null;
// 情况A: 选中了图片本身或其包装器 (ProseMirror 标准行为)
// 此时 startContainer 是父容器,startOffset 指向该节点
if (range.startContainer.nodeType === 1) {
const child = range.startContainer.childNodes[range.startOffset];
if (child) {
if (child.tagName === 'IMG') img = child;
else if (child.nodeType === 1 && child.querySelector) img = child.querySelector('img');
}
}
// 情况B: 某些情况下直接选中了 IMG 节点
if (!img && range.commonAncestorContainer.nodeName === 'IMG') {
img = range.commonAncestorContainer;
}
if (!img) {
// 最后的尝试:检查 commonAncestorContainer 是否就是图片包装器
// 只有当它不是编辑器根节点时才查找,防止找到全文第一张图
const container = range.commonAncestorContainer;
if (container.nodeType === 1 && !container.classList.contains('ProseMirror')) {
// 限制查找范围仅在当前容器内,且仅当容器内图片很少时才敢认领
const imgs = container.querySelectorAll('img');
if (imgs.length === 1) img = imgs[0];
}
}
if (!img) {
alert("请先点击选中一张图片!(出现蓝框)");
return;
}
// 2. 找到图片所属的“行容器” (通常是 ProseMirror 的 p 或 div)
// 我们要在这个容器的前面和后面插入,而不是在里面插入
const blockContainer = img.closest('.ProseMirror > *'); // 找到直接子元素
if (!blockContainer) {
alert("无法定位段落位置");
return;
}
// 3. 定义插入逻辑 (直接 DOM 操作,最安全)
const insertTag = (text, position) => {
// 构造 ProseMirror 兼容的段落结构
const p = document.createElement('p');
const span = document.createElement('span');
span.style.color = "transparent";
span.textContent = text;
p.appendChild(span);
// 插入
if (position === 'before') {
blockContainer.parentNode.insertBefore(p, blockContainer);
} else {
// 插入到后面
blockContainer.parentNode.insertBefore(p, blockContainer.nextSibling);
}
};
// 执行插入
insertTag("<背景>", 'before');
insertTag("背景>", 'after');
// 结束,不需要后续的粘贴逻辑
return;
}
else if (choice === '2') {
const badgeStyle = "background:#636e72;color:white;padding:2px 6px;border-radius:4px;font-size:12px;font-weight:bold;";
htmlContent = `<移除背景>`;
} else {
return;
}
}
else if (type === 'emptyline') {
// 插入一个包含 Non-Breaking Space 的段落,并在其后追加一个普通空行供光标落脚
htmlContent = '
';
}
else if (type === 'quote') {
// 1. 获取当前选中的文本作为引用内容
const quoteText = cleanText.trim();
if (!quoteText) { alert("请先选中一段要引用的文字!"); return; }
// 2. 尝试从 URL 获取当前书籍 ID
const currentBookId = window.location.pathname.match(/\/book\/(\d+)/)?.[1] || "";
// 3. 弹出输入框
const createQuoteDialog = (callback) => {
const mask = document.createElement('div');
mask.style.cssText = `position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:10000;display:flex;justify-content:center;align-items:center;`;
const box = document.createElement('div');
box.style.cssText = `background:#fff;padding:20px;border-radius:8px;width:300px;box-shadow:0 4px 12px rgba(0,0,0,0.2);`;
box.innerHTML = `
插入引用跳转
引用内容: ${quoteText.substring(0, 20)}${quoteText.length>20?'...':''}
取消
`;
mask.appendChild(box);
document.body.appendChild(mask);
const close = () => mask.remove();
box.querySelector('#g-quote-cancel').onclick = close;
box.querySelector('#g-quote-confirm').onclick = () => {
const bid = box.querySelector('#g-quote-book').value.trim() || currentBookId;
const fid = box.querySelector('#g-quote-floor').value.trim();
if(!bid || !fid) { alert("请填写完整信息"); return; }
callback(bid, fid);
close();
};
setTimeout(() => box.querySelector('#g-quote-floor').focus(), 50);
};
createQuoteDialog((bid, fid) => {
// ⚡ 关键:先恢复选区
if (selection.rangeCount === 0 && savedRange) {
selection.removeAllRanges();
selection.addRange(savedRange);
} else if (editorDiv) {
editorDiv.focus();
}
// 构造结构:标签独占一行,内容独占一行
// 这样普通用户看到的是三行,不会有奇怪的缩进
const prefix = `<引用 id="${bid}" floor="${fid}">
`;
const content = `${esc(quoteText)}
`;
const suffix = `</引用>
`;
htmlContent = prefix + content + suffix;
// 执行粘贴
const dt = new DataTransfer();
dt.setData('text/html', htmlContent);
dt.setData('text/plain', `<引用 id="${bid}" floor="${fid}">\n${quoteText}\n引用>`);
const evt = new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: dt });
document.activeElement.dispatchEvent(evt);
});
return;
}
// 注意:type === 'color' 已经在上面处理过了,这里不需要再处理
if (htmlContent) {
//console.log("--------------------------------------------------");
//console.log("[DEBUG] 准备粘贴...");
//console.log("[DEBUG] 原始文本 (cleanText):", JSON.stringify(cleanText));
//console.log("[DEBUG] 生成的 HTML (htmlContent):", htmlContent);
//console.log("--------------------------------------------------");
// ⚡ 修复:使用模拟粘贴 (ClipboardEvent) 替代 insertHTML
const dt = new DataTransfer();
dt.setData('text/html', htmlContent);
dt.setData('text/plain', cleanText);
const evt = new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: dt });
document.activeElement.dispatchEvent(evt);
// ⚡ 更新全局 savedRange
const newSel = window.getSelection();
if (newSel.rangeCount > 0) {
savedRange = newSel.getRangeAt(0).cloneRange();
lastSelectionUpdate = Date.now();
}
}
};
const initEditor = setInterval(() => {
if (document.querySelector('.ProseMirror')) {
createEditorBtn();
clearInterval(initEditor);
}
}, 1000);
return;
}
// ============================================================
// 💬 【闲聊模式 (Chat Mode)】
// ============================================================
if (IS_CHAT) {
log("💬 闲聊模式已激活");
// 1. 基础配置 (独立读取)
const defaultConfig = { compressEnabled: false };
function loadChatConfig() {
const s = localStorage.getItem('gululu_chat_config_v1');
return s ? { ...defaultConfig, ...JSON.parse(s) } : defaultConfig;
}
function saveChatConfig(c) {
localStorage.setItem('gululu_chat_config_v1', JSON.stringify(c));
}
let currentConfig = loadChatConfig();
// 2. 独立 UI
const createChatUI = () => {
const btn = document.createElement('div');
btn.id = 'g-chat-btn'; // ✨ 添加 ID
btn.innerText = '⚙️';
btn.style.cssText = `
position: fixed; bottom: 30px; right: 30px; width: 40px; height: 40px;
background: #fff; border-radius: 50%; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
text-align: center; line-height: 40px; cursor: pointer; z-index: 9999;
font-size: 20px; transition: transform 0.3s;
`;
document.body.appendChild(btn);
const panel = document.createElement('div');
panel.id = 'g-chat-panel'; // ✨ 添加 ID
panel.style.cssText = `
position: fixed; bottom: 80px; right: 30px; background: #fff; padding: 15px;
border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); z-index: 9999;
display: none; font-size: 14px; border: 1px solid #f0f0f0; min-width: 180px;
`;
panel.innerHTML = `
闲聊助手
`;
document.body.appendChild(panel);
btn.addEventListener('click', () => { panel.style.display = panel.style.display==='block'?'none':'block'; });
panel.querySelector('input').addEventListener('change', (e) => {
currentConfig.compressEnabled = e.target.checked;
saveChatConfig(currentConfig);
});
};
// 3. 提示框 (复用样式逻辑)
const showToast = (msg, type = 'info') => {
let container = document.getElementById('g-toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'g-toast-container';
container.style.cssText = `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 10000002; pointer-events: none;`;
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.style.cssText = `
background: rgba(0, 0, 0, 0.85); color: #fff; padding: 15px 30px;
border-radius: 12px; margin-bottom: 10px; font-size: 16px; font-weight: bold;
animation: g-fade 3s forwards; pointer-events: auto; display: flex; align-items: center;
backdrop-filter: blur(5px);
`;
toast.innerHTML = `${type==='success'?'✅':'ℹ️'} ${msg}`;
if(!document.getElementById('g-chat-style')) {
const s = document.createElement('style');
s.id = 'g-chat-style';
s.innerHTML = `@keyframes g-fade { 0% {opacity:0;transform:scale(0.9);} 10% {opacity:1;transform:scale(1);} 90% {opacity:1;} 100% {opacity:0;transform:scale(0.9);} }`;
document.head.appendChild(s);
}
container.appendChild(toast);
setTimeout(() => { toast.remove(); }, 3000);
};
// 4. 图片压缩模块 (原生极速版 - 支持点击与粘贴)
const initImageCompressor = () => {
// 配置常量
const MAX_SIDE = 800;
const QUALITY = 0.7;
// 核心压缩函数
const nativeCompress = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
const img = new Image();
img.src = e.target.result;
img.onload = () => {
let w = img.width, h = img.height;
if (w > MAX_SIDE || h > MAX_SIDE) {
const ratio = Math.min(MAX_SIDE / w, MAX_SIDE / h);
w = Math.floor(w * ratio);
h = Math.floor(h * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
canvas.toBlob((blob) => {
if (!blob) { reject(new Error("Canvas to Blob failed")); return; }
const newFile = new File([blob], file.name.replace(/\.[^/.]+$/, ".webp"), { type: 'image/webp', lastModified: Date.now() });
resolve(newFile);
}, 'image/webp', QUALITY);
};
img.onerror = reject;
};
reader.onerror = reject;
});
};
// 1. 拦截点击上传 (Input Change)
document.addEventListener('change', async (e) => {
if (!currentConfig.compressEnabled) return;
const target = e.target;
if (target.tagName !== 'INPUT' || target.type !== 'file') return;
// 避免死循环:如果标记为已压缩,则放行,并重置标记以便下次使用
if (target.dataset.compressed === 'true') {
target.dataset.compressed = '';
return;
}
const files = target.files;
if (!files || files.length === 0) return;
let hasImage = false;
for (let i = 0; i < files.length; i++) {
if (files[i].type.startsWith('image/')) { hasImage = true; break; }
}
if (!hasImage) return;
e.stopImmediatePropagation();
e.preventDefault();
showToast('⚡ 正在压缩选中的图片...', 'info');
const dt = new DataTransfer();
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith('image/')) {
const compressedFile = await nativeCompress(file);
dt.items.add(compressedFile);
log(`📉 点击压缩: ${file.name} ${(file.size/1024).toFixed(0)}KB -> ${(compressedFile.size/1024).toFixed(0)}KB`);
} else {
dt.items.add(file);
}
}
target.files = dt.files;
target.dataset.compressed = 'true'; // 标记
showToast('压缩完成,上传中', 'success');
target.dispatchEvent(new Event('change', { bubbles: true }));
} catch (err) {
console.error(err);
showToast('压缩失败,使用原图', 'error');
target.dataset.compressed = 'true';
target.dispatchEvent(new Event('change', { bubbles: true }));
}
}, true);
// 2. 拦截粘贴上传 (Paste)
document.addEventListener('paste', async (e) => {
if (!currentConfig.compressEnabled) return;
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
let imageItem = null;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
imageItem = items[i];
break;
}
}
if (!imageItem) return;
// 发现图片,拦截粘贴
e.preventDefault();
e.stopPropagation();
const file = imageItem.getAsFile();
showToast('⚡ 正在压缩粘贴的图片...', 'info');
try {
const compressedFile = await nativeCompress(file);
log(`📉 粘贴压缩: ${(file.size/1024).toFixed(0)}KB -> ${(compressedFile.size/1024).toFixed(0)}KB`);
// 寻找页面上的文件上传控件
// 策略:找 input[type=file],通常聊天框附近会有一个
const fileInput = document.querySelector('input[type="file"][accept*="image"]') || document.querySelector('input[type="file"]');
if (fileInput) {
const dt = new DataTransfer();
dt.items.add(compressedFile);
fileInput.files = dt.files;
fileInput.dataset.compressed = 'true'; // 标记为已压缩,防止 Change 监听器再次处理
showToast('压缩完成,上传中', 'success');
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
} else {
showToast('无法找到上传通道,请尝试点击按钮上传', 'error');
}
} catch (err) {
console.error(err);
showToast('粘贴压缩失败', 'error');
}
}, true);
};
createChatUI();
initImageCompressor();
return; // 退出,防止后续阅读器逻辑执行
}
// ============================================================
// 📖 【阅读模式 (Reader Mode)】
// ============================================================
// 🎵 音效
const SOUND_URL = "http://117.21.200.182:9112/touzi.mp3";
let soundBlobUrl = null;
// 🎵 全局音乐播放器
let globalBgm = null;
let stopBgmBtn = null;
function preloadSound() {
if (soundBlobUrl) return;
GM_xmlhttpRequest({
method: "GET", url: SOUND_URL, responseType: "blob",
onload: (res) => { if (res.status === 200) soundBlobUrl = URL.createObjectURL(res.response); }
});
}
function playDiceSound() {
if (!soundBlobUrl) { preloadSound(); return; }
const audio = new Audio(soundBlobUrl);
audio.currentTime = 0;
audio.play().catch(() => {});
}
// 记录当前正在播放的按钮元素
let currentPlayingBtn = null;
function playMusic(url, title, btnElement) {
// 如果点击的是当前正在播放的按钮,则执行手动停止(带淡出)
if (currentPlayingBtn === btnElement && globalBgm) {
stopMusic(false, true);
return;
}
// 切歌:静默停止旧歌,带淡出(实现交叉淡入淡出效果)
stopMusic(true, true);
const audio = new Audio(url);
audio.loop = true;
audio.volume = 0; // 🔇 初始静音,准备淡入
globalBgm = audio;
// 更新按钮状态
if (btnElement) {
btnElement.classList.add('playing');
currentPlayingBtn = btnElement;
}
// 定义成功回调 (复用)
const onPlaySuccess = () => {
showStopBtn(true, title);
// 📈 淡入逻辑 (2秒内从0升到1)
let vol = 0;
const fadeIn = setInterval(() => {
if (globalBgm !== audio || audio.paused) {
clearInterval(fadeIn);
return;
}
vol = Math.min(1, vol + 0.05);
audio.volume = vol;
if (vol >= 1) clearInterval(fadeIn);
}, 100);
};
audio.play().then(onPlaySuccess).catch(e => {
// ⚡ 修复:捕获自动播放被拦截的错误 (NotAllowedError)
if (e.name === 'NotAllowedError') {
console.warn("[GULULU] 自动播放被拦截,等待用户交互...");
// 添加一次性监听器,一旦用户点击页面,立即尝试恢复播放
const resume = () => {
// 只有当当前 BGM 依然是这首歌时才播放 (防止用户已经切歌)
if (globalBgm === audio) {
audio.play().then(onPlaySuccess).catch(() => {});
}
};
['click', 'keydown', 'touchstart'].forEach(evt =>
document.addEventListener(evt, resume, { once: true })
);
} else {
console.error(e);
showToast(`播放失败: 无法加载音频`, 'error');
stopMusic(true, false); // 出错立即停止,不淡出
}
});
audio.onerror = () => {
showToast(`播放出错`, 'error');
stopMusic(true, false);
};
}
function stopMusic(isSilent = false, fade = true) {
if (globalBgm) {
const audioToStop = globalBgm;
globalBgm = null; // ⚡ 立即解绑,允许新歌接管 globalBgm
if (fade && !audioToStop.paused) {
// 📉 淡出逻辑 (1秒内从当前音量降到0)
let vol = audioToStop.volume;
const fadeOut = setInterval(() => {
vol = Math.max(0, vol - 0.1);
audioToStop.volume = vol;
if (vol <= 0) {
audioToStop.pause();
clearInterval(fadeOut);
}
}, 100);
} else {
audioToStop.pause();
}
if (!isSilent) showToast('⏹️ 音乐已停止', 'info');
}
// 重置按钮状态
if (currentPlayingBtn) {
currentPlayingBtn.classList.remove('playing');
currentPlayingBtn = null;
}
document.querySelectorAll('.g-music-key.playing').forEach(el => el.classList.remove('playing'));
showStopBtn(false);
}
// 🌍 暴露给手机版调用 (挂载到 unsafeWindow 以便跨脚本通信)
const targetWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
targetWindow.GululuHelper = targetWindow.GululuHelper || {};
targetWindow.GululuHelper.stopMusic = stopMusic;
function showStopBtn(show, title) {
if (!stopBgmBtn) {
stopBgmBtn = document.createElement('div');
stopBgmBtn.id = 'g-stop-bgm-btn';
stopBgmBtn.innerHTML = '⏹️';
// 样式:位于魔法骰子(bottom:85px)的上方
stopBgmBtn.style.cssText = `
position: fixed; bottom: 140px; right: 30px; width: 40px; height: 40px;
background: #e17055; color: white; border-radius: 50%;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); text-align: center;
line-height: 40px; cursor: pointer; z-index: 9999; font-size: 18px;
transition: all 0.2s; display: none; border: 2px solid white;
`;
stopBgmBtn.title = "点击停止播放";
stopBgmBtn.addEventListener('click', () => stopMusic(false));
document.body.appendChild(stopBgmBtn);
// 旋转动画样式
const style = document.createElement('style');
style.innerHTML = `@keyframes g-spin { 0% {transform: rotate(0deg);} 100% {transform: rotate(360deg);} }`;
document.head.appendChild(style);
}
if (show) {
stopBgmBtn.style.display = 'block';
stopBgmBtn.style.animation = 'g-spin 4s linear infinite';
stopBgmBtn.title = `正在播放: ${title} (点击停止)`;
} else {
stopBgmBtn.style.display = 'none';
stopBgmBtn.style.animation = 'none';
}
}
// 📚 配置
const bookMatch = window.location.href.match(/\/book\/(\d+)/);
const BOOK_ID = bookMatch ? bookMatch[1] : 'global';
const BOOK_STORAGE_KEY = `gululu_book_${BOOK_ID}_unlocked_v6`;
// loadConfig 已移至全局
function saveConfig(c) {
localStorage.setItem('gululu_global_config_v6', JSON.stringify(c));
// 状态同步
if (c.danmakuEnabled) document.body.classList.add('g-danmaku-mode');
else document.body.classList.remove('g-danmaku-mode');
if (c.nightModeEnabled) document.body.classList.add('g-night-mode');
else document.body.classList.remove('g-night-mode');
}
function loadUnlocked() {
const s = localStorage.getItem(BOOK_STORAGE_KEY);
return new Set(s ? JSON.parse(s) : []);
}
function saveUnlocked(uid) {
const s = loadUnlocked(); s.add(uid);
let arr = Array.from(s); if(arr.length > 3000) arr = arr.slice(-2000);
localStorage.setItem(BOOK_STORAGE_KEY, JSON.stringify(arr));
}
function clearBookData() {
if(confirm(`重置书籍 (ID:${BOOK_ID}) 进度?`)) {
localStorage.removeItem(BOOK_STORAGE_KEY); location.reload();
}
}
let currentConfig = loadConfig();
if (currentConfig.danmakuEnabled) document.body.classList.add('g-danmaku-mode');
if (currentConfig.nightModeEnabled) document.body.classList.add('g-night-mode');
// mobileModeEnabled 已经在全局初始化时处理过了,这里不需要重复调用
// 🎨 样式
const readerStyle = document.createElement('style');
readerStyle.innerHTML = `
/* --- 🌙 夜间模式 (Night Mode) --- */
body.g-night-mode {
background-color: #121212 !important; /* 更深的背景,突出卡片 */
color: #b2bec3 !important;
}
/* 1. 顶部导航栏 (Header) */
body.g-night-mode header[class*="Header_header"],
body.g-night-mode .Header_header__YHF2u {
background-color: #1e1e1e !important;
border-bottom: 1px solid #333 !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.5) !important;
}
/* Logo 和图标反白 */
body.g-night-mode .Header_logo__6cuuw,
body.g-night-mode .Header_naviItem__32HHW,
body.g-night-mode .Header_naviRightItem__1dULT {
color: #ddd !important;
}
body.g-night-mode .Header_logo__6cuuw svg path {
fill: #ddd !important;
fill-opacity: 1 !important;
}
/* 搜索框 */
body.g-night-mode .SearchBar_containerInner__O921h {
background-color: #333 !important;
border: 1px solid #555 !important;
}
body.g-night-mode .SearchBar_input__whYqF {
color: #fff !important;
}
body.g-night-mode .SearchBar_searchIcon__G9TQS {
color: #aaa !important;
}
/* 2. 左侧工具栏 (Toolbar) */
body.g-night-mode button[class*="Toolbar_actionButton"],
body.g-night-mode .Toolbar_actionButton___zcJm {
background-color: #2d2d2d !important;
border-color: #444 !important;
box-shadow: 0 2px 5px rgba(0,0,0,0.3) !important;
}
/* 图标变亮 */
body.g-night-mode button[class*="Toolbar_actionButton"] svg path,
body.g-night-mode .Toolbar_actionButton___zcJm svg path {
fill: #b2bec3 !important;
}
/* 文字变亮 */
body.g-night-mode button[class*="Toolbar_actionButton"] span,
body.g-night-mode .Toolbar_actionButton___zcJm span {
color: #b2bec3 !important;
}
/* 3. 内容卡片 (Content Card) - 稍微亮一点,形成层次 */
body.g-night-mode .ContentCard_card__1XvW6,
body.g-night-mode [class*="ContentCard_card"] {
background-color: #1e1e1e !important;
border: 1px solid #333 !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.3) !important;
color: #b2bec3 !important;
}
/* 楼层内容区域 */
body.g-night-mode [class*="FloorContent_richText"] {
background-color: transparent !important; /* 继承卡片背景 */
color: #b2bec3 !important;
}
/* 4. 网页大背景 (Global Background) */
/* 基础深色底 (仅 body) */
body.g-night-mode {
background-color: #121212 !important;
}
/* ⚡ 核心修复:淡出网站原生的背景图片容器 */
/* 关键修改:移除 background: none 和 visibility: hidden,确保 opacity 过渡能被肉眼看到 */
body.g-night-mode div[class*="Background_backgroundRead"],
body.g-bg-active div[class*="Background_backgroundRead"] {
opacity: 0 !important;
pointer-events: none !important;
}
/* ⚡ 修复:默认夜间模式下,容器必须是深色 (兜底) */
body.g-night-mode #__next,
body.g-night-mode #root,
body.g-night-mode #pageScrollContainer,
body.g-night-mode main,
body.g-night-mode div[class*="Layout_"],
body.g-night-mode div[class*="book_read"] {
background-color: #121212 !important;
position: relative !important;
z-index: 1 !important;
}
/* 降低图片亮度,保护眼睛 */
body.g-night-mode img {
filter: brightness(0.7) !important;
transition: filter 0.3s;
}
body.g-night-mode img:hover {
filter: brightness(1) !important;
}
/* 骰子和引用块的适配 */
body.g-night-mode .g-dice-mask {
background-color: #2d3436 !important;
border-color: #555 !important;
}
body.g-night-mode .g-fold-controller {
background: #2d2d2d !important;
color: #aaa !important;
border-color: #555 !important;
}
body.g-night-mode .g-fold-controller:hover {
background: #383838 !important;
color: #ddd !important;
}
/* 评论区侧边栏适配 */
body.g-night-mode div[class*="CommentBlock_container"] {
background-color: #1e1e1e !important;
}
/* 评论列表背景 */
body.g-night-mode div[class*="CommentBlock_commentList"] {
background-color: #1e1e1e !important;
}
/* 评论项文字颜色适配 */
body.g-night-mode div[class*="CommentBlock_nickName"],
body.g-night-mode div[class*="CommentBlock_commentContent"] {
color: #dcdde1 !important;
}
body.g-night-mode div[class*="CommentBlock_commentTime"] {
color: #718093 !important;
}
/* 点赞图标和数字 */
body.g-night-mode div[class*="CommentBlock_likeView"] svg path {
fill: #7f8fa6 !important;
}
body.g-night-mode div[class*="CommentBlock_likeView"] span {
color: #7f8fa6 !important;
}
/* 回复按钮 */
body.g-night-mode button[class*="CommentBlock_replyBtn"] {
color: #a4b0be !important;
}
body.g-night-mode button[class*="CommentBlock_replyBtn"]:hover {
color: #fff !important;
}
/* 卡片底部评论展开按钮 */
body.g-night-mode button[class*="ContentCard_comment"] {
color: #b2bec3 !important;
}
body.g-night-mode button[class*="ContentCard_comment"] svg path {
fill: #b2bec3 !important;
}
/* 底部输入框容器 (消除白边) */
body.g-night-mode div[class*="CommentBlock_bottomContainer"] {
background-color: #2f3640 !important;
border-top: 1px solid #333 !important;
}
body.g-night-mode div[class*="commentItem"] {
border-bottom-color: #333 !important;
}
/* 子评论夜间模式适配 */
body.g-night-mode div[class*="SubCommentList_subCommentContent"],
body.g-night-mode span[class*="SubCommentList_nickName"] {
color: #b2bec3 !important;
}
body.g-night-mode div[class*="SubCommentList_subCommentContent"] span[style*="color"] {
color: #74b9ff !important; /* @用户名 颜色 */
}
body.g-night-mode div[class*="SubCommentList_commentTime"] {
color: #636e72 !important;
}
/* 子评论回复按钮 */
body.g-night-mode button[class*="SubCommentList_replyBtn"] {
color: #a4b0be !important;
}
body.g-night-mode button[class*="SubCommentList_replyBtn"]:hover {
color: #fff !important;
}
/* 子评论点赞 */
body.g-night-mode div[class*="SubCommentList_likeView"] svg path {
fill: #636e72 !important;
}
body.g-night-mode div[class*="SubCommentList_likeView"] span {
color: #636e72 !important;
}
body.g-night-mode .semi-input-textarea,
body.g-night-mode textarea,
body.g-night-mode input {
background-color: #353b48 !important;
color: #f5f6fa !important;
border-color: #555 !important;
}
/* 选中文字颜色 */
body.g-night-mode ::selection {
background: #fdcb6e !important;
color: #000 !important;
}
/* 修复:夜间模式下段落评论气泡不可见 */
body.g-night-mode span[class*="RichTextParagraph_inlineCommentNumber"] svg path {
stroke: #eee !important;
}
body.g-night-mode span[class*="RichTextParagraph_commentNumber"] {
color: #eee !important;
}
/* === 修复 PC 端夜间模式子评论可见性 === */
/* 子评论内容 */
body.g-night-mode div[class*="SubCommentList_subCommentContent"],
body.g-night-mode span[class*="SubCommentList_nickName"] b {
color: #b2bec3 !important;
}
/* @用户 高亮 */
body.g-night-mode div[class*="SubCommentList_subCommentContent"] span[style*="color"] {
color: #74b9ff !important;
}
/* 时间信息 */
body.g-night-mode div[class*="SubCommentList_commentTime"] {
color: #636e72 !important;
}
/* 底部回复按钮 */
body.g-night-mode button[class*="SubCommentList_replyBtn"] {
color: #a4b0be !important;
}
body.g-night-mode button[class*="SubCommentList_replyBtn"]:hover {
color: #fff !important;
}
/* 点赞图标和数字 */
body.g-night-mode div[class*="SubCommentList_likeView"] svg path {
fill: #636e72 !important;
}
body.g-night-mode div[class*="SubCommentList_likeView"] span {
color: #636e72 !important;
}
/* 手机版子评论夜间模式适配 (主文件兜底) */
body.g-night-mode .gm-sub-cmt-list { background-color: #252525 !important; border-color: #333 !important; }
body.g-night-mode .gm-sub-cmt-item { color: #ccc !important; border-color: #333 !important; }
body.g-night-mode .gm-sub-content { color: #ccc !important; }
/* --- 弹幕模式 V3.37 --- */
body.g-danmaku-mode div[class*="CommentBlock_container"][data-visible="false"] {
display: none !important; opacity: 0 !important; pointer-events: none !important;
}
body.g-danmaku-mode div[class*="CommentBlock_container"][data-visible="true"] {
position: fixed !important; inset: 0 !important;
width: 100% !important; height: 100% !important;
z-index: 9999 !important; opacity: 1 !important; visibility: visible !important;
background: transparent !important; pointer-events: none !important; transform: none !important;
}
body.g-danmaku-mode div[class*="CommentBlock_commentList"],
body.g-danmaku-mode div[class*="semi-empty"],
body.g-danmaku-mode h6 { display: none !important; }
body.g-danmaku-mode div[class*="CommentBlock_bottomContainer"] {
position: fixed !important; bottom: 30px !important; left: 50% !important;
transform: translateX(-50%) !important; width: 500px !important; max-width: 80% !important;
background: rgba(255, 255, 255, 0.95) !important; border-radius: 30px !important;
box-shadow: 0 4px 20px rgba(0,0,0,0.2) !important; padding: 10px 20px !important;
pointer-events: auto !important; z-index: 10000 !important;
display: flex !important; align-items: center !important;
border: 1px solid rgba(0,0,0,0.1) !important;
}
body.g-danmaku-mode button[class*="CommentBlock_closeBtn"] {
position: fixed !important; bottom: 35px !important; left: 50% !important; margin-left: 280px !important;
top: auto !important; right: auto !important; width: 36px !important; height: 36px !important;
background: #fff !important; color: #555 !important; border-radius: 50% !important;
box-shadow: 0 2px 10px rgba(0,0,0,0.15) !important; pointer-events: auto !important;
z-index: 10001 !important; display: flex !important; align-items: center; justify-content: center;
border: 1px solid rgba(0,0,0,0.1) !important; transition: all 0.2s !important;
}
body.g-danmaku-mode button[class*="CommentBlock_closeBtn"]:hover {
background: #f0f0f0 !important; transform: scale(1.1);
}
@media (prefers-color-scheme: dark) {
body.g-danmaku-mode div[class*="CommentBlock_bottomContainer"] {
background: rgba(40, 40, 40, 0.95) !important; border-color: #555 !important;
}
body.g-danmaku-mode button[class*="CommentBlock_closeBtn"] {
background: #333 !important; color: #ddd !important; border-color: #555 !important;
}
}
@media (max-width: 768px) {
body.g-danmaku-mode div[class*="CommentBlock_bottomContainer"] {
width: 75% !important; margin-left: -25px; bottom: 20px !important;
}
body.g-danmaku-mode button[class*="CommentBlock_closeBtn"] {
margin-left: 0 !important; left: auto !important; right: 15px !important; bottom: 25px !important;
}
}
#g-danmaku-layer {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 999998; overflow: hidden;
}
.g-danmaku-item {
position: absolute; white-space: nowrap;
color: #fff; text-shadow: 2px 2px 0px #000, -1px -1px 0px #000;
font-size: 24px; font-weight: bold; opacity: 0.95;
transform: translateX(100vw); will-change: transform;
font-family: "Microsoft YaHei", sans-serif;
}
.g-danmaku-self {
border: 2px solid #fff; border-radius: 8px; padding: 2px 10px;
background: rgba(0,0,0,0.3); box-shadow: 0 0 10px rgba(255,255,255,0.5);
z-index: 999999; color: #ffd700;
}
@keyframes g-danmaku-move {
from { transform: translateX(100vw); }
to { transform: translateX(-100%); }
}
.g-dice-mask {
display: inline-flex; align-items: center; justify-content: center;
background-color: #333; color: transparent !important;
border-radius: 4px; padding: 0 6px; margin: 0 2px;
cursor: pointer; border: 1px solid #555;
min-width: 24px; height: 1.4em; vertical-align: text-bottom;
position: relative; font-size: 14px; line-height: 1.4;
}
.g-dice-mask::after { content: "🎲"; color: #fff; position: absolute; font-size: 12px; pointer-events: none; }
.g-dice-mask:hover { background-color: #555; transform: scale(1.05); transition: 0.2s; }
.g-dice-revealed {
background: transparent; color: #d63031 !important; font-weight: bold; padding: 0 2px;
cursor: default; animation: g-pop 0.3s ease-out;
}
@keyframes g-pop { 0% { transform: scale(0.8); opacity: 0.5; } 100% { transform: scale(1); opacity: 1; } }
.g-hidden-block { display: none !important; }
.g-fade-in { animation: g-fade 0.5s ease-out; }
@keyframes g-fade { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
.g-fold-controller {
background: rgba(0, 0, 0, 0.04); border: 1px dashed rgba(127, 127, 127, 0.5);
border-radius: 6px; margin: 10px 0 4px 0; padding: 8px 12px;
cursor: pointer; font-weight: bold; color: #444; user-select: none;
display: flex; align-items: center; font-size: 14px; transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.g-fold-controller:hover { background: rgba(0, 0, 0, 0.08); border-color: rgba(127, 127, 127, 0.8); }
.g-folded-content {
border-left: 2px dashed rgba(127,127,127,0.3); padding-left: 18px;
margin-left: 10px; background: transparent; position: relative;
}
@media (prefers-color-scheme: dark) {
.g-fold-controller { background: #2d2d2d; border-color: #555; color: #ddd; }
.g-fold-controller:hover { background: #383838; }
.g-folded-content { border-left-color: rgba(255,255,255,0.2); }
}
#g-setting-btn {
position: fixed; bottom: 30px; right: 30px; width: 40px; height: 40px; background: #fff;
border-radius: 50%; box-shadow: 0 4px 12px rgba(0,0,0,0.15); text-align: center;
line-height: 40px; cursor: pointer; z-index: 9999; font-size: 20px; transition: transform 0.3s;
}
#g-setting-btn:hover { transform: rotate(90deg); }
#g-setting-panel {
position: fixed; bottom: 30px; right: 80px; background: #fff; padding: 15px;
border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); z-index: 1000005; display: none; font-size: 14px;
border: 1px solid #f0f0f0; min-width: 220px;
}
#g-magic-btn {
position: fixed; bottom: 85px; right: 30px; width: 40px; height: 40px;
background: rgba(255, 215, 0, 0.85); /* 半透明金色 */
border-radius: 50%; box-shadow: 0 4px 12px rgba(0,0,0,0.15); text-align: center; color: white;
line-height: 40px; cursor: pointer; z-index: 9999; font-size: 20px; transition: all 0.2s;
border: 1px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(2px);
}
#g-magic-btn:hover {
transform: scale(1.1) rotate(-15deg);
background: #ffd700; /* 纯金 */
box-shadow: 0 0 15px rgba(255, 215, 0, 0.6);
}
#g-magic-btn::after {
content: "揭示接下来的10个骰子";
position: absolute; right: 50px; top: 8px;
background: #333; color: #fff; padding: 4px 8px;
border-radius: 4px; font-size: 12px; white-space: nowrap;
opacity: 0; pointer-events: none; transition: opacity 0.2s;
}
#g-magic-btn:hover::after { opacity: 1; right: 55px; }
.g-setting-item { margin-bottom: 10px; display: flex; align-items: center; cursor: pointer; }
.g-setting-item input { margin-right: 8px; }
.g-btn-danger { width: 100%; padding: 8px; background: #ff7675; color: white; border: none; border-radius: 4px; cursor: pointer; margin-top: 10px; }
.g-badge-new { background: #00b894; color: white; font-size: 10px; padding: 2px 4px; border-radius: 4px; margin-left: 5px; }
/* --- 秘宝系统 --- */
.g-secret-chest {
display: inline-block; padding: 8px 12px; margin: 5px 0;
background: #2d3436; color: #fdcb6e; border: 2px solid #fdcb6e;
border-radius: 8px; cursor: pointer; font-weight: bold;
box-shadow: 0 4px 0 #b2bec3; transition: all 0.1s; user-select: none;
}
.g-secret-chest:active { transform: translateY(4px); box-shadow: 0 0 0 #b2bec3; }
.g-secret-chest.unlocked {
background: #fff; color: #2d3436; border-color: #2d3436; cursor: default;
box-shadow: none; transform: none; border-style: dashed;
}
.g-clue-key {
display: inline-block; padding: 5px 10px; margin: 5px 0;
background: #dfe6e9; color: #b7791f; border-radius: 20px; border: 1px solid #b7791f;
cursor: pointer; font-size: 12px; transition: all 0.2s; font-weight: bold;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.g-clue-key:hover { transform: scale(1.05); background: #fff; }
.g-clue-key.collected { background: #b2bec3; color: #636e72; border-color: #636e72; cursor: default; }
/* ⚡ 核弹级权重修复:使用 html body 前缀强制覆盖 */
html body .g-music-key {
display: inline-flex; align-items: center; padding: 6px 16px; margin: 4px 6px;
background-color: #6c5ce7 !important; /* 兜底纯色 */
background-image: linear-gradient(135deg, #6c5ce7, #a29bfe) !important;
color: #fff !important; border-radius: 50px !important;
cursor: pointer; font-size: 14px; font-weight: bold;
box-shadow: 0 4px 15px rgba(108, 92, 231, 0.3) !important; transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
user-select: none; border: 2px solid rgba(255,255,255,0.2) !important;
text-shadow: 0 1px 2px rgba(0,0,0,0.1);
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
html body .g-music-key::before {
content: "▶"; margin-right: 8px; font-size: 12px;
display: inline-block; transition: transform 0.2s;
}
html body .g-music-key:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 6px 20px rgba(108, 92, 231, 0.4) !important;
background-image: linear-gradient(135deg, #5f4dd0, #9086fc) !important;
}
html body .g-music-key:active { transform: scale(0.95); }
/* 播放中状态 */
html body .g-music-key.playing {
background-color: #00b894 !important;
background-image: linear-gradient(135deg, #00b894, #55efc4) !important;
box-shadow: 0 4px 15px rgba(0, 184, 148, 0.4) !important;
padding-right: 20px;
}
.g-music-key.playing::before {
content: "⏸";
animation: g-pulse 1s infinite;
}
.g-music-key.playing::after {
content: " ";
display: inline-block; width: 8px; height: 8px;
background: #fff; border-radius: 50%; margin-left: 10px;
animation: g-beat 1s infinite;
}
@keyframes g-pulse { 0% { transform: scale(1); } 50% { transform: scale(1.2); } 100% { transform: scale(1); } }
@keyframes g-beat { 0% { opacity: 0.5; transform: scale(0.8); } 50% { opacity: 1; transform: scale(1.2); } 100% { opacity: 0.5; transform: scale(0.8); } }
/* --- 自定义提示框 --- */
#g-toast-container {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
z-index: 10000002; pointer-events: none; width: max-content; max-width: 80%;
}
.g-toast {
background: rgba(0, 0, 0, 0.85); color: #fff; padding: 20px 40px;
border-radius: 16px; margin-bottom: 15px; font-size: 18px; font-weight: bold;
box-shadow: 0 8px 30px rgba(0,0,0,0.3); text-align: center;
animation: g-fade-in-out 3s forwards; pointer-events: auto;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(5px); border: 1px solid rgba(255,255,255,0.1);
}
@keyframes g-fade-in-out {
0% { opacity: 0; transform: scale(0.9); }
10% { opacity: 1; transform: scale(1); }
90% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(0.9); }
}
/* --- 🎬 视效系统 (VFX) --- */
#g-vfx-canvas {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
pointer-events: none; z-index: 999990; opacity: 0.8;
display: none;
}
/* 地震动画 */
@keyframes g-shake {
0% { transform: translate(1px, 1px) rotate(0deg); }
10% { transform: translate(-1px, -2px) rotate(-1deg); }
20% { transform: translate(-3px, 0px) rotate(1deg); }
30% { transform: translate(3px, 2px) rotate(0deg); }
40% { transform: translate(1px, -1px) rotate(1deg); }
50% { transform: translate(-1px, 2px) rotate(-1deg); }
60% { transform: translate(-3px, 1px) rotate(0deg); }
70% { transform: translate(3px, 1px) rotate(-1deg); }
80% { transform: translate(-1px, -1px) rotate(1deg); }
90% { transform: translate(1px, 2px) rotate(0deg); }
100% { transform: translate(1px, -2px) rotate(-1deg); }
}
.g-vfx-quake {
animation: g-shake 0.5s cubic-bezier(.36,.07,.19,.97) infinite !important;
}
/* 闪电覆盖层 */
#g-vfx-flash {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: #fff; opacity: 0; pointer-events: none; z-index: 999991;
mix-blend-mode: overlay;
}
@keyframes g-flash-anim {
0%, 100% { opacity: 0; }
2%, 8% { opacity: 0.8; }
4% { opacity: 0.1; }
6% { opacity: 0.6; }
}
.g-vfx-flash-active {
animation: g-flash-anim 4s infinite random !important;
}
/* --- 🌌 氛围背景系统 --- */
#g-bg-layer {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
z-index: -1; /* 图片层位于原生背景之下(需配合原生背景透明化) */
background-size: cover; background-position: center; background-repeat: no-repeat;
opacity: 0;
transition: opacity 0.8s ease-in-out, filter 0.5s;
pointer-events: none;
filter: blur(8px) brightness(0.95);
}
#g-bg-backdrop {
z-index: -2 !important; /* 最底层黑色底板 */
}
/* === 核心过渡修复 V2 === */
/* 1. 全局过渡定义:赋予所有相关容器动画能力 (常驻) */
/* 只要涉及背景或透明度的,全部加上 0.8s 过渡 */
html, body, #__next, #root, #pageScrollContainer, main,
div[class*="Layout_"],
div[class*="Background_"] {
transition: background-color 0.8s ease, opacity 0.8s ease !important;
}
/* 2. 激活状态:根节点背景透明化 */
html.g-bg-active,
body.g-bg-active {
background-color: transparent !important;
}
/* 3. 激活状态:原生背景容器淡出 (Opacity -> 0) */
/* 注意:这里通过 opacity 控制显隐,利用上面的 transition 实现渐变 */
body.g-bg-active div[class*="Background_"] {
opacity: 0 !important;
pointer-events: none !important;
}
/* 4. 激活状态:内容白底容器透明化 (Background-color -> transparent) */
/* 这里会触发 background-color 的 0.8s 过渡,实现“慢慢变透” */
body.g-bg-active #__next,
body.g-bg-active #root,
body.g-bg-active #pageScrollContainer,
body.g-bg-active main,
body.g-bg-active div[class*="Layout_"],
body.g-bg-active div[class*="book_read"] {
background-color: transparent !important;
}
/* 5. 卡片修正:给卡片加回半透明底色,防止文字悬空 */
div[class*="ContentCard_card"] {
transition: background-color 0.8s ease !important;
}
body.g-bg-active div[class*="ContentCard_card"] {
background-color: rgba(255, 255, 255, 0.85) !important;
}
body.g-night-mode.g-bg-active div[class*="ContentCard_card"] {
background-color: rgba(30, 30, 30, 0.85) !important;
}
/* 修复内容层级,确保文字浮在背景之上 */
div[class*="ContentCard_card"] {
position: relative !important;
z-index: 1 !important;
/* 给卡片加一个半透明底色,防止文字直接叠在花哨背景上看不清 */
background-color: rgba(255, 255, 255, 0.85) !important;
}
/* 夜间模式下的卡片底色适配 */
body.g-night-mode div[class*="ContentCard_card"] {
background-color: rgba(30, 30, 30, 0.85) !important;
}
/* 夜间模式:大幅度压暗,使用混合模式增强效果 */
body.g-night-mode #g-bg-layer {
filter: blur(10px) brightness(0.4) saturate(0.8) !important;
background-color: #000 !important; /* 黑色底色 */
}
/* --- 引用跳转块 --- */
.g-quote-box {
background: #f8f9fa; border-left: 4px solid #00cec9;
padding: 10px 15px; margin: 10px 0; border-radius: 0 8px 8px 0;
cursor: pointer; position: relative; transition: all 0.2s;
}
.g-quote-box:hover { background: #e2e6ea; transform: translateX(5px); }
.g-quote-icon {
position: absolute; top: -10px; left: 10px; font-size: 24px; color: #00cec9; opacity: 0.3;
}
.g-quote-content { font-style: italic; color: #636e72; font-size: 0.95em; }
.g-quote-info {
font-size: 10px; color: #b2bec3; text-align: right; margin-top: 5px;
}
/* 夜间模式适配 */
body.g-night-mode .g-quote-box {
background: #2d3436; border-left-color: #00cec9;
}
body.g-night-mode .g-quote-box:hover { background: #353b48; }
body.g-night-mode .g-quote-content { color: #b2bec3; }
/* === 核心过渡修复 Final === */
/* 1. 必须给所有层级强制加上过渡,否则类名切换时会瞬间跳变 */
html, body,
div[class*="Background_"],
#__next, #root, #pageScrollContainer, main, div[class*="Layout_"], div[class*="book_read"],
div[class*="ContentCard_card"] {
transition: background-color 0.8s ease-in-out, opacity 0.8s ease-in-out !important;
}
/* 2. 激活状态:原生背景容器淡出 */
/* ❌ 绝对禁止使用 visibility: hidden 或 display: none,否则过渡无效 */
body.g-bg-active div[class*="Background_"] {
opacity: 0 !important;
pointer-events: none !important;
}
/* 3. 激活状态:根容器背景色透明 */
/* ❌ 绝对禁止使用 background: none (会瞬间移除图片),只改颜色 */
html.g-bg-active,
body.g-bg-active {
background-color: transparent !important;
}
/* 4. 激活状态:内容容器透明 */
body.g-bg-active #__next,
body.g-bg-active #root,
body.g-bg-active #pageScrollContainer,
body.g-bg-active main,
body.g-bg-active div[class*="Layout_"],
body.g-bg-active div[class*="book_read"] {
background-color: transparent !important;
}
/* 5. 卡片修正:保持半透明白底 */
div[class*="ContentCard_card"] {
transition: background-color 0.8s ease !important;
}
body.g-bg-active div[class*="ContentCard_card"] {
background-color: rgba(255, 255, 255, 0.85) !important;
}
body.g-night-mode.g-bg-active div[class*="ContentCard_card"] {
background-color: rgba(30, 30, 30, 0.85) !important;
}
`;
document.head.appendChild(readerStyle);
// ==========================================
// 🔔 提示系统 (Toast)
// ==========================================
const showToast = (msg, type = 'info') => {
let container = document.getElementById('g-toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'g-toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = 'g-toast';
let icon = '';
if (type === 'success') icon = '✅';
else if (type === 'error') icon = '❌';
else if (type === 'lock') icon = '🔒';
else if (type === 'info') icon = 'ℹ️';
// type === 'key' 时 icon 为空,不显示左侧独立图标,方便在 msg 中自定义排版
const iconHtml = icon ? `${icon}` : '';
toast.innerHTML = `${iconHtml}${msg.replace(/\n/g, '
')}`;
container.appendChild(toast);
setTimeout(() => { toast.remove(); }, 3000);
};
// ==========================================
// 📺 弹幕引擎
// ==========================================
let danmakuLayer = null;
const shootDanmaku = (text, delayMs, isSelf = false) => {
if (!danmakuLayer) {
danmakuLayer = document.createElement('div');
danmakuLayer.id = 'g-danmaku-layer';
document.body.appendChild(danmakuLayer);
}
setTimeout(() => {
const el = document.createElement('div');
el.className = 'g-danmaku-item';
el.innerText = text;
if (isSelf) {
el.classList.add('g-danmaku-self');
el.style.top = '50%';
el.style.zIndex = '9999999';
} else {
const topPercent = 5 + Math.random() * 55;
el.style.top = `${topPercent}%`;
}
const duration = 7 + Math.random() * 5;
el.style.animation = `g-danmaku-move ${duration}s linear forwards`;
danmakuLayer.appendChild(el);
setTimeout(() => { el.remove(); }, duration * 1000);
}, delayMs);
};
const fireComments = (commentElements) => {
const texts = Array.from(commentElements).map(c => c.textContent.trim());
if (texts.length > 0) {
log(`🚀 发射 ${texts.length} 条弹幕`);
texts.forEach((t, i) => shootDanmaku(t, i * 300));
}
};
const triggerSelfDanmaku = (textarea) => {
const text = textarea.value.trim();
if (text) {
log("📨 发射自评弹幕: ", text);
shootDanmaku(text, 0, true);
}
};
// 🌟 全局交互监听 (V3.37:强力滚动监听)
const initGlobalInteractions = () => {
const attemptClose = () => {
if (!currentConfig.danmakuEnabled) return;
// 📱 手机模式下,禁止因滚动而自动关闭输入框 (防止弹出键盘时视口变化触发误判)
if (globalConfig.mobileModeEnabled) return;
const sidebar = document.querySelector('div[class*="CommentBlock_container"]');
if (sidebar && sidebar.getAttribute('data-visible') === 'true') {
const closeBtn = sidebar.querySelector('button[class*="CommentBlock_closeBtn"]');
if (closeBtn) {
closeBtn.click();
}
}
};
// 1. 点击事件
document.body.addEventListener('click', (e) => {
if (!currentConfig.danmakuEnabled) return;
const sidebar = document.querySelector('div[class*="CommentBlock_container"]');
if (!sidebar || sidebar.getAttribute('data-visible') !== 'true') return;
const target = e.target;
const bottomBar = sidebar.querySelector('div[class*="CommentBlock_bottomContainer"]');
const closeBtn = sidebar.querySelector('button[class*="CommentBlock_closeBtn"]');
const isInsideInput = bottomBar && bottomBar.contains(target);
const isInsideClose = closeBtn && closeBtn.contains(target);
if (isInsideInput) {
if (target.tagName !== 'TEXTAREA') {
const textarea = bottomBar.querySelector('textarea');
if (textarea) triggerSelfDanmaku(textarea);
}
return;
}
if (isInsideClose) return; // 点击关闭按钮,交给系统默认逻辑
// 📱 手机模式下,点击遮罩(外部区域)不关闭输入框,必须使用物理关闭按钮
if (globalConfig.mobileModeEnabled) return;
if (closeBtn) {
closeBtn.click();
}
}, true);
// 2. 键盘事件
document.body.addEventListener('keydown', (e) => {
if (!currentConfig.danmakuEnabled) return;
const target = e.target;
if (target.tagName === 'TEXTAREA' && target.className && target.className.includes('CommentBlock_input')) {
if (e.key === 'Enter' && e.ctrlKey) {
triggerSelfDanmaku(target);
}
}
}, true);
// 3. 强力滚动监听 (节流优化)
const scrollTargets = [window, document, document.body];
const scrollHandler = throttle(() => {
attemptClose();
}, 300); // 300ms 检查一次,大幅降低 CPU 占用
scrollTargets.forEach(t => t.addEventListener('scroll', scrollHandler, { capture: true, passive: true }));
setTimeout(() => {
const container = document.querySelector('#pageScrollContainer');
if (container) container.addEventListener('scroll', scrollHandler, { capture: true, passive: true });
}, 2000);
// 4. 全局事件委托 (替代原本的 bindEvents)
document.body.addEventListener('click', (e) => {
const target = e.target;
// 处理:音乐播放
if (target.matches('.g-music-key')) {
const title = target.getAttribute('data-title');
const url = target.getAttribute('data-url');
if (title && url) {
if (url === 'STOP') {
stopMusic(); // 手动点击停止,允许弹窗
} else {
playMusic(url, title, target);
}
}
return;
}
// 处理:线索获取
if (target.matches('.g-clue-key')) {
const title = target.getAttribute('data-title');
const pwd = target.getAttribute('data-pwd');
if (title && pwd) {
const key = `gululu_secret_${BOOK_ID}_${title}`;
localStorage.setItem(key, pwd);
target.classList.add('collected');
target.innerHTML = `🔑 已记录线索:${title}`;
showToast(`🔑 线索已记录!
秘密 [${title}] 的密码是:${pwd}`, 'key');
}
return;
}
// 处理:秘密揭示
const chest = target.closest('.g-secret-chest');
if (chest && !chest.classList.contains('unlocked')) {
const title = chest.getAttribute('data-title');
const cipher = chest.getAttribute('data-cipher');
const key = `gululu_secret_${BOOK_ID}_${title}`;
const savedPwd = localStorage.getItem(key);
if (!savedPwd) {
showToast(`这是一个被加密的秘密 [${title}]
你还没有找到对应的线索(钥匙)`, 'lock');
return;
}
try {
const bytes = CryptoJS.AES.decrypt(cipher, savedPwd);
const originalText = bytes.toString(CryptoJS.enc.Utf8);
if (originalText) {
chest.classList.add('unlocked');
chest.innerHTML = `📖 [${title}]
${originalText.replace(/\n/g, '
')}`;
showToast(`🔑 秘密 [${title}] 已揭示!`, 'key');
} else {
showToast("密码错误或数据损坏!", 'error');
}
} catch (err) {
showToast("解密失败,密码可能不匹配。", 'error');
}
return;
}
// 处理:骰子遮罩点击
if (target.matches('.g-dice-mask')) {
e.preventDefault(); e.stopPropagation();
const container = target.closest('div[class*="FloorContent_richText"]') || target.closest('.gm-floor-body');
if (!container) return;
// ⚡ 快捷键功能:按住 Alt 点击,解锁当前楼层所有骰子
if (e.altKey) {
const allMasksInFloor = container.querySelectorAll('.g-dice-mask');
if (allMasksInFloor.length > 0) {
if (currentConfig.soundEnabled) playDiceSound();
allMasksInFloor.forEach(mask => {
const gid = mask.getAttribute('data-group-id');
if (gid) {
saveUnlocked(gid); // 逐个保存状态
// 视觉揭开
const sameGroup = container.querySelectorAll(`[data-group-id="${gid}"]`);
sameGroup.forEach(m => m.className = 'g-dice-revealed');
// 迷雾解锁
if (currentConfig.fogModeEnabled) {
const lockedElements = container.querySelectorAll(`.g-hidden-block[data-fog-lock="${gid}"]`);
lockedElements.forEach(el => {
el.classList.remove('g-hidden-block');
el.classList.add('g-fade-in');
});
}
}
});
log(`⚡ 批量解锁了本层 ${allMasksInFloor.length} 个骰子`);
}
return;
}
// 普通点击:单点解锁
const groupId = target.getAttribute('data-group-id');
if (!groupId) return;
if (currentConfig.soundEnabled) playDiceSound();
const groupMasks = container.querySelectorAll(`[data-group-id="${groupId}"]`);
groupMasks.forEach(elm => {
elm.className = 'g-dice-revealed';
});
saveUnlocked(groupId);
if (currentConfig.fogModeEnabled) {
const lockedElements = container.querySelectorAll(`.g-hidden-block[data-fog-lock="${groupId}"]`);
lockedElements.forEach(el => {
el.classList.remove('g-hidden-block');
el.classList.add('g-fade-in');
});
}
return;
}
// 处理:折叠栏点击
const foldController = target.closest('.g-fold-controller');
if (foldController) {
const wasClosed = foldController.dataset.closed === 'true';
const isNowClosed = !wasClosed; // 计算新状态
foldController.dataset.closed = isNowClosed.toString();
const floor = foldController.closest('div[class*="FloorContent_richText"]') || foldController.closest('.gm-floor-body');
if (floor) {
const groupItems = floor.querySelectorAll(`[data-fold-owner="${foldController.id}"]`);
groupItems.forEach(item => {
// 如果现在是关闭的(isNowClosed=true),则添加 hidden (true)
item.classList.toggle('g-hidden-block', isNowClosed);
});
}
const title = foldController.getAttribute('data-title') || "详细内容";
// 根据新状态更新 UI
const arrow = isNowClosed ? '▶' : '▼';
const tip = isNowClosed ? '(点击展开)' : '(点击折叠)';
foldController.innerHTML = `${arrow} 🎲 ${title} ${tip}`;
}
});
};
const initCommentObserver = () => {
let retryCount = 0;
const tryFindSidebar = () => {
const sidebar = document.querySelector('div[class*="CommentBlock_container"]');
if (sidebar) {
log("✅ 弹幕系统就绪");
const attrObserver = new MutationObserver((mutations) => {
if (!currentConfig.danmakuEnabled) return;
mutations.forEach(m => {
if (m.attributeName === 'data-visible') {
const isVisible = sidebar.getAttribute('data-visible') === 'true';
if (isVisible) {
log("🔓 评论区开启 -> 弹幕模式");
handleSidebarOpen(sidebar);
}
}
});
});
attrObserver.observe(sidebar, { attributes: true });
initGlobalInteractions();
} else {
if (retryCount++ < 20) setTimeout(tryFindSidebar, 1000);
}
};
tryFindSidebar();
};
const handleSidebarOpen = (sidebarNode) => {
const existingComments = sidebarNode.querySelectorAll('div[class*="commentContent"]');
if (existingComments.length > 0) {
fireComments(existingComments);
return;
}
let hasFired = false;
const contentObserver = new MutationObserver(() => {
const newComments = sidebarNode.querySelectorAll('div[class*="commentContent"]');
if (newComments.length > 0 && !hasFired) {
hasFired = true;
contentObserver.disconnect();
fireComments(newComments);
}
});
contentObserver.observe(sidebarNode, { childList: true, subtree: true });
setTimeout(() => {
if (!hasFired) {
const emptyState = sidebarNode.querySelector('div[class*="semi-empty"]');
if (emptyState) shootDanmaku("还没有评论哦~ 来抢沙发吧!", 0);
contentObserver.disconnect();
}
}, 5000);
};
// ==========================================
// 🛠️ 核心引擎 (Observer 优化版)
// ==========================================
const createMaskHtml = (text, floorId, unlockedSet, globalIndexRef) => {
// 1. 宽泛匹配整个算式链条 + 后缀文本
// 核心正则:匹配骰子算式,并贪婪捕获后续非标签字符作为后缀
const chainDiceRegex = /((?:【?)\d+[dD]\d+(?:[^<]*?=\s*【?[\d\.]+】?)+)([^<]*)/gi;
return text.replace(chainDiceRegex, (match, wholeChain, suffix) => {
const index = globalIndexRef.count++;
const groupId = `${floorId}_g_${index}`;
const isUnlocked = unlockedSet.has(groupId);
const cls = isUnlocked ? 'g-dice-revealed' : 'g-dice-mask';
// 2. 处理核心算式部分 (替换等号后的数字)
let processedChain = wholeChain.replace(/(=)(\s*)(【[\d\.]+】|[\d\.]+)/g, (m, eq, space, num) => {
// 智能拆分括号:【99】 -> 【 + 99 + 】
let content = num;
let prefix = "", subSuffix = "";
if (num.startsWith('【') && num.endsWith('】')) {
prefix = "【"; subSuffix = "】";
content = num.slice(1, -1);
}
return `${eq}${space}${prefix}${content}${subSuffix}`;
});
// 3. 处理后缀部分 (大成功/大失败等)
if (suffix && suffix.trim().length > 0) {
// 如果后缀里包含 】 (例如格式混乱的情况),我们尽量保留它
// 但通常 wholeChain 已经吃掉了配对的 】
// 这里简单粗暴:直接遮罩后缀,除非它以 】 开头(可能是漏网之鱼)
if (suffix.startsWith('】')) {
const realSuffix = suffix.substring(1);
return `${processedChain}】${realSuffix}`;
}
return `${processedChain}${suffix}`;
}
return processedChain + suffix;
});
};
const processSingleFloor = (floor, unlockedSet) => {
// ⚠️ 移除顶部的 floor.dataset.processed 检查,允许增量更新
// 兼容 Mobile Reforged 的 .gm-floor 和 原版 floorxxx
const floorNode = floor.closest('div[id^="floor"]') || floor.closest('.gm-floor');
const floorId = floorNode?.id || (floorNode?.dataset?.floorNum ? `floor${floorNode.dataset.floorNum}` : 'f_unknown');
const children = Array.from(floor.children);
// log(`🔍 扫描楼层: ${floorId} (元素数: ${children.length})`);
let isInsideFold = false;
let currentFoldController = null;
let currentLockGroupId = null;
let currentFoldTitle = "详细内容";
const globalIndexRef = { count: parseInt(floor.dataset.diceIdx || '0') };
let hasValidContent = false;
children.forEach((child, idx) => {
if (['SCRIPT', 'STYLE'].includes(child.tagName)) return;
// ⚡ 0. 状态恢复 (State Recovery) - 解决增量加载/图片懒加载导致的折叠失效
// 如果遇到了已存在的控制器,说明进入了折叠区
if (child.classList.contains('g-fold-controller')) {
currentFoldController = child;
isInsideFold = true;
return; // 跳过控制器本身
}
// 如果遇到了已标记的结束行,说明折叠区结束
if (child.dataset.foldEnd === 'true') {
isInsideFold = false;
currentFoldController = null;
}
const isProcessed = child.dataset.processed === 'true';
const isTextContainer = (child.className && child.className.includes('RichTextParagraph_container')) ||
['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(child.tagName);
const isImage = child.tagName === 'IMG'; // 🖼️ 图片支持
let pTag = null;
let textContent = "";
if (isTextContainer) {
hasValidContent = true;
if (['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(child.tagName)) {
pTag = child;
} else {
// ⚡ 修复:扩充选择器,支持标题 (h1-h6) 参与迷雾和折叠,不仅仅是 p 标签
pTag = child.querySelector('p, h1, h2, h3, h4, h5, h6');
}
// ⚡ 修复:如果容器内没有文本标签,可能是纯图片容器,或者是骨架屏
// 如果有图片,则不跳过,以便后续的折叠/迷雾逻辑能处理它
if (!pTag) {
if (!child.querySelector('img')) return; // 既无文本也无图片,跳过
} else {
textContent = pTag.textContent;
// 🌑 净化纯黑色硬编码样式 & 智能提亮暗色字体 (适配夜间模式)
// 仅对首次处理的段落执行,避免重复计算
if (!isProcessed) {
const adjustColor = (el) => {
const color = el.style.color;
if (!color) return;
// 1. 移除纯黑
const c = color.replace(/\s/g, '');
if (c === 'rgb(0,0,0)' || c === '#000000' || c === 'black') {
el.style.color = '';
return;
}
// 2. 提亮暗色 (RGB检测)
// 浏览器通常会将 hex 自动转为 rgb
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (match) {
let r = parseInt(match[1]), g = parseInt(match[2]), b = parseInt(match[3]);
const maxVal = Math.max(r, g, b);
// 如果最大亮度小于 110 (且不是纯黑),则等比提亮到 120 以上
if (maxVal < 110 && maxVal > 0) {
const scale = 120 / maxVal;
r = Math.min(255, Math.round(r * scale));
g = Math.min(255, Math.round(g * scale));
b = Math.min(255, Math.round(b * scale));
el.style.color = `rgb(${r}, ${g}, ${b})`;
}
}
};
// 1. 检查子元素
pTag.querySelectorAll('[style*="color"]').forEach(adjustColor);
// 2. 检查段落容器本身
if (pTag.style.color) adjustColor(pTag);
}
}
} else if (isImage) {
hasValidContent = true;
}
// --- 0. 秘宝与线索解析 (Secret & Clue) ---
// 仅对未处理的段落执行
if (!isProcessed && isTextContainer && pTag) {
let html = pTag.innerHTML;
let hasSecretChange = false;
// 解析 <发现秘密>[名字]密码发现秘密>
if (html.includes('发现秘密')) {
const clueRegex = /(?:]*>)?(?:<|<)发现秘密(?:>|>)(?:<\/span>)?(?:\[(.*?)\])(?:<\/span>)?(?:]*>)?(.*?)(?:<\/span>)?(?:]*>)?(?:<|<)\/发现秘密(?:>|>)(?:<\/span>)?/g;
let newHtml = html.replace(clueRegex, (match, title, pwd) => {
const cleanPwd = pwd.replace(/<[^>]+>/g, '');
return `🔑 获得线索:${title}`;
});
if (newHtml !== html) {
html = newHtml;
hasSecretChange = true;
}
}
// 解析 <秘密>[名字]密文秘密>
if (html.includes('秘密') && !html.includes('发现秘密')) {
const secretRegex = /(?:]*>)?(?:<|<)秘密(?:>|>)(?:<\/span>)?(?:\[(.*?)\])(?:<\/span>)?(?:]*>)?([\s\S]*?)(?:<\/span>)?(?:]*>)?(?:<|<)\/秘密(?:>|>)(?:<\/span>)?/g;
let newHtml = html.replace(secretRegex, (match, title, cipher) => {
const cleanCipher = cipher.replace(/<[^>]+>/g, '');
return `🔒 你发现了一个秘密:${title} (点击揭示)
`;
});
if (newHtml !== html) {
html = newHtml;
hasSecretChange = true;
}
}
if (hasSecretChange) {
pTag.innerHTML = html;
textContent = pTag.textContent; // 更新文本内容
}
// 1. 解析 <音乐> 和 <自动音乐>
if (html.includes('音乐') && html.includes('音乐结束')) {
// 1. 手动音乐 (使用旧版本正则)
const manualRegex = /(?:]*>)?(?:<|<)音乐(?:>|>)(?:<\/span>)?([\s\S]*?)(?:♪|♪)(?:]*>)?(.*?)(?:<|<)\/音乐结束(?:>|>)(?:<\/span>)?/gi;
html = html.replace(manualRegex, (match, title, url) => {
let cleanTitle = title.replace(/<[^>]+>/g, '').trim();
const cleanUrl = url.replace(/<[^>]+>/g, '').trim();
if (!cleanTitle) cleanTitle = "BGM";
// 强制样式:对抗外层透明 span
return `${cleanTitle}`;
});
// 2. 自动音乐 (使用旧版本正则)
const autoRegex = /(?:<|<)自动音乐(?:>|>)(?:<\/span>)?([\s\S]*?)(?:♪|♪)(?:]*>)?([\s\S]*?)(?:<|<)\/自动音乐结束(?:>|>)/gi;
html = html.replace(autoRegex, (match, title, url) => {
let cleanTitle = title.replace(/<[^>]+>/g, '').trim();
const cleanUrl = url.replace(/<[^>]+>/g, '').trim();
if (!cleanTitle) cleanTitle = "Auto BGM";
// 强制样式
return `⚡ ${cleanTitle}`;
});
}
// 2. 解析停止音乐指令 (独立逻辑)
// 只要包含“停止音乐”就尝试解析
if (html.includes('停止音乐')) {
// 使用旧版本正则,兼容 span 包裹
const stopRegex = /(?:]*>)?(?:<|<)停止音乐(?:>|>)(?:<\/span>)?/gi;
html = html.replace(stopRegex, () => {
// 强制样式,并使用 opacity:0.5 让它对作者可见但低调
return `⏹`;
});
}
// 统一更新 DOM
if (pTag.innerHTML !== html) {
pTag.innerHTML = html;
textContent = pTag.textContent;
}
// 4. 引用跳转 (Inline & Block)
let hasQuoteChange = false;
// A. 内联引用 (在一行内)
if (html.includes('引用') && html.includes('/引用')) {
const quoteRegex = /(?:<|<)引用\s+id="(\d+)"\s+floor="(\d+)"(?:>|>)([\s\S]*?)(?:<|<)\/引用(?:>|>)/gi;
html = html.replace(quoteRegex, (match, bid, fid, content) => {
const cleanContent = content.replace(/<[^>]+>/g, '').trim();
const targetUrl = `/book/${bid}?floorSort=${fid}#g-anchor=${encodeURIComponent(cleanContent)}`;
// 生成带自定义弹窗的 HTML 结构比较麻烦,这里我们先生成基础结构,
// 然后通过事件委托或者在插入后绑定事件来处理。
// 但由于 replace 是字符串操作,无法直接绑定函数。
// 变通方案:直接把弹窗逻辑内联写在 onclick 属性里(虽然丑点,但有效),或者给它一个特殊的类名,事后绑定。
// 鉴于内联引用的使用场景较少且代码复杂度高,我们暂时保持原样,或者使用一个全局函数。
// 为了最佳体验,我们定义一个全局跳转函数 g_jumpTo(url, bid, fid)
window.g_jumpTo = (url, bid, fid) => {
const mask = document.createElement('div');
mask.style.cssText = `position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:10000;display:flex;justify-content:center;align-items:center;backdrop-filter:blur(3px);animation:g-fade 0.2s;`;
const box = document.createElement('div');
box.style.cssText = `background:#fff;padding:25px;border-radius:12px;width:320px;box-shadow:0 10px 30px rgba(0,0,0,0.3);text-align:center;transform:scale(0.9);animation:g-pop 0.2s forwards;`;
box.innerHTML = `
🚀
跳转确认
即将跳转到 Book:${bid}
第 ${fid} 层
`;
mask.id = 'g-jump-mask';
mask.appendChild(box);
document.body.appendChild(mask);
};
return `
`;
});
if (pTag.innerHTML !== html) {
pTag.innerHTML = html;
textContent = pTag.textContent;
hasQuoteChange = true;
}
}
// B. 块级引用 (跨节点: 标签-内容-结束标签)
// 只有当不是内联引用时才检查
if (!hasQuoteChange && (html.includes('<引用') || html.includes('<引用'))) {
const startMatch = html.match(/(?:<|<)引用\s+id="(\d+)"\s+floor="(\d+)"(?:>|>)/);
if (startMatch) {
const bid = startMatch[1];
const fid = startMatch[2];
// 向后查找
const next1 = child.nextElementSibling;
const next2 = next1 ? next1.nextElementSibling : null;
if (next1 && next2) {
// 检查 next2 是否是结束标签 (兼容 P 标签包裹)
const endText = next2.textContent || "";
if (endText.includes('引用>') || endText.includes('</引用>')) {
// 提取中间节点的内容
const quoteContentNode = next1.querySelector('p') || next1;
const contentHtml = quoteContentNode.innerHTML;
const cleanContent = next1.textContent.trim();
const targetUrl = `/book/${bid}?floorSort=${fid}#g-anchor=${encodeURIComponent(cleanContent)}`;
// 创建引用块
const quoteBox = document.createElement('div');
quoteBox.className = 'g-quote-box';
quoteBox.innerHTML = `
“
${contentHtml}
”
`;
// 漂亮的确认弹窗
quoteBox.onclick = () => {
const mask = document.createElement('div');
mask.style.cssText = `position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:10000;display:flex;justify-content:center;align-items:center;backdrop-filter:blur(3px);animation:g-fade 0.2s;`;
const box = document.createElement('div');
box.style.cssText = `background:#fff;padding:25px;border-radius:12px;width:320px;box-shadow:0 10px 30px rgba(0,0,0,0.3);text-align:center;transform:scale(0.9);animation:g-pop 0.2s forwards;`;
box.innerHTML = `
🚀
跳转确认
即将跳转到 Book:${bid}
第 ${fid} 层
`;
mask.appendChild(box);
document.body.appendChild(mask);
const close = () => {
mask.style.opacity = '0';
setTimeout(() => mask.remove(), 200);
};
box.querySelector('#g-jump-cancel').onclick = (e) => { e.stopPropagation(); close(); };
mask.onclick = (e) => { if(e.target===mask) close(); };
box.querySelector('#g-jump-go').onclick = (e) => {
e.stopPropagation();
location.href = targetUrl;
close();
};
};
// 插入并隐藏原节点
child.parentNode.insertBefore(quoteBox, child);
child.style.display = 'none';
next1.style.display = 'none';
next2.style.display = 'none';
// 标记处理防止重复
child.dataset.processed = 'true';
next1.dataset.processed = 'true';
next2.dataset.processed = 'true';
return; // 结束当前节点的处理
}
}
}
}
// --- 🎬 解析特效标签 <特效:下雨> ---
// 格式支持: <特效:下雨> 或 <特效:下雨>
if (currentConfig.vfxEnabled && (html.includes('特效'))) {
const vfxRegex = /(?:<|<)特效[::](.*?)(?:>|>)/gi;
let foundVfx = null;
let newHtml = html.replace(vfxRegex, (match, type) => {
if (!foundVfx) foundVfx = type.trim(); // 只取该段落第一个
return ''; // 隐藏标签
});
if (foundVfx) {
const card = floor.closest('div[class*="ContentCard_card"]');
if (card && !card.dataset.vfx) {
let vfxType = '';
if (['下雨', '雨', 'rain'].includes(foundVfx)) vfxType = 'rain';
else if (['下雪', '雪', 'snow'].includes(foundVfx)) vfxType = 'snow';
else if (['打雷', '雷', 'thunder', 'lightning'].includes(foundVfx)) vfxType = 'thunder';
else if (['地震', '震动', 'quake', 'earthquake'].includes(foundVfx)) vfxType = 'quake';
else if (['狂风', '风', 'wind', 'gale'].includes(foundVfx)) vfxType = 'wind';
else if (['停止', '关闭', 'stop', 'clear'].includes(foundVfx)) vfxType = 'stop';
if (vfxType) card.dataset.vfx = vfxType;
}
pTag.innerHTML = newHtml;
textContent = pTag.textContent;
}
}
// --- 🌌 解析背景标签 (V3: 增强跨节点检测与调试) ---
if (html.includes('背景')) {
let hasBgChange = false;
let foundInline = false;
let url = null;
// 1. 尝试检测内联结构 (一行内包含标签和图片)
const bgSetRegex = /(?:<|<)背景(?:>|>)([\s\S]*?)(?:<|<)\/背景(?:>|>)/gi;
html = html.replace(bgSetRegex, (match, content) => {
const srcMatch = content.match(/src="([^"]+)"/);
if (srcMatch && srcMatch[1]) {
url = srcMatch[1];
foundInline = true;
console.log("[GULULU-BG] ✅ 内联检测成功:", url);
}
return content;
});
// 2. 尝试检测分离结构 (若内联失败)
// 场景:容器(<背景>) + IMG节点 + 容器(背景>)
// 兼容转义字符 <背景>
if (!foundInline && (html.match(/(?:<|<)背景(?:>|>)/))) {
console.log("[GULULU-BG] 🔍 发现背景开始标签,正在寻找后续图片...");
// 向后查找紧邻的节点
const nextNode = child.nextElementSibling;
if (nextNode) {
// 兼容直接是 IMG 标签,或者 IMG 被包裹在 DIV 中
let img = null;
if (nextNode.tagName === 'IMG') img = nextNode;
else if (nextNode.querySelector) img = nextNode.querySelector('img');
if (img) {
const src = img.getAttribute('src') || img.getAttribute('data-src');
if (src) {
url = src;
foundInline = true;
console.log("[GULULU-BG] ✅ 跨节点检测成功:", src);
// 清理开始标签
html = html.replace(/(?:<|<)背景(?:>|>)/gi, '');
} else {
console.log("[GULULU-BG] ⚠️ 找到图片标签但没有 src 属性");
}
} else {
console.log("[GULULU-BG] ⚠️ 下一个节点不是图片,也未包含图片:", nextNode.tagName);
}
} else {
console.log("[GULULU-BG] ⚠️ 没有下一个兄弟节点");
}
}
// 统一应用 URL
if (url) {
pTag.setAttribute('data-bg-url', url);
hasBgChange = true;
}
// 3. 清理分离结构的结束标签 背景>
if (html.includes('/背景')) {
const oldHtml = html;
html = html.replace(/(?:<|<)\/背景(?:>|>)/gi, '');
if (html !== oldHtml) hasBgChange = true;
}
// 4. 解析 <移除背景>
if (html.includes('移除背景') || html.includes('清除背景') || html.includes('恢复背景')) {
const bgClearRegex = /(?:<|<)(?:移除背景|清除背景|恢复背景)(?:>|>)/gi;
html = html.replace(bgClearRegex, () => {
console.log("[GULULU-BG] 🚫 检测到移除指令");
pTag.setAttribute('data-bg-clear', 'true');
hasBgChange = true;
return '';
});
}
if (hasBgChange) {
pTag.innerHTML = html;
}
}
}
// --- 1. 折叠逻辑 (Folding) ---
// ⚡ 修复:提取检测逻辑,允许对“掉链子”的已处理折叠区进行自愈 (Self-Healing)
let hasStart = false;
let hasEnd = false;
if (currentConfig.foldingEnabled && isTextContainer && pTag) {
hasStart = textContent.includes('<折叠>') || textContent.includes('<折叠>') || pTag.innerHTML.includes('<折叠>');
hasEnd = textContent.includes('折叠结束>') || textContent.includes('</折叠结束>') || pTag.innerHTML.includes('</折叠结束>');
}
// 核心逻辑:如果发现了折叠头,但当前不在折叠态(说明控制器丢失),强制执行修复
const forceFoldRepair = hasStart && !currentFoldController;
if ((!isProcessed || forceFoldRepair) && (hasStart || hasEnd)) {
if (hasStart) {
isInsideFold = true;
// 对 textContent 做正则提取,兼容 < 和 <
const titleMatch = textContent.match(/(?:<|<)折叠(?:>|>)\[(.*?)\]/);
currentFoldTitle = titleMatch ? titleMatch[1] : `部分内容`;
const controller = document.createElement('div');
controller.className = 'g-fold-controller';
controller.id = `${floorId}_fold_ctrl_${idx}`;
controller.dataset.closed = 'true';
controller.setAttribute('data-title', currentFoldTitle);
const arrow = '▶';
// ⚡ 修复:初始图标缺失问题,补上 🎲
controller.innerHTML = `${arrow} 🎲 ${currentFoldTitle} (点击显示)`;
if (currentConfig.fogModeEnabled && currentLockGroupId) {
if (!unlockedSet.has(currentLockGroupId)) {
controller.classList.add('g-hidden-block');
controller.setAttribute('data-fog-lock', currentLockGroupId);
}
}
// 单行折叠 (Same Line)
if (hasEnd) {
child.parentNode.insertBefore(controller, child);
const htmlContent = pTag.innerHTML;
const contentMatch = htmlContent.match(/(?:]*>)?(?:<|<)折叠(?:>|>)(?:\[.*?\])?(?:<\/span>)?([\s\S]*?)(?:]*>)?(?:<|<)\/折叠结束(?:>|>)(?:<\/span>)?/);
let innerContent = contentMatch ? contentMatch[1] : "内容解析失败";
innerContent = innerContent.replace(/^
/i, '').replace(/
$/i, ''); // 清理首尾BR
const contentDiv = document.createElement('div');
contentDiv.className = 'g-folded-content g-hidden-block';
contentDiv.setAttribute('data-fold-owner', controller.id);
contentDiv.innerHTML = innerContent;
// ♻️ 修复:保留手机版评论气泡 (抢救即将被删除的按钮)
// 直接移动 DOM 节点,避免正则复制导致的重复
const commentBtn = child.querySelector('.gm-comment-btn');
if (commentBtn) {
contentDiv.appendChild(commentBtn);
}
child.parentNode.insertBefore(contentDiv, child);
child.remove();
isInsideFold = false;
return;
}
// --- 多行折叠模式 (Multi-line) ---
child.parentNode.insertBefore(controller, child);
currentFoldController = controller;
// ⚡ 核心优化:判断该行除去标签外是否还有其他内容
const rawTxt = textContent.trim();
const contentWithoutTag = rawTxt.replace(/(?:<|<)折叠(?:>|>)\[.*?\]/g, "").trim();
if (contentWithoutTag.length === 0) {
child.style.display = 'none'; // 幽灵行消失术
// ♻️ 修复:如果标题行本身有评论气泡,将其移动到控制器上
const commentBtn = child.querySelector('.gm-comment-btn');
if (commentBtn) {
// 调整样式以适应控制器
commentBtn.style.float = 'right';
commentBtn.style.marginTop = '-2px';
controller.appendChild(commentBtn);
}
} else {
// 混合内容:保留该行作为折叠内容的一部分
child.classList.add('g-hidden-block');
child.setAttribute('data-fold-owner', controller.id);
child.classList.add('g-folded-content');
}
child.dataset.processed = 'true';
return;
}
if (hasEnd) {
if (isInsideFold && currentFoldController) {
const rawTxt = textContent.trim();
const contentWithoutTag = rawTxt.replace(/(?:<|<)\/折叠结束(?:>|>)/g, "").trim();
if (contentWithoutTag.length === 0) {
child.style.display = 'none'; // 幽灵行消失术
} else {
// 混合内容:归入折叠块
child.setAttribute('data-fold-owner', currentFoldController.id);
child.classList.add('g-hidden-block');
child.classList.add('g-folded-content');
}
} else {
// 孤儿结束标签,如果是纯标签也直接隐藏
if (textContent.trim().replace(/(?:<|<)\/折叠结束(?:>|>)/g, "").trim().length === 0) {
child.style.display = 'none';
}
}
isInsideFold = false;
currentFoldController = null;
child.dataset.processed = 'true';
child.dataset.foldEnd = 'true'; // ⚡ 标记:这是折叠结束行,供下次扫描识别
return;
}
}
// --- 2. 可见性控制 (Visibility: Fold > Fog) ---
// 即使已处理,也要检查可见性(因为解锁状态可能变了,或者折叠控制器刚被修复)
if (isInsideFold && currentFoldController) {
// ⚡ 修复:移除 !isProcessed 限制。只要在折叠区内且未被关联,就强制归入。
// 这解决了“先扫描了图片(标记为processed但未隐藏),后修复了控制器”导致图片漏在外面的问题。
const isAlreadyFolded = child.hasAttribute('data-fold-owner');
if (!isAlreadyFolded && child !== currentFoldController) {
child.setAttribute('data-fold-owner', currentFoldController.id);
child.classList.add('g-hidden-block');
child.classList.add('g-folded-content');
}
}
else if (currentConfig.fogModeEnabled) {
// 只要有锁,且是文本或图片
if (currentLockGroupId && (isTextContainer || isImage)) {
if (!unlockedSet.has(currentLockGroupId)) {
child.classList.add('g-hidden-block');
child.setAttribute('data-fog-lock', currentLockGroupId);
} else {
child.classList.remove('g-hidden-block');
}
}
}
// --- 3. 骰子加密 & 状态更新 (Encryption & State) ---
if (currentConfig.diceMaskEnabled && isTextContainer && pTag) {
// 仅对未处理的段落进行替换
if (!isProcessed && /\d+[dD]\d+/.test(textContent)) {
const htmlContent = pTag.innerHTML;
const newHtml = createMaskHtml(htmlContent, floorId, unlockedSet, globalIndexRef);
if (newHtml !== htmlContent) {
pTag.innerHTML = newHtml;
}
}
// ⚠️ 关键修复:无论是否已处理,都必须读取 DOM 中的骰子来更新 currentLockGroupId
// 这样才能让后续的图片/文本被正确锁定
// ⚡ BugFix: 必须同时检查 DOM 属性,防止增量扫描时 isInsideFold 状态失效导致折叠区内骰子泄露
const isEffectiveFold = isInsideFold || child.hasAttribute('data-fold-owner') || child.classList.contains('g-folded-content');
if (!isEffectiveFold) {
const masks = pTag.querySelectorAll('.g-dice-mask[data-key="true"], .g-dice-revealed[data-key="true"]');
if (masks.length > 0) {
const lastMask = masks[masks.length - 1];
currentLockGroupId = lastMask.getAttribute('data-group-id');
}
}
}
child.dataset.processed = 'true';
});
// 保存计数器状态
floor.dataset.diceIdx = globalIndexRef.count;
// 只有当确实发现了内容时,才标记楼层为“已处理”
// (这样如果是空骨架屏,下次轮询还会再试)
if (hasValidContent) {
floor.dataset.processed = 'true';
}
};
// ==========================================
// 🎬 视效引擎 (VFX Engine)
// ==========================================
const VFX_MANAGER = {
canvas: null,
ctx: null,
flashLayer: null,
width: 0,
height: 0,
particles: [],
animId: null,
currentEffect: null, // 'rain', 'snow', 'wind'
quakeTimer: null, // ⚡ 地震定时器
init: function() {
if (!this.canvas) {
this.canvas = document.createElement('canvas');
this.canvas.id = 'g-vfx-canvas';
document.body.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
this.flashLayer = document.createElement('div');
this.flashLayer.id = 'g-vfx-flash';
document.body.appendChild(this.flashLayer);
window.addEventListener('resize', () => this.resize());
this.resize();
}
},
resize: function() {
if (!this.canvas) return;
this.width = window.innerWidth;
this.height = window.innerHeight;
this.canvas.width = this.width;
this.canvas.height = this.height;
},
play: function(effectName) {
// 如果是地震,无论是否当前正在震,都允许重新触发以重置时间;如果是天气则避免重复
if (this.currentEffect === effectName && effectName !== 'quake') return;
// 停止旧特效 (停止动作会清理 quakeTimer)
this.stop();
if (!effectName || effectName === 'stop') return;
this.currentEffect = effectName;
this.init(); // 确保 canvas 存在
// 1. CSS 类特效 (地震/闪电)
if (effectName === 'quake') {
document.body.classList.add('g-vfx-quake');
// ⚡ 地震持续5秒后自动停止
this.quakeTimer = setTimeout(() => {
document.body.classList.remove('g-vfx-quake');
if (this.currentEffect === 'quake') this.currentEffect = null;
}, 5000);
return;
}
if (effectName === 'thunder') {
this.flashLayer.classList.add('g-vfx-flash-active');
// 闪电通常伴随雨,所以我们也开启雨,只作为背景
this.startParticles('rain', 50);
return;
}
// 2. Canvas 粒子特效
if (['rain', 'snow', 'wind'].includes(effectName)) {
this.canvas.style.display = 'block';
this.startParticles(effectName);
}
},
stop: function() {
this.currentEffect = null;
if (this.animId) cancelAnimationFrame(this.animId);
if (this.quakeTimer) { clearTimeout(this.quakeTimer); this.quakeTimer = null; } // ⚡ 清除定时器
this.particles = [];
if (this.canvas) {
this.ctx.clearRect(0, 0, this.width, this.height);
this.canvas.style.display = 'none';
}
if (this.flashLayer) {
this.flashLayer.classList.remove('g-vfx-flash-active');
}
document.body.classList.remove('g-vfx-quake');
},
stop: function() {
this.currentEffect = null;
if (this.animId) cancelAnimationFrame(this.animId);
this.particles = [];
if (this.canvas) {
this.ctx.clearRect(0, 0, this.width, this.height);
this.canvas.style.display = 'none';
}
if (this.flashLayer) {
this.flashLayer.classList.remove('g-vfx-flash-active');
}
document.body.classList.remove('g-vfx-quake');
},
startParticles: function(type, countOverride) {
const maxParticles = countOverride || (type === 'rain' ? 300 : (type === 'snow' ? 150 : 400));
for (let i = 0; i < maxParticles; i++) {
this.particles.push(this.createParticle(type));
}
const loop = () => {
this.ctx.clearRect(0, 0, this.width, this.height);
this.ctx.fillStyle = (type === 'snow') ? 'rgba(255, 255, 255, 0.8)' : 'rgba(174, 194, 224, 0.6)';
if (type === 'wind') this.ctx.fillStyle = 'rgba(200, 200, 200, 0.4)';
this.particles.forEach(p => {
this.updateParticle(p, type);
this.drawParticle(p, type);
});
this.animId = requestAnimationFrame(loop);
};
loop();
},
createParticle: function(type) {
return {
x: Math.random() * this.width,
y: Math.random() * this.height,
l: Math.random() * 1, // life/length
xs: (Math.random() - 0.5) * (type === 'rain' ? 1 : 2),
ys: Math.random() * (type === 'rain' ? 15 : (type === 'snow' ? 2 : 20)) + (type === 'snow' ? 1 : 10)
};
},
updateParticle: function(p, type) {
if (type === 'rain') {
p.y += p.ys;
p.x += 1; // 风向
if (p.y > this.height) { p.y = -20; p.x = Math.random() * this.width; }
if (p.x > this.width) { p.x = 0; }
}
else if (type === 'snow') {
p.y += p.ys;
p.x += Math.sin(Date.now() / 1000 + p.ys) * 2; // 飘雪
if (p.y > this.height) { p.y = -10; p.x = Math.random() * this.width; }
}
else if (type === 'wind') {
p.x += p.ys + 10; // 狂风横吹
p.y += (Math.random() - 0.5) * 2;
if (p.x > this.width) { p.x = -20; p.y = Math.random() * this.height; }
}
},
drawParticle: function(p, type) {
this.ctx.beginPath();
if (type === 'rain') {
this.ctx.rect(p.x, p.y, 1, p.ys);
} else if (type === 'snow') {
this.ctx.arc(p.x, p.y, Math.random() * 3 + 1, 0, Math.PI * 2);
} else if (type === 'wind') {
this.ctx.rect(p.x, p.y, 30 + Math.random()*20, 1);
}
this.ctx.fill();
}
};
// 🕵️ 视效触发器 (Observer + Mouse)
const initVFXObserver = () => {
if (!currentConfig.vfxEnabled) return;
let activeFloor = null;
// 策略1: IntersectionObserver (视口中心判定)
// 使用负边距将判定区域压缩到屏幕中间的一条线,只有穿过这条线的元素才算 Active
const observerFunc = (entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const floor = entry.target;
// 如果当前鼠标正悬停在某个元素上,不要被滚动覆盖
if (floor.matches(':hover')) return;
// 检查该楼层是否有特效
const vfx = floor.dataset.vfx;
// ⚡ 逻辑变更:只要切换楼层,就根据当前楼层状态决定播放或停止
if (activeFloor !== floor) {
activeFloor = floor;
if (vfx) {
VFX_MANAGER.play(vfx);
} else {
VFX_MANAGER.stop(); // 自动停止
}
}
}
});
};
const observer = new IntersectionObserver(observerFunc, {
root: null,
rootMargin: '-45% 0px -45% 0px', // 仅关注屏幕中心 10% 区域
threshold: 0
});
// 策略2: Mouseover (鼠标指哪打哪)
// 这是用户特别要求的
document.addEventListener('mouseover', throttle((e) => {
const floor = e.target.closest('div[class*="ContentCard_card"]') || e.target.closest('.gm-floor');
if (floor && floor !== activeFloor) {
activeFloor = floor;
const vfx = floor.dataset.vfx;
if (vfx) {
VFX_MANAGER.play(vfx);
} else {
VFX_MANAGER.stop(); // 自动停止
}
}
}, 200), { passive: true }); // 节流 200ms
// 将 Observer 绑定到所有卡片 (需配合 MutationObserver 动态添加)
const bindObserver = () => {
document.querySelectorAll('div[class*="ContentCard_card"], .gm-floor').forEach(el => {
if (!el.dataset.vfxObserved) {
observer.observe(el);
el.dataset.vfxObserved = 'true';
}
});
};
// 定时检查新楼层 (简单粗暴但有效)
setInterval(bindObserver, 2000);
bindObserver();
};
// ==========================================
// 🌌 氛围背景引擎 (Atmospheric BG Engine)
// ==========================================
const BG_MANAGER = {
layer: null,
currentUrl: null,
init: function() {
if (this.layer) return;
// 1. 创建黑色底板 (最底层 z-index: -2)
// 它永远在那里,不需要动,只要上面的层透明了,它就会露出来
const backdrop = document.createElement('div');
backdrop.style.cssText = `position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:-2;background:#000;pointer-events:none;`;
backdrop.id = 'g-bg-backdrop';
document.body.appendChild(backdrop);
// 2. 创建背景图层 (次底层 z-index: -1)
this.layer = document.createElement('div');
this.layer.id = 'g-bg-layer';
document.body.appendChild(this.layer);
// 绑定滚动监听 (节流)
const scrollHandler = throttle(() => this.check(), 200);
window.addEventListener('scroll', scrollHandler, { passive: true });
// ⚡ 修复:轮询等待滚动容器出现,防止初始化过早导致监听失败
const attachTimer = setInterval(() => {
const container = document.getElementById('pageScrollContainer');
if (container) {
console.log("[GULULU-BG] ✅ 锁定滚动容器 #pageScrollContainer,绑定监听器");
container.addEventListener('scroll', scrollHandler, { passive: true });
clearInterval(attachTimer);
// 立即检查一次
this.check();
}
}, 1000);
},
check: function() {
// 查找触发点
const triggers = document.querySelectorAll('[data-bg-url], [data-bg-clear]');
// 调试日志:确认 triggers 是否存在
// if (triggers.length > 0 && Math.random() > 0.95) console.log(`[GULULU-BG] 🔍 监测中,当前页面共有 ${triggers.length} 个背景触发点`);
if (triggers.length === 0) return;
const checkLine = window.innerHeight * 0.4;
let activeUrl = null;
let shouldClear = false;
let foundTrigger = false;
let triggerEl = null;
for (let i = 0; i < triggers.length; i++) {
const el = triggers[i];
// ⚡ 修复:跳过被隐藏的元素(例如迷雾锁定的图片),防止它们抢占背景
// display:none 的元素 rect.top 为 0,会被误判为“在屏幕上方”
if (el.offsetParent === null) continue;
const rect = el.getBoundingClientRect();
// 寻找位于判定线之上的最后一个触发点
if (rect.top < checkLine) {
foundTrigger = true;
triggerEl = el;
if (el.hasAttribute('data-bg-clear')) {
activeUrl = null;
shouldClear = true;
} else {
activeUrl = el.getAttribute('data-bg-url');
shouldClear = false;
}
} else {
break;
}
}
if (foundTrigger) {
// 只有状态改变时才打印日志
if (activeUrl !== this.currentUrl && !(shouldClear && this.currentUrl === 'CLEAR')) {
console.log(`[GULULU-BG] 🕵️ 滚动命中: ${shouldClear ? '清除指令' : activeUrl}`, triggerEl);
}
this.update(activeUrl, shouldClear);
}
},
update: function(url, isClear) {
// 状态去重
if (url === this.currentUrl && !isClear) return;
if (isClear && this.currentUrl === 'CLEAR') return;
// --- 场景 1: 清除背景 ---
if (isClear || !url) {
console.log("[GULULU-BG] 🔄 执行清除背景 (Fade to Default)");
this.currentUrl = 'CLEAR';
// 1. 图片层淡出
this.layer.style.opacity = '0';
// 2. 等待淡出完毕,恢复原生背景
setTimeout(() => {
if(this.currentUrl !== 'CLEAR') return;
console.log("[GULULU-BG] ✅ 图片已隐藏,移除激活状态");
this.layer.style.backgroundImage = 'none';
document.body.classList.remove('g-bg-active');
}, 800);
}
// --- 场景 2: 设置背景 ---
else {
// 情况A: 首次激活 (原生白 -> 黑 -> 自定义图)
if (this.currentUrl === 'CLEAR' || !this.currentUrl) {
this.currentUrl = url;
console.log(`%c[GULULU-BG] 🎬 阶段1: 开始原生背景淡出 (T=0ms)`, "color:orange;font-weight:bold");
// 1. 仅操作 body,触发 CSS transition
document.body.classList.add('g-bg-active');
// 2. 等待原生背景淡出 (0.8s)
setTimeout(() => {
if(this.currentUrl !== url) return;
console.log(`%c[GULULU-BG] 🌑 阶段2: 背景应已全黑,开始加载图片`, "color:orange;font-weight:bold");
// 3. 设置图片并开始淡入 (opacity: 0 -> 1)
this.layer.style.backgroundImage = `url("${url}")`;
this.layer.offsetHeight; // 强制重绘
this.layer.style.opacity = '1';
}, 800);
}
// 情况B: 切换图片 (图片 -> 图片)
else {
console.log(`[GULULU-BG] 🔄 切换图片: ${this.currentUrl} -> ${url}`);
// 1. 旧图淡出 (露出底下的黑色 backdrop)
this.layer.style.opacity = '0';
const targetUrl = url;
this.currentUrl = targetUrl;
// 2. 等待淡出 (0.8s)
setTimeout(() => {
if(this.currentUrl !== targetUrl) return;
console.log(`[GULULU-BG] 🖼️ 新图片淡入`);
// 3. 换新图并淡入
this.layer.style.backgroundImage = `url("${targetUrl}")`;
this.layer.offsetHeight; // 强制重绘
this.layer.style.opacity = '1';
}, 800);
}
}
}
};
// 🎵 自动音乐监听器
const initAutoMusicObserver = () => {
const observer = new IntersectionObserver((entries) => {
if (!currentConfig.autoMusicEnabled) return;
entries.forEach(entry => {
if (entry.isIntersecting) {
const btn = entry.target;
// 防止重复触发 (滚动回去不再自动播,但可以手动点)
if (btn.dataset.autoPlayed === 'true') return;
const url = btn.getAttribute('data-url');
const title = btn.getAttribute('data-title');
if (url) {
if (url === 'STOP') {
log(`⏹️ 自动停止触发`);
stopMusic(true); // ⚡ 静默停止,不弹窗
} else {
log(`🎵 自动播放触发: ${title}`);
playMusic(url, title, btn);
}
btn.dataset.autoPlayed = 'true'; // 标记为已自动播放
observer.unobserve(btn); // 停止监听该元素
}
}
});
}, { threshold: 0.5 }); // 露出 50% 时触发
// 扫描并添加监听
const scan = () => {
document.querySelectorAll('.g-music-key[data-auto="true"]:not([data-observed])').forEach(el => {
observer.observe(el);
el.setAttribute('data-observed', 'true');
});
};
setInterval(scan, 2000); // 轮询新内容
};
// 🧹 内存清理引擎 (Hollow & Cache)
// 策略:保留楼层容器(防止React崩溃),掏空内容存为字符串(释放DOM内存),滚动回来时自动回填
const initMemoryCleaner = () => {
if (!currentConfig.cleanupEnabled) return;
// 内存缓存:floorId -> htmlString
const floorCache = new Map();
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const floor = entry.target;
const id = floor.id;
if (entry.isIntersecting) {
// --- 进入视口:恢复内容 ---
if (floor.classList.contains('g-hollowed')) {
const cachedHtml = floorCache.get(id);
if (cachedHtml) {
floor.innerHTML = cachedHtml;
floor.classList.remove('g-hollowed');
floor.style.height = '';
floor.style.contain = 'none';
// log(`♻️ [Restore] 恢复楼层: ${id}`);
}
}
} else {
// --- 离开视口:掏空内容 ---
// 仅处理位于视口上方的旧楼层 (top < 0)
// 缓冲区:上下各 4000px (约 4-5 屏),保证阅读流畅性
if (entry.boundingClientRect.top < 0 && !floor.classList.contains('g-hollowed')) {
const h = entry.boundingClientRect.height;
if (h > 0) {
// 1. 保存 HTML 到内存 (字符串占用远小于 DOM 对象)
floorCache.set(id, floor.innerHTML);
// 2. 锁定高度
floor.style.height = `${h}px`;
floor.style.contain = 'strict';
// 3. 掏空 DOM
floor.innerHTML = '';
floor.classList.add('g-hollowed');
// log(`🗑️ [Hollow] 掏空楼层: ${id} (Saved ${floorCache.get(id).length} chars)`);
}
}
}
});
}, {
// 缓冲区:视口上下各 4000px 内的楼层保持活跃,之外的才会被掏空
// 这个数值越大,回滚时的白屏概率越低,但内存占用越高。4000px 是个平衡点。
rootMargin: '4000px 0px 4000px 0px'
});
const scan = () => {
const floors = document.querySelectorAll('div[id^="floor"]:not([data-mem-observed])');
floors.forEach(f => {
observer.observe(f);
f.setAttribute('data-mem-observed', 'true');
});
};
setInterval(scan, 2000);
scan();
};
// ⚓ 锚点定位器 (Anchor Locator)
const initAnchorLocator = () => {
const hash = window.location.hash;
if (!hash || !hash.startsWith('#g-anchor=')) return;
const targetText = decodeURIComponent(hash.substring(10)); // 去掉 #g-anchor=
if (!targetText) return;
log(`⚓ 启动锚点定位: "${targetText}"`);
// 创建一个临时的 Observer,专门找这段文字
// 找到后高亮并滚动,然后自毁
let found = false;
let retry = 0;
const check = () => {
if (found || retry++ > 20) return; // 最多尝试 20 次 (约 20秒)
// 简单粗暴:遍历所有 P 标签
// 性能优化:只找最近加载的楼层
const paragraphs = document.querySelectorAll('div[class*="RichTextParagraph_container"] p');
for (let p of paragraphs) {
if (p.textContent.includes(targetText)) {
log("✅ 找到目标段落,执行定位");
found = true;
// 1. 滚动
p.scrollIntoView({ behavior: "smooth", block: "center" });
// 2. 高亮特效
p.style.transition = "background 0.5s";
p.style.backgroundColor = "#ffeaa7"; // 亮黄色
p.style.borderRadius = "4px";
p.style.boxShadow = "0 0 10px #ffeaa7";
// 3. 淡出高亮
setTimeout(() => {
p.style.backgroundColor = "transparent";
p.style.boxShadow = "none";
}, 3000);
return;
}
}
// 没找到,继续轮询 (等待 React 加载)
setTimeout(check, 1000);
};
// 延迟一点启动,等待页面基础渲染
setTimeout(check, 1500);
};
// 🎯 精准 DOM 监听器 (Targeted Observer)
const initContentObserver = () => {
// 1. 处理逻辑
const handleMutations = (mutations) => {
const unlockedSet = loadUnlocked();
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType !== 1) return;
// --- Mobile Reforged 适配 ---
if (node.classList && node.classList.contains('gm-floor')) {
const body = node.querySelector('.gm-floor-body');
if(body) processSingleFloor(body, unlockedSet);
return;
}
if (node.classList && node.classList.contains('gm-floor-body')) {
processSingleFloor(node, unlockedSet);
return;
}
// 手机版内容更新 (例如 P 标签被插入到 gm-floor-body)
if ((node.tagName === 'P' || node.tagName === 'IMG') && node.closest('.gm-floor-body')) {
processSingleFloor(node.closest('.gm-floor-body'), unlockedSet);
return;
}
// ---------------------------
// 情况A: 这是一个新楼层卡片 (ContentCard)
if (node.className && typeof node.className === 'string' && node.className.includes('ContentCard_card')) {
const floorContent = node.querySelector('div[class*="FloorContent_richText"]');
if (floorContent) processSingleFloor(floorContent, unlockedSet);
return;
}
// 情况B: 这是一个楼层内容容器 (FloorContent)
if (node.className && typeof node.className === 'string' && node.className.includes('FloorContent_richText')) {
processSingleFloor(node, unlockedSet);
return;
}
// 情况C: ⚡ 终极兜底 - 只要是楼层内部的新增节点 (例如 P 标签、IMG 图片)
if (node.tagName === 'P' || node.tagName === 'IMG' || (node.className && typeof node.className === 'string' && node.className.includes('RichTextParagraph_container'))) {
const parentFloor = node.closest('div[class*="FloorContent_richText"]');
if (parentFloor) {
processSingleFloor(parentFloor, unlockedSet);
}
}
});
});
};
const observer = new MutationObserver(handleMutations);
// 2. 寻找目标容器 (兼容 Mobile Reforged)
const findAndObserve = () => {
const mobileContainer = document.getElementById('gm-scroll-area');
if (mobileContainer) {
log("✅ 已锁定手机版容器: gm-scroll-area");
// 手机版直接监听 scroll area,子元素是 .gm-floor
observer.observe(mobileContainer, { childList: true, subtree: true });
const unlockedSet = loadUnlocked();
// 初始扫描
const floors = document.querySelectorAll('.gm-floor-body');
log(`🚀 手机版初始扫描: ${floors.length} 个楼层`);
floors.forEach(f => processSingleFloor(f, unlockedSet));
return;
}
// ⚡ 核心修复:如果开启了手机模式,必须死等手机容器出现,绝对不能绑定到隐藏的桌面容器上
if (globalConfig.mobileModeEnabled) {
// log("⏳ 等待手机版容器初始化...");
setTimeout(findAndObserve, 500);
return;
}
const scrollContainer = document.getElementById('pageScrollContainer');
if (!scrollContainer) {
setTimeout(findAndObserve, 500); // 容器未出现,稍后重试
return;
}
// 通常 book_read 类名的 div 是 scrollContainer 的直接子元素
const targetContainer = scrollContainer.querySelector('div[class*="book_read"]');
if (targetContainer) {
log("✅ 已锁定内容容器:", targetContainer.className);
// 仅监听子列表变动 (childList: true, subtree: false) 提高性能避免噪音
// 因为 React 是一张张卡片添加进来的
observer.observe(targetContainer, { childList: true, subtree: true });
// 立即执行一次全量扫描,防止漏掉初始内容
const unlockedSet = loadUnlocked();
const floors = document.querySelectorAll('div[class*="FloorContent_richText"]');
log(`🚀 初始扫描: ${floors.length} 个楼层`);
floors.forEach(f => processSingleFloor(f, unlockedSet));
} else {
setTimeout(findAndObserve, 500);
}
};
findAndObserve();
};
// 🔧 原生网页滚动锚定修复 (防止向上加载时跳页)
const initScrollFix = () => {
const scroller = document.getElementById('pageScrollContainer');
if (!scroller) return;
// 1. 强制开启浏览器原生的滚动锚定 (作为第一道防线)
scroller.style.overflowAnchor = 'auto';
// 2. 手动锚定逻辑 (针对 React 动态插入导致的锚定失效)
// 寻找内容包裹容器,通常是 book_read 或 main
const content = scroller.querySelector('div[class*="book_read"]') || scroller.querySelector('main');
if (!content) return;
let lastHeight = content.offsetHeight;
const ro = new ResizeObserver(() => {
const newHeight = content.offsetHeight;
const delta = newHeight - lastHeight;
const currentScroll = scroller.scrollTop;
// 判定条件:
// 1. 高度增加了 (delta > 0)
// 2. 用户当前处于顶部区域 (currentScroll < 300),说明触发了“加载上一页”
// 3. 增加的高度显著 (delta > 100),排除细微布局调整
if (delta > 100 && currentScroll < 300) {
log(`[GULULU] 🛡️ 检测到顶部内容插入 (H:+${delta}px),修正视口位置`);
// 🚫 临时禁用平滑滚动,防止修正时画面抖动
const oldBehavior = scroller.style.scrollBehavior;
scroller.style.scrollBehavior = 'auto';
// 🔧 修正位置:保持相对位置不变
scroller.scrollTop = currentScroll + delta;
// 恢复平滑滚动
setTimeout(() => {
scroller.style.scrollBehavior = oldBehavior;
}, 50);
}
lastHeight = newHeight;
});
ro.observe(content);
log("✅ 滚动锚定修复已启动");
};
function createUI() {
if (document.getElementById('g-setting-btn')) return;
// 1. 设置按钮
const btn = document.createElement('div');
btn.id = 'g-setting-btn'; btn.innerText = '⚙️';
document.body.appendChild(btn);
// 2. 魔法按钮 (偷看10个)
const magicBtn = document.createElement('div');
magicBtn.id = 'g-magic-btn'; magicBtn.innerText = '🎲';
document.body.appendChild(magicBtn);
// 3. 设置面板
const panel = document.createElement('div');
panel.id = 'g-setting-panel';
// 📱 适配手机模式的样式
const isMobile = globalConfig.mobileModeEnabled;
if (isMobile) {
panel.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 90%; max-width: 600px;
background: #fff; padding: 30px;
border-radius: 24px; box-shadow: 0 10px 50px rgba(0,0,0,0.3);
z-index: 2147483647; display: none; font-size: 30px;
border: 1px solid #eee;
/* ⚡ 核心修复:使用 fit-content 彻底解决高度塌陷或溢出问题 */
height: fit-content;
max-height: 85vh;
overflow-y: auto;
box-sizing: border-box;
`;
// 注入手机版特供 CSS (放大控件 + 夜间模式适配)
const mStyle = document.createElement('style');
mStyle.innerHTML = `
#g-setting-panel .g-setting-item { padding: 25px 0; border-bottom: 1px solid #f5f5f5; }
#g-setting-panel input[type="checkbox"] { transform: scale(3.0); margin-right: 30px; }
#g-setting-panel .g-btn-danger { padding: 30px; font-size: 36px; margin-top: 40px; border-radius: 20px; }
#g-setting-panel .g-badge-new { font-size: 20px; padding: 4px 10px; border-radius: 8px; margin-left: 10px; }
/* 面板夜间模式 */
body.g-night-mode #g-setting-panel { background: #2d2d2d !important; border-color: #444 !important; color: #ddd !important; }
body.g-night-mode #g-setting-panel .g-setting-item { border-bottom-color: #444 !important; }
body.g-night-mode #g-panel-close { color: #888 !important; }
`;
document.head.appendChild(mStyle);
} else {
panel.style.cssText = `
position: fixed; bottom: 30px; right: 80px; background: #fff; padding: 15px;
border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); z-index: 1000005; display: none; font-size: 14px;
border: 1px solid #f0f0f0; min-width: 220px;
`;
}
panel.innerHTML = `
助手设置
(Book: ${BOOK_ID})
${isMobile ? '
✕
' : ''}
(有bug请反馈)
💡 Tip: 按住 Alt 点击骰子可解锁整层
`;
document.body.appendChild(panel);
if(isMobile) {
panel.querySelector('#g-panel-close').onclick = () => panel.style.display = 'none';
}
btn.addEventListener('click', () => { panel.style.display = panel.style.display==='block'?'none':'block'; });
// 批量揭开逻辑 (Reveal Next 10)
const revealNext10 = () => {
// 获取所有遮罩元素
const masks = document.querySelectorAll('.g-dice-mask');
if (masks.length === 0) {
alert("当前页面没有未揭开的骰子!");
return;
}
const unlocked = loadUnlocked();
const lidsToUnlock = new Set();
// 1. 搜集前10个唯一的未解锁 Group ID
for (let i = 0; i < masks.length; i++) {
const gid = masks[i].getAttribute('data-group-id');
if (gid && !unlocked.has(gid)) {
lidsToUnlock.add(gid);
if (lidsToUnlock.size >= 10) break; // 凑够10个判定就停止
}
}
if (lidsToUnlock.size === 0) return;
// 2. 批量执行解锁
lidsToUnlock.forEach(gid => {
unlocked.add(gid);
// 视觉揭开:查找所有属于该组的元素(包括数字和后缀)
const sameGroup = document.querySelectorAll(`[data-group-id="${gid}"]`);
sameGroup.forEach(m => m.className = 'g-dice-revealed');
// 迷雾解锁
if (currentConfig.fogModeEnabled) {
const lockedElements = document.querySelectorAll(`.g-hidden-block[data-fog-lock="${gid}"]`);
lockedElements.forEach(el => {
el.classList.remove('g-hidden-block');
el.classList.add('g-fade-in');
});
}
});
// 3. 保存进度
let arr = Array.from(unlocked);
if(arr.length > 3000) arr = arr.slice(-2000);
localStorage.setItem(BOOK_STORAGE_KEY, JSON.stringify(arr));
if (currentConfig.soundEnabled) playDiceSound();
log(`⚡ 偷看了 ${lidsToUnlock.size} 组骰子`);
};
const update = () => {
currentConfig.foldingEnabled = panel.querySelector('#cb-fold').checked;
currentConfig.diceMaskEnabled = panel.querySelector('#cb-mask').checked;
currentConfig.fogModeEnabled = panel.querySelector('#cb-fog').checked;
currentConfig.soundEnabled = panel.querySelector('#cb-sound').checked;
currentConfig.autoMusicEnabled = panel.querySelector('#cb-auto-music').checked;
currentConfig.cleanupEnabled = panel.querySelector('#cb-cleanup').checked;
currentConfig.danmakuEnabled = panel.querySelector('#cb-danmaku').checked;
// 新增:夜间模式状态更新
currentConfig.nightModeEnabled = panel.querySelector('#cb-night').checked;
// 实时应用样式(saveConfig 中也会处理,但为了响应速度这里可以显式调用或依赖 saveConfig)
saveConfig(currentConfig);
btn.innerText='🔄';
};
panel.querySelectorAll('input').forEach(cb => cb.addEventListener('change', update));
panel.querySelector('#btn-reset').addEventListener('click', clearBookData);
magicBtn.addEventListener('click', revealNext10);
// 🌍 暴露给手机版调用 (挂载到 unsafeWindow)
const targetWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
targetWindow.GululuHelper = targetWindow.GululuHelper || {};
targetWindow.GululuHelper.revealNext10 = revealNext10;
targetWindow.GululuHelper.toggleSettings = () => {
// 确保面板显示时层级足够高 (虽然 CSS 已经改了 z-index,这里双重保险)
panel.style.zIndex = '2147483647';
panel.style.display = panel.style.display==='block'?'none':'block';
};
}
// 🚀 统一启动入口
const start = () => {
createUI();
preloadSound();
initCommentObserver();
initVFXObserver();
BG_MANAGER.init(); // 启动背景引擎
initAutoMusicObserver(); // 启动自动音乐
initMemoryCleaner(); // 启动内存优化
initAnchorLocator(); // 启动锚点定位
initScrollFix(); // 启动滚动修复
initContentObserver(); // 核心启动
};
// 等待 DOM 加载完成后再执行,确保 document.body 已存在
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();