// ==UserScript== // @name ChromaFlow // @namespace http://tampermonkey.net/ // @version 12.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; // ========================================== // 0. 全局默认配置与站点适配区 // ========================================== let config = { targets: 'p, li, blockquote, dd, dt, ms-cmark-node, .text-base, .markdown, .markdown-body, .prose, article, [data-testid="tweetText"]', // 【还原】移除了沉浸式翻译的忽略类名,让中英文同样享受渐变染色 ignores: 'code, pre, kbd, button, input, textarea, select, [contenteditable="true"], .inline-code', tolerance: 12, debug: false, shadowHosts: '' }; const SITE_ADAPTERS = [ { name: "MSN & Bing News", match: /msn\.com|bing\.com/i, targets: 'p, li, blockquote, dd, dt', // 【还原】移除了沉浸式翻译的忽略类名 ignores: 'code, pre, kbd, button, input, textarea, select, [contenteditable="true"], .inline-code, views-native-ad, cp-article-image, .ad-slot-placeholder, .article-cont-read-container, .continue-reading-slot, fluent-button, .article-image-slot, .intra-article-module', tolerance: 15, shadowHosts: 'cp-article' } ]; const currentHost = window.location.hostname; for (let adapter of SITE_ADAPTERS) { if (adapter.match.test(currentHost)) { if (adapter.targets) config.targets = adapter.targets; if (adapter.ignores) config.ignores += `, ${adapter.ignores}`; if (adapter.tolerance) config.tolerance = adapter.tolerance; if (adapter.shadowHosts) config.shadowHosts = adapter.shadowHosts; if (config.debug) console.log(`ChromaFlow: 已成功加载 [${adapter.name}] 专属适配配置。`); break; } } // ========================================== // 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] } }; const nodeRangesMap = new WeakMap(); const processedBlocks = new WeakMap(); 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); return (((r * 299) + (g * 587) + (b * 114)) / 1000) >= 128; } function cleanupNodeRanges(node) { const entries = nodeRangesMap.get(node); if (entries) { entries.forEach(entry => { if (entry.bucket && highlightsMap[entry.bucket]) { highlightsMap[entry.bucket].delete(entry.range); } }); nodeRangesMap.delete(node); } } // ========================================== // 3. 物理行高聚类引擎 (修复单字孤行颜色丢失) // ========================================== function processBlock(block, isResize = false) { if (!isEnabled) return; if (block.closest(config.ignores)) return; if (block.offsetWidth === 0 && block.offsetHeight === 0) { const blockStyle = window.getComputedStyle(block); if (blockStyle.display !== 'contents') return; } // 保留对沉浸式翻译加载动画的忽略,避免加载时异常 if (block.querySelector('.immersive-translate-loading-spinner')) return; const originalColor = window.getComputedStyle(block).color; const themePrefix = isTextColorLight(originalColor) ? '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(config.ignores)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); const textNodes = []; let currentNode; while (currentNode = walker.nextNode()) textNodes.push(currentNode); if (textNodes.length === 0) { processedBlocks.set(block, true); return; } let allWords = []; textNodes.forEach(node => { let wordEntries = nodeRangesMap.get(node); if (!wordEntries || !isResize) { if (wordEntries) cleanupNodeRanges(node); wordEntries = []; const text = node.nodeValue; const regex = /(?:\p{Emoji_Presentation}|\p{Extended_Pictographic})|[\u4E00-\u9FFF]|[a-zA-Z0-9_’'.-]+|[^\s\u4E00-\u9FFF]+/gu; let match; const isLink = !!(node.parentNode && node.parentNode.closest('a')); while ((match = regex.exec(text)) !== null) { try { const range = new Range(); range.setStart(node, match.index); range.setEnd(node, match.index + match[0].length); wordEntries.push({ range, bucket: null, isLink: isLink }); } catch(e) { if (config.debug) console.warn('ChromaFlow Range 生成异常:', e, match[0]); } } nodeRangesMap.set(node, wordEntries); } wordEntries.forEach(entry => { const rects = entry.range.getClientRects(); if (rects.length === 0) return; const rect = rects[0]; if (rect.width === 0 || rect.height === 0) return; allWords.push({ entry: entry, x: rect.left, y: rect.top + rect.height / 2 }); }); }); if (allWords.length === 0) { processedBlocks.set(block, true); return; } allWords.sort((a, b) => a.y - b.y || a.x - b.x); let lines = []; let currentLine = [allWords[0]]; let currentLineY = allWords[0].y; for (let i = 1; i < allWords.length; i++) { let word = allWords[i]; if (Math.abs(word.y - currentLineY) < config.tolerance) { 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; const lineLength = lineWords.length; lineWords.forEach((wordObj, wordIndex) => { if (wordObj.entry.isLink) { if (wordObj.entry.bucket) { if (highlightsMap[wordObj.entry.bucket]) { highlightsMap[wordObj.entry.bucket].delete(wordObj.entry.range); } wordObj.entry.bucket = null; } return; } // 孤行单字修复:将 progress 设为 0,经过奇偶行反转后自然衔接上一行末尾颜色 let progress = lineLength > 1 ? wordIndex / (lineLength - 1) : 0; if (isOdd) progress = 1 - progress; const newBucket = `${themePrefix}${Math.min(20, Math.max(0, Math.round(progress * 20)))}`; if (wordObj.entry.bucket !== newBucket) { if (wordObj.entry.bucket && highlightsMap[wordObj.entry.bucket]) { highlightsMap[wordObj.entry.bucket].delete(wordObj.entry.range); } highlightsMap[newBucket].add(wordObj.entry.range); wordObj.entry.bucket = newBucket; } }); }); processedBlocks.set(block, true); } // ========================================== // 4. 异步调度与 Shadow DOM 穿透监听 // ========================================== const pendingBlocks = new Set(); let processTimer = null; function queueBlock(block) { if (!isEnabled) return; pendingBlocks.add(block); if (processTimer) return; processTimer = setTimeout(() => { pendingBlocks.forEach(b => { if (b.isConnected) processBlock(b, false); }); pendingBlocks.clear(); processTimer = null; }, 150); } const viewportObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting && isEnabled) { const block = entry.target; if (!processedBlocks.get(block)) { queueBlock(block); } } }); }, { rootMargin: '400px' }); const observedShadowHosts = new WeakSet(); function observeShadowRoot(host) { if (observedShadowHosts.has(host) || !host.shadowRoot) return; observedShadowHosts.add(host); const shadowObserver = new MutationObserver((mutations) => { if (!isEnabled) return; const blocksToProcess = new Set(); 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(config.targets); if (block) { processedBlocks.set(block, false); blocksToProcess.add(block); } }); blocksToProcess.forEach(block => queueBlock(block)); }); shadowObserver.observe(host.shadowRoot, { childList: true, characterData: true, subtree: true }); } function scanAndObserve() { if (!isEnabled) return; document.querySelectorAll(config.targets).forEach(block => { if (!processedBlocks.has(block)) { processedBlocks.set(block, false); viewportObserver.observe(block); } }); if (config.shadowHosts) { document.querySelectorAll(config.shadowHosts).forEach(host => { if (host.shadowRoot) { observeShadowRoot(host); host.shadowRoot.querySelectorAll(config.targets).forEach(block => { if (!processedBlocks.has(block)) { processedBlocks.set(block, false); viewportObserver.observe(block); } }); } }); } } const mutationObserver = new MutationObserver((mutations) => { if (!isEnabled) return; const blocksToProcess = new Set(); mutations.forEach(m => { m.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { if (config.shadowHosts && node.matches && node.matches(config.shadowHosts)) { observeShadowRoot(node); if (node.shadowRoot) { node.shadowRoot.querySelectorAll(config.targets).forEach(b => { if (!processedBlocks.has(b)) { processedBlocks.set(b, false); viewportObserver.observe(b); blocksToProcess.add(b); } }); } } if (config.shadowHosts && node.querySelectorAll) { node.querySelectorAll(config.shadowHosts).forEach(host => { observeShadowRoot(host); if (host.shadowRoot) { host.shadowRoot.querySelectorAll(config.targets).forEach(b => { if (!processedBlocks.has(b)) { processedBlocks.set(b, false); viewportObserver.observe(b); blocksToProcess.add(b); } }); } }); } } }); let target = m.target; if (target.nodeType === Node.TEXT_NODE) target = target.parentNode; if (!target || !target.closest) return; const block = target.closest(config.targets); if (block) { processedBlocks.set(block, false); blocksToProcess.add(block); } }); blocksToProcess.forEach(block => queueBlock(block)); }); mutationObserver.observe(document.body, { childList: true, characterData: true, subtree: true }); let scanIntervalTime = 2000; let scanTimerId = null; function scheduleNextScan() { if (scanTimerId) clearTimeout(scanTimerId); scanTimerId = setTimeout(() => { if (isEnabled) scanAndObserve(); scanIntervalTime = Math.min(scanIntervalTime + 1000, 10000); scheduleNextScan(); }, scanIntervalTime); } scheduleNextScan(); // ========================================== // 5. 快捷键与 Resize 优化 // ========================================== function getActiveBlocksInViewport(viewportHeight, margin = 400) { const blocks = []; document.querySelectorAll(config.targets).forEach(block => { const rect = block.getBoundingClientRect(); if (rect.top < viewportHeight + margin && rect.bottom > -margin) blocks.push(block); }); if (config.shadowHosts) { document.querySelectorAll(config.shadowHosts).forEach(host => { if (host.shadowRoot) { host.shadowRoot.querySelectorAll(config.targets).forEach(block => { const rect = block.getBoundingClientRect(); if (rect.top < viewportHeight + margin && rect.bottom > -margin) blocks.push(block); }); } }); } return blocks; } let resizeTimer; window.addEventListener('resize', () => { if (!isEnabled) return; clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const blocks = getActiveBlocksInViewport(window.innerHeight, 400); blocks.forEach(block => processBlock(block, true)); }, 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()); const clearState = (block) => processedBlocks.set(block, false); document.querySelectorAll(config.targets).forEach(clearState); if (config.shadowHosts) { document.querySelectorAll(config.shadowHosts).forEach(host => { if(host.shadowRoot) host.shadowRoot.querySelectorAll(config.targets).forEach(clearState); }); } pendingBlocks.clear(); if (processTimer) { clearTimeout(processTimer); processTimer = null; } } else { scanAndObserve(); const blocks = getActiveBlocksInViewport(window.innerHeight, 0); blocks.forEach(block => queueBlock(block)); } } }); })();