// ==UserScript== // @name ChromaFlow // @namespace http://tampermonkey.net/ // @version 16.0 // @description 网页文字渐变色辅助阅读。Ctrl+Shift+B 切换。 // @description:en Reading focus with color gradients (Ctrl+Shift+B). // @author Lain1984 // @license MIT // @match *://*/* // @grant GM_addStyle // @downloadURL https://update.greasyfork.icu/scripts/574458/ChromaFlow.user.js // @updateURL https://update.greasyfork.icu/scripts/574458/ChromaFlow.meta.js // ==/UserScript== // ==/UserScript== (function() { 'use strict'; if (typeof CSS === 'undefined' || !CSS.highlights) { console.warn('ChromaFlow: 您的浏览器不支持 CSS Custom Highlight API,脚本无法运行。'); return; } // ========================================== // 模块 1:全局配置 // ========================================== const Config = { enabled: true, debug: false, bucketCount: 20, tolerance: 16, wordRegex: /(?:\p{Emoji_Presentation}|\p{Extended_Pictographic})|\p{Script=Han}|[a-zA-Z0-9_’'.-]+|[^\s\p{Script=Han}a-zA-Z0-9_’'.-]+/gu, selectors: { targets: 'p, li, blockquote, dd, dt, h1, h2, h3, h4, h5, h6, ms-cmark-node, .text-base, .markdown-body, .prose, [data-testid="tweetText"]', ignores: 'code, pre, kbd, button, input, textarea, select, [contenteditable="true"], .inline-code, .immersive-translate-loading-spinner', shadowHosts: '' }, 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] } }, initAdapters() { const currentHost = window.location.hostname; const adapters = [ { name: "MSN & Bing News", match: /msn\.com|bing\.com/i, targets: 'p, h2, h3, h4, h5, h6, blockquote, li', ignores: 'views-native-ad, fluent-button, .ad-slot-placeholder, .article-cont-read-container, .continue-reading-slot', tolerance: 15, shadowHosts: 'cp-article' // 关键:MSN 使用 Web Components } ]; for (let adapter of adapters) { if (adapter.match.test(currentHost)) { if (adapter.targets) this.selectors.targets = adapter.targets; if (adapter.ignores) this.selectors.ignores += `, ${adapter.ignores}`; if (adapter.tolerance) this.tolerance = adapter.tolerance; if (adapter.shadowHosts) this.selectors.shadowHosts = adapter.shadowHosts; break; } } } }; // ========================================== // 模块 2:颜色计算与 CSS 注入引擎 (核心突破:Shadow 穿透) // ========================================== class ColorEngine { constructor() { this.highlightsMap = {}; this.baseCssStr = ''; this.initPalettes(); } interpolate(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])) ]; } getGradientRGB(theme, progress) { let rgb = progress < 0.5 ? this.interpolate(theme.c1, theme.mid, progress / 0.5) : this.interpolate(theme.mid, theme.c2, (progress - 0.5) / 0.5); return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; } initPalettes() { for (let i = 0; i <= Config.bucketCount; i++) { const progress = i / Config.bucketCount; ['light', 'dark'].forEach(theme => { const bucketName = `cf-${theme}-${i}`; this.highlightsMap[bucketName] = new Highlight(); CSS.highlights.set(bucketName, this.highlightsMap[bucketName]); this.baseCssStr += `::highlight(${bucketName}) { color: ${this.getGradientRGB(Config.themes[theme], progress)} !important; }\n`; }); } // 主文档注入 this.injectCSS(document); } // 动态将样式注入到目标作用域 (突破 Web Components 样式隔离) injectCSS(root) { const id = 'chromaflow-styles'; if (root.getElementById && root.getElementById(id)) return; if (root.querySelector && root.querySelector(`#${id}`)) return; const style = document.createElement('style'); style.id = id; style.textContent = this.baseCssStr; if (root === document) { if (typeof GM_addStyle !== 'undefined') { GM_addStyle(this.baseCssStr); // 兼容某些油猴特性 } else { document.head.appendChild(style); } } else { root.appendChild(style); // 注入到 ShadowRoot } } clearAll() { Object.values(this.highlightsMap).forEach(hl => hl.clear()); } isLightText(colorStr) { if (!colorStr) return false; const rgbMatch = colorStr.match(/\d+/g); if (!rgbMatch || rgbMatch.length < 3) return false; const [r, g, b] = rgbMatch.map(Number); return (((r * 299) + (g * 587) + (b * 114)) / 1000) >= 128; } assignRangeToBucket(range, bucketName, oldBucketName) { if (oldBucketName === bucketName) return; if (oldBucketName && this.highlightsMap[oldBucketName]) { this.highlightsMap[oldBucketName].delete(range); } if (bucketName && this.highlightsMap[bucketName]) { this.highlightsMap[bucketName].add(range); } } } // ========================================== // 模块 3:核心文本解析与聚类 (健壮节点穿越) // ========================================== class TextProcessor { constructor(colorEngine) { this.colorEngine = colorEngine; this.nodeRangesMap = new WeakMap(); this.processedBlocks = new WeakMap(); this.blockDirectionState = new WeakMap(); } clearState(block) { this.processedBlocks.set(block, false); } cleanupNode(node) { const entries = this.nodeRangesMap.get(node); if (entries) { entries.forEach(entry => this.colorEngine.assignRangeToBucket(entry.range, null, entry.bucket)); this.nodeRangesMap.delete(node); } } // 安全地向上跨越层级和影子DOM查找前驱状态 getPreviousDirectionState(block) { let current = block; let depth = 0; while (current && depth < 12) { let sibling = current.previousElementSibling; while (sibling) { if (sibling.querySelectorAll) { const targets = sibling.querySelectorAll(Config.selectors.targets); if (targets.length > 0) { for (let i = targets.length - 1; i >= 0; i--) { if (this.blockDirectionState.has(targets[i])) { return this.blockDirectionState.get(targets[i]); } } } } if (sibling.matches && sibling.matches(Config.selectors.targets) && this.blockDirectionState.has(sibling)) { return this.blockDirectionState.get(sibling); } sibling = sibling.previousElementSibling; } // 没找到兄弟?往上爬。如果碰到了 ShadowRoot 边界,通过 host 跨越到外层 Light DOM 继续找 if (current.parentElement) { current = current.parentElement; } else if (current.getRootNode && current.getRootNode() instanceof ShadowRoot) { current = current.getRootNode().host; } else { break; } depth++; } return 0; } processBlock(block, isResize = false) { if (!Config.enabled || block.closest(Config.selectors.ignores)) return; if (!block.isConnected) return; // 防御断开的 DOM if (block.offsetWidth === 0 && block.offsetHeight === 0) { if (window.getComputedStyle(block).display !== 'contents') return; } const originalColor = window.getComputedStyle(block).color; const themePrefix = this.colorEngine.isLightText(originalColor) ? 'cf-dark-' : 'cf-light-'; const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, { acceptNode: node => { if (!node.nodeValue.trim()) return NodeFilter.FILTER_SKIP; if (node.parentNode && node.parentNode.closest(Config.selectors.ignores)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); const textNodes = []; let currentNode; while (currentNode = walker.nextNode()) textNodes.push(currentNode); if (textNodes.length === 0) { this.processedBlocks.set(block, true); return; } let allWords = []; textNodes.forEach(node => { let wordEntries = this.nodeRangesMap.get(node); if (!wordEntries || isResize) { this.cleanupNode(node); wordEntries = []; const text = node.nodeValue; Config.wordRegex.lastIndex = 0; let match; // 安全判断链接 (由于使用TreeWalker,parentNode必为Element) const isLink = !!(node.parentNode.closest('a')); while ((match = Config.wordRegex.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 }); } catch(e) {} } this.nodeRangesMap.set(node, wordEntries); } wordEntries.forEach(entry => { if(!entry.range.startContainer.isConnected) return; 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) { this.processedBlocks.set(block, true); return; } let directionToggle = this.getPreviousDirectionState(block); 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) => { const lineLength = lineWords.length; const isOdd = directionToggle % 2 !== 0; lineWords.forEach((wordObj, wordIndex) => { if (wordObj.entry.isLink) { this.colorEngine.assignRangeToBucket(wordObj.entry.range, null, wordObj.entry.bucket); wordObj.entry.bucket = null; return; } let progress = lineLength > 1 ? wordIndex / (lineLength - 1) : 0; if (isOdd) progress = 1 - progress; const bucketIndex = Math.min(Config.bucketCount, Math.max(0, Math.round(progress * Config.bucketCount))); const newBucket = `${themePrefix}${bucketIndex}`; this.colorEngine.assignRangeToBucket(wordObj.entry.range, newBucket, wordObj.entry.bucket); wordObj.entry.bucket = newBucket; }); if (lineLength > 1) directionToggle++; }); this.blockDirectionState.set(block, directionToggle); this.processedBlocks.set(block, true); } } // ========================================== // 模块 4:生命周期与事件调度 (安全隔离探测) // ========================================== class ObserverManager { constructor(processor, colorEngine) { this.processor = processor; this.colorEngine = colorEngine; // 引入 Engine 用于 CSS 注入 this.pendingBlocks = new Set(); this.processTimer = null; this.observedShadowHosts = new WeakSet(); this.blockDisplayCache = new WeakMap(); this.initViewportObserver(); this.initMutationObserver(); this.initResizeObserver(); this.initFallbackScanner(); } isTrueBlockLevel(element) { const tag = element.tagName.toLowerCase(); if (['p', 'li', 'blockquote', 'dd', 'dt', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div'].includes(tag)) { return true; } if (this.blockDisplayCache.has(element)) return this.blockDisplayCache.get(element); if (!element.isConnected) return false; const display = window.getComputedStyle(element).display; const isBlock = !['inline', 'inline-block', 'contents', 'none'].includes(display); this.blockDisplayCache.set(element, isBlock); return isBlock; } // 安全获取逻辑块:过滤 ShadowRoot 导致的 TypeError getEffectiveBlock(node) { let current = node.nodeType === Node.TEXT_NODE ? node.parentNode : node; let fallback = null; // 必须验证 Node.ELEMENT_NODE (节点类型 1),防止遇到 DocumentFragment(11) 和 Document(9) 崩溃 while (current && current.nodeType === Node.ELEMENT_NODE) { if (current.tagName === 'BODY' || current.tagName === 'HTML') break; // 此时 current.matches 绝对安全 if (current.matches(Config.selectors.targets) && !current.matches('article, main')) { if (this.isTrueBlockLevel(current)) { return current; } else if (!fallback) { fallback = current; } } current = current.parentNode; } return fallback; } queueBlock(block) { if (!Config.enabled) return; this.pendingBlocks.add(block); if (!this.processTimer) { this.processTimer = setTimeout(() => { requestAnimationFrame(() => { this.pendingBlocks.forEach(b => { if (b.isConnected) this.processor.processBlock(b, false); }); this.pendingBlocks.clear(); this.processTimer = null; }); }, 150); } clearTimeout(block._cfRefetchTimer); block._cfRefetchTimer = setTimeout(() => { if (block.isConnected && Config.enabled) { this.processor.clearState(block); this.processor.processBlock(block, true); } }, 2500); } initViewportObserver() { this.viewportObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting && Config.enabled) { const block = entry.target; if (!this.processor.processedBlocks.get(block)) this.queueBlock(block); } }); }, { rootMargin: '400px' }); } observeNode(node) { if (!this.processor.processedBlocks.has(node)) { this.processor.clearState(node); this.viewportObserver.observe(node); } } observeShadowRoot(host) { if (this.observedShadowHosts.has(host) || !host.shadowRoot) return; this.observedShadowHosts.add(host); // 【核心突破】向 ShadowRoot 内注入高亮 CSS! this.colorEngine.injectCSS(host.shadowRoot); const shadowObserver = new MutationObserver(mutations => this.handleMutations(mutations)); shadowObserver.observe(host.shadowRoot, { childList: true, characterData: true, subtree: true }); } handleMutations(mutations) { if (!Config.enabled) return; const blocksToProcess = new Set(); mutations.forEach(m => { let target = m.target; const block = this.getEffectiveBlock(target); if (block) { this.processor.clearState(block); blocksToProcess.add(block); this.observeNode(block); } m.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE && Config.selectors.shadowHosts && node.matches(Config.selectors.shadowHosts)) { this.observeShadowRoot(node); } }); }); blocksToProcess.forEach(block => this.queueBlock(block)); } initMutationObserver() { this.mutationObserver = new MutationObserver(m => this.handleMutations(m)); this.mutationObserver.observe(document.body, { childList: true, characterData: true, subtree: true }); } initResizeObserver() { let resizeTimer; window.addEventListener('resize', () => { if (!Config.enabled) return; clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { this.scanAndObserve(true); }, 300); }); } scanAndObserve(forceResize = false) { if (!Config.enabled) return; document.querySelectorAll(Config.selectors.targets).forEach(node => { const block = this.getEffectiveBlock(node); if (block) { if (forceResize) this.processor.clearState(block); this.observeNode(block); if (forceResize && this.isElementInViewport(block)) this.processor.processBlock(block, true); } }); if (Config.selectors.shadowHosts) { document.querySelectorAll(Config.selectors.shadowHosts).forEach(host => { this.observeShadowRoot(host); // 注入 CSS 并监听 if (host.shadowRoot) { host.shadowRoot.querySelectorAll(Config.selectors.targets).forEach(node => { const block = this.getEffectiveBlock(node); if (block) { if (forceResize) this.processor.clearState(block); this.observeNode(block); if (forceResize && this.isElementInViewport(block)) this.processor.processBlock(block, true); } }); } }); } } isElementInViewport(el) { const rect = el.getBoundingClientRect(); return (rect.top <= (window.innerHeight + 400) && rect.bottom >= -400); } initFallbackScanner() { let scanIntervalTime = 2000; const scheduleNextScan = () => { setTimeout(() => { if (Config.enabled) this.scanAndObserve(); scanIntervalTime = Math.min(scanIntervalTime + 2000, 10000); scheduleNextScan(); }, scanIntervalTime); }; scheduleNextScan(); } } // ========================================== // 模块 5:应用入口 // ========================================== class ChromaFlowApp { constructor() { Config.initAdapters(); // 提取主机信息并加载适配器 this.colorEngine = new ColorEngine(); this.processor = new TextProcessor(this.colorEngine); this.observer = new ObserverManager(this.processor, this.colorEngine); this.initHotkeys(); } initHotkeys() { document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'b' || e.key === 'B')) { e.preventDefault(); Config.enabled = !Config.enabled; if (!Config.enabled) { this.colorEngine.clearAll(); const clearAllStates = (root) => { root.querySelectorAll(Config.selectors.targets).forEach(b => this.processor.clearState(b)); }; clearAllStates(document); if (Config.selectors.shadowHosts) { document.querySelectorAll(Config.selectors.shadowHosts).forEach(host => { if (host.shadowRoot) clearAllStates(host.shadowRoot); }); } } else { this.observer.scanAndObserve(); } } }); } } new ChromaFlowApp(); })();