Greasy Fork

Greasy Fork is available in English.

ChromaFlow

网页文字渐变色辅助阅读:重构排版与着色解耦架构,完美穿透Shadow DOM(支持MSN等),优化Resize性能。Ctrl+Shift+B 切换。

当前为 2026-04-19 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         ChromaFlow
// @namespace    http://tampermonkey.net/
// @version      11.0
// @description  网页文字渐变色辅助阅读:重构排版与着色解耦架构,完美穿透Shadow DOM(支持MSN等),优化Resize性能。Ctrl+Shift+B 切换。
// @description:en  Reading focus with color gradients (Ctrl+Shift+B). Deeply supports Shadow DOM & optimized resize.
// @author       Lain1984
// @license      MIT
// @match        *://*/*
// @grant        GM_addStyle
// ==/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: '' // 【新增】声明需要穿透的 Shadow DOM 宿主选择器
    };

    const SITE_ADAPTERS = [
        {
            name: "MSN & Bing News",
            match: /msn\.com|bing\.com/i,
            // 【修正】移除 cp-article,因为它是 Shadow Host,真正的文本在它内部的 p 标签里
            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,
            // 【新增】指定要穿透的 Shadow 宿主
            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; // MSN使用专属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;

                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 });
                    } 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) => {
                let progress = lineLength > 1 ? wordIndex / (lineLength - 1) : 0.5;
                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' });

    // 【新增】Shadow DOM MutationObserver 管理器
    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);
            }
        });

        // 扫描并穿透 Shadow DOM
        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);
                        }
                    });
                }
            });
        }
    }

    // 主 MutationObserver,增加对动态挂载的 Shadow Host 的捕获
    const mutationObserver = new MutationObserver((mutations) => {
        if (!isEnabled) return;
        const blocksToProcess = new Set();

        mutations.forEach(m => {
            // 拦截新增的 Shadow Host 节点
            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);
                                    }
                                });
                            }
                        });
                    }
                }
            });

            // 处理常规 DOM 变更
            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 优化
    // ==========================================

    // 抽取视口内 Block 获取逻辑,兼容 Shadow DOM
    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());
                // 清理状态时,也需要顾及 Shadow DOM 内部
                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));
            }
        }
    });

})();