Greasy Fork

Greasy Fork is available in English.

ChromaFlow

网页文字渐变色辅助阅读 (Ctrl+Shift+B 开关):读长文对话不串行。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         ChromaFlow
// @namespace    http://tampermonkey.net/
// @version      6.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';

    let isEnabled = true;

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

    // ==========================================
    // 2. 环境探测与分词
    // ==========================================
    function getRealBackgroundColor(el) {
        let bg = 'rgba(0, 0, 0, 0)';
        while (el && el.nodeType === 1) {
            bg = window.getComputedStyle(el).backgroundColor;
            if (bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent' && bg !== '') return bg;
            el = el.parentNode;
        }
        if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) return 'rgb(30, 30, 30)';
        return 'rgb(255, 255, 255)';
    }

    function isDarkTheme(colorStr) {
        const rgbMatch = colorStr.match(/\d+/g);
        if (!rgbMatch || rgbMatch.length < 3) return false;
        return ((rgbMatch[0] * 299) + (rgbMatch[1] * 587) + (rgbMatch[2] * 114)) / 1000 < 128;
    }

    function tokenizeText(text) {
        // 匹配单个汉字,或连续英文字母/数字,或标点/空格
        const regex = /[\u4E00-\u9FFF]|\s+|[^\s\u4E00-\u9FFF]+/g;
        let result = [];
        let match;
        while ((match = regex.exec(text)) !== null) result.push(match[0]);
        return result;
    }

    // ==========================================
    // 3. 核心目标:白名单与黑名单
    // ==========================================
    // 白名单:只有这些容器里的文字才配被当做“文章”阅读
    // 包含标准 HTML 标签,以及 AI Studio/ChatGPT/Claude 常见的富文本容器
    const TARGET_SELECTORS = 'p, li, blockquote, dd, dt, ms-cmark-node, .text-base, .markdown, .markdown-body, .prose';

    // 黑名单:在白名单里,如果遇到这些元素,立刻跳过(防破坏代码块)
    const IGNORE_SELECTORS = 'code, pre, kbd, button, input, textarea, select, [contenteditable="true"], .inline-code';

    // ==========================================
    // 4. 文本切割与染色引擎
    // ==========================================
    function processBlock(block) {
        if (!isEnabled || block.dataset.beelineProcessed === "true") return;
        if (block.closest(IGNORE_SELECTORS)) return; // 再次确认不在代码块内

        // --- 步骤 A: 将纯文本替换为包裹 span 的单词/汉字 ---
        const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, {
            acceptNode: function(node) {
                if (!node.nodeValue.trim()) return NodeFilter.FILTER_SKIP;
                const parent = node.parentNode;
                if (!parent) return NodeFilter.FILTER_SKIP;
                if (parent.classList.contains('beeline-word')) return NodeFilter.FILTER_SKIP;
                if (parent.closest(IGNORE_SELECTORS)) return NodeFilter.FILTER_REJECT;
                return NodeFilter.FILTER_ACCEPT;
            }
        });

        let textNodes = [];
        let currentNode;
        while (currentNode = walker.nextNode()) textNodes.push(currentNode);

        textNodes.forEach(node => {
            const text = node.nodeValue;
            const parent = node.parentNode;
            const fragment = document.createDocumentFragment();
            const tokens = tokenizeText(text);

            tokens.forEach(token => {
                if (token.trim() === '') {
                    fragment.appendChild(document.createTextNode(token));
                } else {
                    const span = document.createElement('span');
                    span.textContent = token;
                    span.className = 'beeline-word';
                    span.style.transition = 'color 0.2s ease';
                    fragment.appendChild(span);
                }
            });
            parent.replaceChild(fragment, node);
        });

        block.dataset.beelineProcessed = "true";
        colorizeBlock(block); // 切割完毕立刻上色
    }

    function colorizeBlock(block) {
        if (!document.body.contains(block) || !isEnabled) return;

        const isDark = isDarkTheme(getRealBackgroundColor(block));
        const theme = isDark ? THEMES.dark : THEMES.light;

        const spans = Array.from(block.querySelectorAll('.beeline-word'));
        if (spans.length === 0) return;

        let lines = [];
        let currentLine = [];
        let lastY = -1;

        // --- 步骤 B: 物理测算 Y 轴换行 (带容差) ---
        spans.forEach(span => {
            const rect = span.getBoundingClientRect();
            if (rect.width === 0 && rect.height === 0) return;

            // 将 Y 坐标归一化到 5px 的网格中,防止同一行中英文高度差异被误判为换行
            const y = Math.round(rect.top / 5) * 5;

            if (lastY === -1 || Math.abs(y - lastY) > 5) {
                if (currentLine.length > 0) lines.push(currentLine);
                currentLine = [span];
                lastY = y;
            } else {
                currentLine.push(span);
            }
        });
        if (currentLine.length > 0) lines.push(currentLine);

        // --- 步骤 C: 渲染渐变色 ---
        lines.forEach((lineSpans, lineIndex) => {
            const isOdd = lineIndex % 2 !== 0;
            const lineLength = lineSpans.length;

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

                let rgb;
                if (progress < 0.45) rgb = interpolateColor(theme.c1, theme.mid, progress / 0.45);
                else if (progress > 0.55) rgb = interpolateColor(theme.mid, theme.c2, (progress - 0.55) / 0.45);
                else rgb = theme.mid;

                const colorStr = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
                span.style.color = colorStr;

                if (span.closest('a')) {
                    span.style.textDecoration = 'underline';
                    span.style.textDecorationColor = colorStr;
                }
            });
        });
    }

    // ==========================================
    // 5. 扫描调度系统 (低耗能心跳)
    // ==========================================
    function scanAndProcess() {
        if (!isEnabled) return;
        // 只寻找白名单内的容器
        const blocks = document.querySelectorAll(TARGET_SELECTORS);
        blocks.forEach(block => {
            // 如果还未处理,则执行切割+上色
            if (block.dataset.beelineProcessed !== "true") {
                processBlock(block);
            }
        });
    }

    let needsUpdate = true;
    const observer = new MutationObserver(() => needsUpdate = true);
    observer.observe(document.body, { childList: true, characterData: true, subtree: true });

    setInterval(() => {
        if (needsUpdate && isEnabled) {
            scanAndProcess();
            needsUpdate = false;
        }
    }, 400);

    // ==========================================
    // 6. 窗口重绘与快捷键
    // ==========================================
    let resizeTimer;
    window.addEventListener('resize', () => {
        if (!isEnabled) return;
        clearTimeout(resizeTimer);
        resizeTimer = setTimeout(() => {
            // 窗口改变时,只重新测算坐标和上色,不需要重新切割 DOM,性能极高
            document.querySelectorAll(TARGET_SELECTORS).forEach(block => {
                if (block.dataset.beelineProcessed === "true") colorizeBlock(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) {
                document.querySelectorAll('.beeline-word').forEach(span => {
                    span.style.color = '';
                    if (span.closest('a')) span.style.textDecoration = '';
                });
            } else {
                needsUpdate = true;
            }
        }
    });

})();