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