// ==UserScript== // @name ChromaFlow // @namespace http://tampermonkey.net/ // @version 7.0 // @description 网页文字渐变色辅助阅读 (Ctrl+Shift+B 开关):读长文对话不串行。 // @description:en Reading focus with color gradients (Ctrl+Shift+B). // @author Lain1984 // @license MIT // @match *://*/* // @grant GM_addStyle // @downloadURL none // ==/UserScript== // ==/UserScript== (function() { 'use strict'; let isEnabled = true; // ========================================== // 1. 高对比度调色盘 // ========================================== const THEMES = { light: { c1: [210, 0, 0], mid: [30, 30, 30], c2: [0, 0, 210] }, dark: { c1: [255, 100, 100], mid: [220, 220, 220], c2: [100, 150, 255] } }; function interpolateColor(color1, color2, factor) { return [ Math.round(color1[0] + factor * (color2[0] - color1[0])), Math.round(color1[1] + factor * (color2[1] - color1[1])), Math.round(color1[2] + factor * (color2[2] - color1[2])) ]; } // ========================================== // 2. 环境探测与分词 // ========================================== function getRealBackgroundColor() { // AI 网站的背景色通常是全局的,直接取 body 或系统主题更稳定 const bodyBg = window.getComputedStyle(document.body).backgroundColor; if (bodyBg !== 'rgba(0, 0, 0, 0)' && bodyBg !== 'transparent') return bodyBg; if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) return 'rgb(30, 30, 30)'; return 'rgb(255, 255, 255)'; } function isDarkTheme(colorStr) { const rgbMatch = colorStr.match(/\d+/g); if (!rgbMatch || rgbMatch.length < 3) return false; return ((rgbMatch[0] * 299) + (rgbMatch[1] * 587) + (rgbMatch[2] * 114)) / 1000 < 128; } function tokenizeText(text) { const regex = /[\u4E00-\u9FFF]|\s+|[^\s\u4E00-\u9FFF]+/g; let result = []; let match; while ((match = regex.exec(text)) !== null) result.push(match[0]); return result; } // ========================================== // 3. 白名单与黑名单 (防止破坏 UI) // ========================================== const TARGET_SELECTORS = 'p, li, blockquote, dd, dt, ms-cmark-node, .text-base, .markdown, .markdown-body, .prose'; const IGNORE_SELECTORS = 'code, pre, kbd, button, input, textarea, select, [contenteditable="true"], .inline-code'; // ========================================== // 4. 安全拆词引擎 (DOM 操作) // ========================================== function wrapTextNodesSafely() { const blocks = document.querySelectorAll(TARGET_SELECTORS); blocks.forEach(block => { if (block.closest(IGNORE_SELECTORS)) return; // 避开代码块 const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (!node.nodeValue.trim()) return NodeFilter.FILTER_SKIP; const parent = node.parentNode; if (!parent) return NodeFilter.FILTER_SKIP; // 【核心修复】:如果文本已经在我们的 span 里,绝不重复处理(防止重影) if (parent.classList.contains('beeline-word')) return NodeFilter.FILTER_REJECT; // 如果在黑名单内,跳过 if (parent.closest(IGNORE_SELECTORS)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); let textNodes = []; let currentNode; while (currentNode = walker.nextNode()) textNodes.push(currentNode); textNodes.forEach(node => { const fragment = document.createDocumentFragment(); const tokens = tokenizeText(node.nodeValue); tokens.forEach(token => { if (token.trim() === '') { fragment.appendChild(document.createTextNode(token)); } else { const span = document.createElement('span'); span.textContent = token; span.className = 'beeline-word'; span.style.transition = 'color 0.2s ease'; fragment.appendChild(span); } }); node.parentNode.replaceChild(fragment, node); }); }); } // ========================================== // 5. 全局流体上色引擎 (完美列表过渡) // ========================================== function applyGlobalColors() { const spans = Array.from(document.querySelectorAll('.beeline-word')); if (spans.length === 0) return; const isDark = isDarkTheme(getRealBackgroundColor()); const theme = isDark ? THEMES.dark : THEMES.light; let lines = []; let currentLine = []; let lastY = -1; // 【核心修复】:使用基于文档的绝对 Y 坐标 (包含 scrollY) // 将网页上的所有文本跨段落、跨列表打散,按物理高度重新分组! spans.forEach(span => { const rect = span.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return; // 绝对高度 = 视口 top + 滚动距离。 按 8px 像素网格对齐,消除误差 const absoluteY = Math.round((rect.top + window.scrollY) / 8) * 8; if (lastY === -1 || Math.abs(absoluteY - lastY) > 8) { if (currentLine.length > 0) lines.push(currentLine); currentLine = [span]; lastY = absoluteY; } else { currentLine.push(span); } }); if (currentLine.length > 0) lines.push(currentLine); // 全局渲染:无论段落还是列表,保证物理行级别的奇偶反转 lines.forEach((lineSpans, lineIndex) => { const isOdd = lineIndex % 2 !== 0; const lineLength = lineSpans.length; lineSpans.forEach((span, wordIndex) => { let progress = lineLength > 1 ? wordIndex / (lineLength - 1) : 0.5; if (isOdd) progress = 1 - progress; let rgb; if (progress < 0.45) rgb = interpolateColor(theme.c1, theme.mid, progress / 0.45); else if (progress > 0.55) rgb = interpolateColor(theme.mid, theme.c2, (progress - 0.55) / 0.45); else rgb = theme.mid; const colorStr = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; span.style.color = colorStr; if (span.closest('a')) { span.style.textDecoration = 'underline'; span.style.textDecorationColor = colorStr; } }); }); } // ========================================== // 6. 智能防抖扫描系统 (Angular 救星) // ========================================== let updateTimeout = null; function triggerUpdate() { if (!isEnabled) return; // 【核心修复】:防抖时间延长到 800ms。 // AI 在快速吐字时,绝对不碰 DOM。等到它吐完停顿了,再执行切割和上色。 clearTimeout(updateTimeout); updateTimeout = setTimeout(() => { wrapTextNodesSafely(); applyGlobalColors(); }, 800); } // 监听 DOM 变动 const observer = new MutationObserver((mutations) => { let hasValidMutation = false; for (let m of mutations) { // 忽略我们自己添加的 span 引起的变动 if (m.target.nodeType === 1 && m.target.classList.contains('beeline-word')) continue; hasValidMutation = true; break; } if (hasValidMutation) triggerUpdate(); }); observer.observe(document.body, { childList: true, characterData: true, subtree: true }); // 初始化时执行一次 setTimeout(triggerUpdate, 500); // ========================================== // 7. 窗口重绘与快捷键 // ========================================== let resizeTimer; window.addEventListener('resize', () => { if (!isEnabled) return; clearTimeout(resizeTimer); // 缩放窗口不需要重新切割,直接重新测算全局坐标上色即可 resizeTimer = setTimeout(applyGlobalColors, 300); }); document.addEventListener('keydown', function(e) { if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'b' || e.key === 'B')) { e.preventDefault(); isEnabled = !isEnabled; if (!isEnabled) { document.querySelectorAll('.beeline-word').forEach(span => { span.style.color = ''; if (span.closest('a')) span.style.textDecoration = ''; }); } else { triggerUpdate(); } } }); })();