Greasy Fork is available in English.
网页文字渐变色辅助阅读 (Ctrl+Shift+B 开关):读长文对话不串行。
当前为
// ==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;
}
}
});
})();