Greasy Fork

Greasy Fork is available in English.

ChromaFlow

网页文字渐变色辅助阅读。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      12.0
// @description  网页文字渐变色辅助阅读。Ctrl+Shift+B 切换。
// @description:en  Reading focus with color gradients (Ctrl+Shift+B).
// @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: ''
    };

    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));
            }
        }
    });

})();