ChromaFlow

网页文字渐变色辅助阅读。Ctrl+Shift+B 切换。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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
// ==/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();

})();