ChromaFlow

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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

})();