// ==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 `
${content}
跳转至 #${fid}
`; }); 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(); } })();