// ==UserScript== // @name ChromaFlow // @namespace http://tampermonkey.net/ // @version 6.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== (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(el) { let bg = 'rgba(0, 0, 0, 0)'; while (el && el.nodeType === 1) { bg = window.getComputedStyle(el).backgroundColor; if (bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent' && bg !== '') return bg; el = el.parentNode; } 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. 核心目标:白名单与黑名单 // ========================================== // 白名单:只有这些容器里的文字才配被当做“文章”阅读 // 包含标准 HTML 标签,以及 AI Studio/ChatGPT/Claude 常见的富文本容器 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. 文本切割与染色引擎 // ========================================== function processBlock(block) { if (!isEnabled || block.dataset.beelineProcessed === "true") return; if (block.closest(IGNORE_SELECTORS)) return; // 再次确认不在代码块内 // --- 步骤 A: 将纯文本替换为包裹 span 的单词/汉字 --- 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; if (parent.classList.contains('beeline-word')) return NodeFilter.FILTER_SKIP; 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 text = node.nodeValue; const parent = node.parentNode; const fragment = document.createDocumentFragment(); const tokens = tokenizeText(text); 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); } }); parent.replaceChild(fragment, node); }); block.dataset.beelineProcessed = "true"; colorizeBlock(block); // 切割完毕立刻上色 } function colorizeBlock(block) { if (!document.body.contains(block) || !isEnabled) return; const isDark = isDarkTheme(getRealBackgroundColor(block)); const theme = isDark ? THEMES.dark : THEMES.light; const spans = Array.from(block.querySelectorAll('.beeline-word')); if (spans.length === 0) return; let lines = []; let currentLine = []; let lastY = -1; // --- 步骤 B: 物理测算 Y 轴换行 (带容差) --- spans.forEach(span => { const rect = span.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return; // 将 Y 坐标归一化到 5px 的网格中,防止同一行中英文高度差异被误判为换行 const y = Math.round(rect.top / 5) * 5; if (lastY === -1 || Math.abs(y - lastY) > 5) { if (currentLine.length > 0) lines.push(currentLine); currentLine = [span]; lastY = y; } else { currentLine.push(span); } }); if (currentLine.length > 0) lines.push(currentLine); // --- 步骤 C: 渲染渐变色 --- 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; } }); }); } // ========================================== // 5. 扫描调度系统 (低耗能心跳) // ========================================== function scanAndProcess() { if (!isEnabled) return; // 只寻找白名单内的容器 const blocks = document.querySelectorAll(TARGET_SELECTORS); blocks.forEach(block => { // 如果还未处理,则执行切割+上色 if (block.dataset.beelineProcessed !== "true") { processBlock(block); } }); } let needsUpdate = true; const observer = new MutationObserver(() => needsUpdate = true); observer.observe(document.body, { childList: true, characterData: true, subtree: true }); setInterval(() => { if (needsUpdate && isEnabled) { scanAndProcess(); needsUpdate = false; } }, 400); // ========================================== // 6. 窗口重绘与快捷键 // ========================================== let resizeTimer; window.addEventListener('resize', () => { if (!isEnabled) return; clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { // 窗口改变时,只重新测算坐标和上色,不需要重新切割 DOM,性能极高 document.querySelectorAll(TARGET_SELECTORS).forEach(block => { if (block.dataset.beelineProcessed === "true") colorizeBlock(block); }); }, 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 { needsUpdate = true; } } }); })();