Greasy Fork is available in English.
网页文字渐变色辅助阅读 (Ctrl+Shift+B 开关):读长文对话不串行。
当前为
// ==UserScript==
// @name ChromaFlow
// @namespace http://tampermonkey.net/
// @version 8.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() {
const bodyBg = window.getComputedStyle(document.body).backgroundColor;
if (bodyBg !== 'rgba(0, 0, 0, 0)' && bodyBg !== 'transparent') return bodyBg;
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. 白名单与黑名单 (防止破坏 UI)
// ==========================================
// 【核心新增】:加入了 [data-testid="tweetText"] 以完美支持 X (Twitter) 的推文正文
const TARGET_SELECTORS = 'p, li, blockquote, dd, dt, ms-cmark-node, .text-base, .markdown, .markdown-body, .prose, [data-testid="tweetText"]';
// 黑名单保持不变,严格保护代码块和输入框
const IGNORE_SELECTORS = 'code, pre, kbd, button, input, textarea, select, [contenteditable="true"], .inline-code';
// ==========================================
// 4. 安全拆词引擎 (DOM 操作)
// ==========================================
function wrapTextNodesSafely() {
const blocks = document.querySelectorAll(TARGET_SELECTORS);
blocks.forEach(block => {
if (block.closest(IGNORE_SELECTORS)) return;
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_REJECT;
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 fragment = document.createDocumentFragment();
const tokens = tokenizeText(node.nodeValue);
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);
}
});
node.parentNode.replaceChild(fragment, node);
});
});
}
// ==========================================
// 5. 全局流体上色引擎 (支持无限滚动)
// ==========================================
function applyGlobalColors() {
const spans = Array.from(document.querySelectorAll('.beeline-word'));
if (spans.length === 0) return;
const isDark = isDarkTheme(getRealBackgroundColor());
const theme = isDark ? THEMES.dark : THEMES.light;
let lines = [];
let currentLine = [];
let lastY = -1;
spans.forEach(span => {
const rect = span.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
const absoluteY = Math.round((rect.top + window.scrollY) / 8) * 8;
if (lastY === -1 || Math.abs(absoluteY - lastY) > 8) {
if (currentLine.length > 0) lines.push(currentLine);
currentLine = [span];
lastY = absoluteY;
} else {
currentLine.push(span);
}
});
if (currentLine.length > 0) lines.push(currentLine);
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;
// 保留 X/Twitter 话题和链接的原生下划线体验
if (span.closest('a')) {
span.style.textDecoration = 'underline';
span.style.textDecorationColor = colorStr;
}
});
});
}
// ==========================================
// 6. 智能防抖扫描系统
// ==========================================
let updateTimeout = null;
function triggerUpdate() {
if (!isEnabled) return;
// 缩短防抖时间到 600ms,让 X 在无限往下滚动刷推文时颜色能更快跟上
clearTimeout(updateTimeout);
updateTimeout = setTimeout(() => {
wrapTextNodesSafely();
applyGlobalColors();
}, 600);
}
const observer = new MutationObserver((mutations) => {
let hasValidMutation = false;
for (let m of mutations) {
if (m.target.nodeType === 1 && m.target.classList.contains('beeline-word')) continue;
hasValidMutation = true;
break;
}
if (hasValidMutation) triggerUpdate();
});
observer.observe(document.body, { childList: true, characterData: true, subtree: true });
setTimeout(triggerUpdate, 500);
// ==========================================
// 7. 窗口重绘与快捷键
// ==========================================
let resizeTimer;
window.addEventListener('resize', () => {
if (!isEnabled) return;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(applyGlobalColors, 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 {
triggerUpdate();
}
}
});
})();