// ==UserScript== // @name ChromaFlow // @namespace http://tampermonkey.net/ // @version 8.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() { 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) // ========================================== // 【核心新增】:加入了 [data-testid="tweetText"] 以完美支持 X (Twitter) 的推文正文 const TARGET_SELECTORS = 'p, li, blockquote, dd, dt, ms-cmark-node, .text-base, .markdown, .markdown-body, .prose, [data-testid="tweetText"]'; // 黑名单保持不变,严格保护代码块和输入框 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; 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; spans.forEach(span => { const rect = span.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return; 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; // 保留 X/Twitter 话题和链接的原生下划线体验 if (span.closest('a')) { span.style.textDecoration = 'underline'; span.style.textDecorationColor = colorStr; } }); }); } // ========================================== // 6. 智能防抖扫描系统 // ========================================== let updateTimeout = null; function triggerUpdate() { if (!isEnabled) return; // 缩短防抖时间到 600ms,让 X 在无限往下滚动刷推文时颜色能更快跟上 clearTimeout(updateTimeout); updateTimeout = setTimeout(() => { wrapTextNodesSafely(); applyGlobalColors(); }, 600); } const observer = new MutationObserver((mutations) => { let hasValidMutation = false; for (let m of mutations) { 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(); } } }); })();