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      10.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;

    // 核心修复:使用 WeakMap 绑定 TextNode 和它对应的 Range 内存对象
    const nodeRangesMap = new WeakMap();
    const processedBlocks = new WeakMap();

    // ==========================================
    // 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] }
    };

    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);
        const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
        return yiq >= 128;
    }

    const TARGET_SELECTORS = 'p, li, blockquote, dd, dt, ms-cmark-node, .text-base, .markdown, .markdown-body, .prose, article, [data-testid="tweetText"]';
    const IGNORE_SELECTORS = 'code, pre, kbd, button, input, textarea, select, [contenteditable="true"], .inline-code';

    // ==========================================
    // 3. 核心垃圾回收机制 (消灭同色Bug的根源)
    // ==========================================
    function cleanupNodeRanges(node) {
        const activeRanges = nodeRangesMap.get(node);
        if (activeRanges) {
            activeRanges.forEach(item => {
                if (highlightsMap[item.bucket]) {
                    highlightsMap[item.bucket].delete(item.range);
                }
            });
            nodeRangesMap.delete(node);
        }
    }

    // ==========================================
    // 4. 物理行高聚类引擎
    // ==========================================
    function processBlock(block) {
        if (!isEnabled) return;
        if (block.closest(IGNORE_SELECTORS)) return;

        // 避免在元素还未完全渲染、或者沉浸式翻译正在转圈时计算坐标
        if (block.offsetWidth === 0 || block.offsetHeight === 0) return;
        if (block.querySelector('.immersive-translate-loading-spinner')) return;

        const originalColor = window.getComputedStyle(block).color;
        const isDark = isTextColorLight(originalColor);
        const themePrefix = isDark ? '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(IGNORE_SELECTORS)) return NodeFilter.FILTER_REJECT;
                return NodeFilter.FILTER_ACCEPT;
            }
        });

        const textNodes = [];
        let currentNode;
        while (currentNode = walker.nextNode()) {
            textNodes.push(currentNode);
            // 【关键修复】:在复用节点产生变异前,强制粉碎旧残留!
            cleanupNodeRanges(currentNode);
        }

        if (textNodes.length === 0) {
            processedBlocks.set(block, true);
            return;
        }

        const blockRect = block.getBoundingClientRect();
        let allWords = [];

        textNodes.forEach(node => {
            const text = node.nodeValue;
            const regex = /[\u4E00-\u9FFF]|[a-zA-Z0-9_’'.-]+|[^\s\u4E00-\u9FFF]+/g;
            let match;
            const myRanges = [];

            while ((match = regex.exec(text)) !== null) {
                try {
                    const range = new Range();
                    range.setStart(node, match.index);
                    range.setEnd(node, match.index + match[0].length);

                    const rects = range.getClientRects();
                    if (rects.length === 0) continue;

                    const rect = rects[0];
                    if (rect.width === 0 || rect.height === 0) continue;

                    const centerY = rect.top + rect.height / 2;
                    const wordObj = { range, x: rect.left, y: centerY };
                    allWords.push(wordObj);
                    myRanges.push(wordObj);
                } catch(e) {}
            }

            if (myRanges.length > 0) {
                nodeRangesMap.set(node, myRanges);
            }
        });

        if (allWords.length === 0) {
            processedBlocks.set(block, true);
            return;
        }

        allWords.sort((a, b) => a.y - b.y);

        let lines = [];
        let currentLine = [allWords[0]];
        let currentLineY = allWords[0].y;

        // 12px 聚类容差,防止行高上下轻微波动导致断层
        for (let i = 1; i < allWords.length; i++) {
            let word = allWords[i];
            if (Math.abs(word.y - currentLineY) < 12) {
                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;
            lineWords.sort((a, b) => a.x - b.x);

            const lineLength = lineWords.length;

            lineWords.forEach((wordObj, wordIndex) => {
                let progress = lineLength > 1 ? wordIndex / (lineLength - 1) : 0.5;
                if (isOdd) progress = 1 - progress;

                const bucketIndex = Math.min(20, Math.max(0, Math.round(progress * 20)));
                const bucketName = `${themePrefix}${bucketIndex}`;

                highlightsMap[bucketName].add(wordObj.range);
                wordObj.bucket = bucketName; // 记录它所在的桶,方便未来清理
            });
        });

        processedBlocks.set(block, true);
    }

    // ==========================================
    // 5. 异步调度与变动监听
    // ==========================================
    const pendingBlocks = new Set();
    let processTimer = null;

    function queueBlock(block) {
        if (!isEnabled) return;
        pendingBlocks.add(block);

        if (processTimer) return;
        // 等待 150ms 确保浏览器排版彻底完成(让折行、Line-clamp 稳固)
        processTimer = setTimeout(() => {
            pendingBlocks.forEach(b => {
                if (b.isConnected) processBlock(b);
            });
            pendingBlocks.clear();
            processTimer = null;
        }, 150);
    }

    function scanAll() {
        if (!isEnabled) return;
        document.querySelectorAll(TARGET_SELECTORS).forEach(block => {
            if (!processedBlocks.get(block)) queueBlock(block);
        });
    }

    const observer = new MutationObserver((mutations) => {
        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(TARGET_SELECTORS);
            if (block) {
                processedBlocks.set(block, false);
                queueBlock(block);
            }
        });
    });

    observer.observe(document.body, { childList: true, characterData: true, subtree: true });

    setInterval(() => { if (isEnabled) scanAll(); }, 1500);

    // ==========================================
    // 6. 快捷键与清理
    // ==========================================
    let resizeTimer;
    window.addEventListener('resize', () => {
        if (!isEnabled) return;
        clearTimeout(resizeTimer);
        resizeTimer = setTimeout(() => {
            document.querySelectorAll(TARGET_SELECTORS).forEach(block => {
                processedBlocks.set(block, false);
                queueBlock(block);
            });
        }, 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());
                document.querySelectorAll(TARGET_SELECTORS).forEach(block => {
                    processedBlocks.set(block, false);
                });
                pendingBlocks.clear();
                if (processTimer) {
                    clearTimeout(processTimer);
                    processTimer = null;
                }
            } else {
                scanAll();
            }
        }
    });
})();