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

})();