Greasy Fork is available in English.
网页文字渐变色辅助阅读:重构排版与着色解耦架构,完美穿透Shadow DOM(支持MSN等),优化Resize性能。Ctrl+Shift+B 切换。
当前为
// ==UserScript==
// @name ChromaFlow
// @namespace http://tampermonkey.net/
// @version 11.0
// @description 网页文字渐变色辅助阅读:重构排版与着色解耦架构,完美穿透Shadow DOM(支持MSN等),优化Resize性能。Ctrl+Shift+B 切换。
// @description:en Reading focus with color gradients (Ctrl+Shift+B). Deeply supports Shadow DOM & optimized resize.
// @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;
// ==========================================
// 0. 全局默认配置与站点适配区
// ==========================================
let config = {
targets: 'p, li, blockquote, dd, dt, ms-cmark-node, .text-base, .markdown, .markdown-body, .prose, article, [data-testid="tweetText"]',
ignores: 'code, pre, kbd, button, input, textarea, select, [contenteditable="true"], .inline-code',
tolerance: 12,
debug: false,
shadowHosts: '' // 【新增】声明需要穿透的 Shadow DOM 宿主选择器
};
const SITE_ADAPTERS = [
{
name: "MSN & Bing News",
match: /msn\.com|bing\.com/i,
// 【修正】移除 cp-article,因为它是 Shadow Host,真正的文本在它内部的 p 标签里
targets: 'p, li, blockquote, dd, dt',
ignores: 'code, pre, kbd, button, input, textarea, select, [contenteditable="true"], .inline-code, views-native-ad, cp-article-image, .ad-slot-placeholder, .article-cont-read-container, .continue-reading-slot, fluent-button, .article-image-slot, .intra-article-module',
tolerance: 15,
// 【新增】指定要穿透的 Shadow 宿主
shadowHosts: 'cp-article'
}
];
const currentHost = window.location.hostname;
for (let adapter of SITE_ADAPTERS) {
if (adapter.match.test(currentHost)) {
if (adapter.targets) config.targets = adapter.targets; // MSN使用专属targets覆盖默认
if (adapter.ignores) config.ignores += `, ${adapter.ignores}`;
if (adapter.tolerance) config.tolerance = adapter.tolerance;
if (adapter.shadowHosts) config.shadowHosts = adapter.shadowHosts;
if (config.debug) console.log(`ChromaFlow: 已成功加载 [${adapter.name}] 专属适配配置。`);
break;
}
}
// ==========================================
// 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] }
};
const nodeRangesMap = new WeakMap();
const processedBlocks = new WeakMap();
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);
return (((r * 299) + (g * 587) + (b * 114)) / 1000) >= 128;
}
function cleanupNodeRanges(node) {
const entries = nodeRangesMap.get(node);
if (entries) {
entries.forEach(entry => {
if (entry.bucket && highlightsMap[entry.bucket]) {
highlightsMap[entry.bucket].delete(entry.range);
}
});
nodeRangesMap.delete(node);
}
}
// ==========================================
// 3. 物理行高聚类引擎
// ==========================================
function processBlock(block, isResize = false) {
if (!isEnabled) return;
if (block.closest(config.ignores)) return;
if (block.offsetWidth === 0 && block.offsetHeight === 0) {
const blockStyle = window.getComputedStyle(block);
if (blockStyle.display !== 'contents') return;
}
if (block.querySelector('.immersive-translate-loading-spinner')) return;
const originalColor = window.getComputedStyle(block).color;
const themePrefix = isTextColorLight(originalColor) ? '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(config.ignores)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
const textNodes = [];
let currentNode;
while (currentNode = walker.nextNode()) textNodes.push(currentNode);
if (textNodes.length === 0) {
processedBlocks.set(block, true);
return;
}
let allWords = [];
textNodes.forEach(node => {
let wordEntries = nodeRangesMap.get(node);
if (!wordEntries || !isResize) {
if (wordEntries) cleanupNodeRanges(node);
wordEntries = [];
const text = node.nodeValue;
const regex = /(?:\p{Emoji_Presentation}|\p{Extended_Pictographic})|[\u4E00-\u9FFF]|[a-zA-Z0-9_’'.-]+|[^\s\u4E00-\u9FFF]+/gu;
let match;
while ((match = regex.exec(text)) !== null) {
try {
const range = new Range();
range.setStart(node, match.index);
range.setEnd(node, match.index + match[0].length);
wordEntries.push({ range, bucket: null });
} catch(e) {
if (config.debug) console.warn('ChromaFlow Range 生成异常:', e, match[0]);
}
}
nodeRangesMap.set(node, wordEntries);
}
wordEntries.forEach(entry => {
const rects = entry.range.getClientRects();
if (rects.length === 0) return;
const rect = rects[0];
if (rect.width === 0 || rect.height === 0) return;
allWords.push({
entry: entry,
x: rect.left,
y: rect.top + rect.height / 2
});
});
});
if (allWords.length === 0) {
processedBlocks.set(block, true);
return;
}
allWords.sort((a, b) => a.y - b.y || a.x - b.x);
let lines = [];
let currentLine = [allWords[0]];
let currentLineY = allWords[0].y;
for (let i = 1; i < allWords.length; i++) {
let word = allWords[i];
if (Math.abs(word.y - currentLineY) < config.tolerance) {
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;
const lineLength = lineWords.length;
lineWords.forEach((wordObj, wordIndex) => {
let progress = lineLength > 1 ? wordIndex / (lineLength - 1) : 0.5;
if (isOdd) progress = 1 - progress;
const newBucket = `${themePrefix}${Math.min(20, Math.max(0, Math.round(progress * 20)))}`;
if (wordObj.entry.bucket !== newBucket) {
if (wordObj.entry.bucket && highlightsMap[wordObj.entry.bucket]) {
highlightsMap[wordObj.entry.bucket].delete(wordObj.entry.range);
}
highlightsMap[newBucket].add(wordObj.entry.range);
wordObj.entry.bucket = newBucket;
}
});
});
processedBlocks.set(block, true);
}
// ==========================================
// 4. 异步调度与 Shadow DOM 穿透监听
// ==========================================
const pendingBlocks = new Set();
let processTimer = null;
function queueBlock(block) {
if (!isEnabled) return;
pendingBlocks.add(block);
if (processTimer) return;
processTimer = setTimeout(() => {
pendingBlocks.forEach(b => {
if (b.isConnected) processBlock(b, false);
});
pendingBlocks.clear();
processTimer = null;
}, 150);
}
const viewportObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && isEnabled) {
const block = entry.target;
if (!processedBlocks.get(block)) {
queueBlock(block);
}
}
});
}, { rootMargin: '400px' });
// 【新增】Shadow DOM MutationObserver 管理器
const observedShadowHosts = new WeakSet();
function observeShadowRoot(host) {
if (observedShadowHosts.has(host) || !host.shadowRoot) return;
observedShadowHosts.add(host);
const shadowObserver = new MutationObserver((mutations) => {
if (!isEnabled) return;
const blocksToProcess = new Set();
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(config.targets);
if (block) {
processedBlocks.set(block, false);
blocksToProcess.add(block);
}
});
blocksToProcess.forEach(block => queueBlock(block));
});
shadowObserver.observe(host.shadowRoot, { childList: true, characterData: true, subtree: true });
}
function scanAndObserve() {
if (!isEnabled) return;
// 扫描常规文档流
document.querySelectorAll(config.targets).forEach(block => {
if (!processedBlocks.has(block)) {
processedBlocks.set(block, false);
viewportObserver.observe(block);
}
});
// 扫描并穿透 Shadow DOM
if (config.shadowHosts) {
document.querySelectorAll(config.shadowHosts).forEach(host => {
if (host.shadowRoot) {
observeShadowRoot(host);
host.shadowRoot.querySelectorAll(config.targets).forEach(block => {
if (!processedBlocks.has(block)) {
processedBlocks.set(block, false);
viewportObserver.observe(block);
}
});
}
});
}
}
// 主 MutationObserver,增加对动态挂载的 Shadow Host 的捕获
const mutationObserver = new MutationObserver((mutations) => {
if (!isEnabled) return;
const blocksToProcess = new Set();
mutations.forEach(m => {
// 拦截新增的 Shadow Host 节点
m.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (config.shadowHosts && node.matches && node.matches(config.shadowHosts)) {
observeShadowRoot(node);
if (node.shadowRoot) {
node.shadowRoot.querySelectorAll(config.targets).forEach(b => {
if (!processedBlocks.has(b)) {
processedBlocks.set(b, false);
viewportObserver.observe(b);
blocksToProcess.add(b);
}
});
}
}
// 处理宿主内部嵌套的情况
if (config.shadowHosts && node.querySelectorAll) {
node.querySelectorAll(config.shadowHosts).forEach(host => {
observeShadowRoot(host);
if (host.shadowRoot) {
host.shadowRoot.querySelectorAll(config.targets).forEach(b => {
if (!processedBlocks.has(b)) {
processedBlocks.set(b, false);
viewportObserver.observe(b);
blocksToProcess.add(b);
}
});
}
});
}
}
});
// 处理常规 DOM 变更
let target = m.target;
if (target.nodeType === Node.TEXT_NODE) target = target.parentNode;
if (!target || !target.closest) return;
const block = target.closest(config.targets);
if (block) {
processedBlocks.set(block, false);
blocksToProcess.add(block);
}
});
blocksToProcess.forEach(block => queueBlock(block));
});
mutationObserver.observe(document.body, { childList: true, characterData: true, subtree: true });
let scanIntervalTime = 2000;
let scanTimerId = null;
function scheduleNextScan() {
if (scanTimerId) clearTimeout(scanTimerId);
scanTimerId = setTimeout(() => {
if (isEnabled) scanAndObserve();
scanIntervalTime = Math.min(scanIntervalTime + 1000, 10000);
scheduleNextScan();
}, scanIntervalTime);
}
scheduleNextScan();
// ==========================================
// 5. 快捷键与 Resize 优化
// ==========================================
// 抽取视口内 Block 获取逻辑,兼容 Shadow DOM
function getActiveBlocksInViewport(viewportHeight, margin = 400) {
const blocks = [];
document.querySelectorAll(config.targets).forEach(block => {
const rect = block.getBoundingClientRect();
if (rect.top < viewportHeight + margin && rect.bottom > -margin) blocks.push(block);
});
if (config.shadowHosts) {
document.querySelectorAll(config.shadowHosts).forEach(host => {
if (host.shadowRoot) {
host.shadowRoot.querySelectorAll(config.targets).forEach(block => {
const rect = block.getBoundingClientRect();
if (rect.top < viewportHeight + margin && rect.bottom > -margin) blocks.push(block);
});
}
});
}
return blocks;
}
let resizeTimer;
window.addEventListener('resize', () => {
if (!isEnabled) return;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const blocks = getActiveBlocksInViewport(window.innerHeight, 400);
blocks.forEach(block => processBlock(block, true));
}, 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());
// 清理状态时,也需要顾及 Shadow DOM 内部
const clearState = (block) => processedBlocks.set(block, false);
document.querySelectorAll(config.targets).forEach(clearState);
if (config.shadowHosts) {
document.querySelectorAll(config.shadowHosts).forEach(host => {
if(host.shadowRoot) host.shadowRoot.querySelectorAll(config.targets).forEach(clearState);
});
}
pendingBlocks.clear();
if (processTimer) {
clearTimeout(processTimer);
processTimer = null;
}
} else {
scanAndObserve();
const blocks = getActiveBlocksInViewport(window.innerHeight, 0);
blocks.forEach(block => queueBlock(block));
}
}
});
})();