// ==UserScript==
// @name 骨碌碌沉浸式全能助手Beta V3.92
// @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.92
// @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 = `
<引用 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 = `)
if (pTag.style.color) {
const c = pTag.style.color.replace(/\s/g, '');
if (c === 'rgb(0,0,0)' || c === '#000000' || c === 'black') {
pTag.style.color = '';
}
}
}
}
} 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 ` 即将跳转到 Book:${bid}
即将跳转到 Book:${bid}跳转确认
第 ${fid} 层跳转确认
第 ${fid} 层
/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];
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();
};
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 = `