// ==UserScript== // @name ChromaFlow // @namespace http://tampermonkey.net/ // @version 10.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'; if (typeof CSS === 'undefined' || !CSS.highlights) { console.warn('ChromaFlow: 您的浏览器不支持 CSS Custom Highlight API,脚本无法运行。'); return; } let isEnabled = true; // 核心修复:使用 WeakMap 绑定 TextNode 和它对应的 Range 内存对象 const nodeRangesMap = new WeakMap(); const processedBlocks = new WeakMap(); // ========================================== // 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])) ]; } function getGradientColorStr(theme, progress) { let rgb; if (progress < 0.5) rgb = interpolateColor(theme.c1, theme.mid, progress / 0.5); else rgb = interpolateColor(theme.mid, theme.c2, (progress - 0.5) / 0.5); return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; } // ========================================== // 2. 初始化 CSS Highlights 调色板 // ========================================== const highlightsMap = {}; let cssStr = ''; for (let i = 0; i <= 20; i++) { const progress = i / 20; const lightName = `cf-light-${i}`; const darkName = `cf-dark-${i}`; highlightsMap[lightName] = new Highlight(); highlightsMap[darkName] = new Highlight(); CSS.highlights.set(lightName, highlightsMap[lightName]); CSS.highlights.set(darkName, highlightsMap[darkName]); cssStr += `::highlight(${lightName}) { color: ${getGradientColorStr(THEMES.light, progress)} !important; }\n`; cssStr += `::highlight(${darkName}) { color: ${getGradientColorStr(THEMES.dark, progress)} !important; }\n`; } GM_addStyle(cssStr); function isTextColorLight(colorStr) { if (!colorStr) return false; const rgbMatch = colorStr.match(/\d+/g); if (!rgbMatch || rgbMatch.length < 3) return false; const r = parseInt(rgbMatch[0], 10), g = parseInt(rgbMatch[1], 10), b = parseInt(rgbMatch[2], 10); const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000; return yiq >= 128; } const TARGET_SELECTORS = 'p, li, blockquote, dd, dt, ms-cmark-node, .text-base, .markdown, .markdown-body, .prose, article, [data-testid="tweetText"]'; const IGNORE_SELECTORS = 'code, pre, kbd, button, input, textarea, select, [contenteditable="true"], .inline-code'; // ========================================== // 3. 核心垃圾回收机制 (消灭同色Bug的根源) // ========================================== function cleanupNodeRanges(node) { const activeRanges = nodeRangesMap.get(node); if (activeRanges) { activeRanges.forEach(item => { if (highlightsMap[item.bucket]) { highlightsMap[item.bucket].delete(item.range); } }); nodeRangesMap.delete(node); } } // ========================================== // 4. 物理行高聚类引擎 // ========================================== function processBlock(block) { if (!isEnabled) return; if (block.closest(IGNORE_SELECTORS)) return; // 避免在元素还未完全渲染、或者沉浸式翻译正在转圈时计算坐标 if (block.offsetWidth === 0 || block.offsetHeight === 0) return; if (block.querySelector('.immersive-translate-loading-spinner')) return; const originalColor = window.getComputedStyle(block).color; const isDark = isTextColorLight(originalColor); const themePrefix = isDark ? 'cf-dark-' : 'cf-light-'; const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (!node.nodeValue.trim()) return NodeFilter.FILTER_SKIP; if (node.parentNode && node.parentNode.closest(IGNORE_SELECTORS)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); const textNodes = []; let currentNode; while (currentNode = walker.nextNode()) { textNodes.push(currentNode); // 【关键修复】:在复用节点产生变异前,强制粉碎旧残留! cleanupNodeRanges(currentNode); } if (textNodes.length === 0) { processedBlocks.set(block, true); return; } const blockRect = block.getBoundingClientRect(); let allWords = []; textNodes.forEach(node => { const text = node.nodeValue; const regex = /[\u4E00-\u9FFF]|[a-zA-Z0-9_’'.-]+|[^\s\u4E00-\u9FFF]+/g; let match; const myRanges = []; while ((match = regex.exec(text)) !== null) { try { const range = new Range(); range.setStart(node, match.index); range.setEnd(node, match.index + match[0].length); const rects = range.getClientRects(); if (rects.length === 0) continue; const rect = rects[0]; if (rect.width === 0 || rect.height === 0) continue; const centerY = rect.top + rect.height / 2; const wordObj = { range, x: rect.left, y: centerY }; allWords.push(wordObj); myRanges.push(wordObj); } catch(e) {} } if (myRanges.length > 0) { nodeRangesMap.set(node, myRanges); } }); if (allWords.length === 0) { processedBlocks.set(block, true); return; } allWords.sort((a, b) => a.y - b.y); let lines = []; let currentLine = [allWords[0]]; let currentLineY = allWords[0].y; // 12px 聚类容差,防止行高上下轻微波动导致断层 for (let i = 1; i < allWords.length; i++) { let word = allWords[i]; if (Math.abs(word.y - currentLineY) < 12) { currentLine.push(word); currentLineY = (currentLineY * (currentLine.length - 1) + word.y) / currentLine.length; } else { lines.push(currentLine); currentLine = [word]; currentLineY = word.y; } } lines.push(currentLine); // 注入新高亮 lines.forEach((lineWords, lineIndex) => { const isOdd = lineIndex % 2 !== 0; lineWords.sort((a, b) => a.x - b.x); const lineLength = lineWords.length; lineWords.forEach((wordObj, wordIndex) => { let progress = lineLength > 1 ? wordIndex / (lineLength - 1) : 0.5; if (isOdd) progress = 1 - progress; const bucketIndex = Math.min(20, Math.max(0, Math.round(progress * 20))); const bucketName = `${themePrefix}${bucketIndex}`; highlightsMap[bucketName].add(wordObj.range); wordObj.bucket = bucketName; // 记录它所在的桶,方便未来清理 }); }); processedBlocks.set(block, true); } // ========================================== // 5. 异步调度与变动监听 // ========================================== const pendingBlocks = new Set(); let processTimer = null; function queueBlock(block) { if (!isEnabled) return; pendingBlocks.add(block); if (processTimer) return; // 等待 150ms 确保浏览器排版彻底完成(让折行、Line-clamp 稳固) processTimer = setTimeout(() => { pendingBlocks.forEach(b => { if (b.isConnected) processBlock(b); }); pendingBlocks.clear(); processTimer = null; }, 150); } function scanAll() { if (!isEnabled) return; document.querySelectorAll(TARGET_SELECTORS).forEach(block => { if (!processedBlocks.get(block)) queueBlock(block); }); } const observer = new MutationObserver((mutations) => { mutations.forEach(m => { let target = m.target; if (target.nodeType === Node.TEXT_NODE) target = target.parentNode; if (!target || !target.closest) return; const block = target.closest(TARGET_SELECTORS); if (block) { processedBlocks.set(block, false); queueBlock(block); } }); }); observer.observe(document.body, { childList: true, characterData: true, subtree: true }); setInterval(() => { if (isEnabled) scanAll(); }, 1500); // ========================================== // 6. 快捷键与清理 // ========================================== let resizeTimer; window.addEventListener('resize', () => { if (!isEnabled) return; clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { document.querySelectorAll(TARGET_SELECTORS).forEach(block => { processedBlocks.set(block, false); queueBlock(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) { Object.values(highlightsMap).forEach(hl => hl.clear()); document.querySelectorAll(TARGET_SELECTORS).forEach(block => { processedBlocks.set(block, false); }); pendingBlocks.clear(); if (processTimer) { clearTimeout(processTimer); processTimer = null; } } else { scanAll(); } } }); })();