网页文字渐变色辅助阅读。Ctrl+Shift+B 切换。
// ==UserScript==
// @name ChromaFlow
// @namespace http://tampermonkey.net/
// @version 16.0
// @description 网页文字渐变色辅助阅读。Ctrl+Shift+B 切换。
// @description:en Reading focus with color gradients (Ctrl+Shift+B).
// @author Lain1984
// @license MIT
// @match *://*/*
// @grant GM_addStyle
// ==/UserScript==
// ==/UserScript==
(function() {
'use strict';
if (typeof CSS === 'undefined' || !CSS.highlights) {
console.warn('ChromaFlow: 您的浏览器不支持 CSS Custom Highlight API,脚本无法运行。');
return;
}
// ==========================================
// 模块 1:全局配置
// ==========================================
const Config = {
enabled: true,
debug: false,
bucketCount: 20,
tolerance: 16,
wordRegex: /(?:\p{Emoji_Presentation}|\p{Extended_Pictographic})|\p{Script=Han}|[a-zA-Z0-9_’'.-]+|[^\s\p{Script=Han}a-zA-Z0-9_’'.-]+/gu,
selectors: {
targets: 'p, li, blockquote, dd, dt, h1, h2, h3, h4, h5, h6, ms-cmark-node, .text-base, .markdown-body, .prose, [data-testid="tweetText"]',
ignores: 'code, pre, kbd, button, input, textarea, select, [contenteditable="true"], .inline-code, .immersive-translate-loading-spinner',
shadowHosts: ''
},
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] }
},
initAdapters() {
const currentHost = window.location.hostname;
const adapters = [
{
name: "MSN & Bing News",
match: /msn\.com|bing\.com/i,
targets: 'p, h2, h3, h4, h5, h6, blockquote, li',
ignores: 'views-native-ad, fluent-button, .ad-slot-placeholder, .article-cont-read-container, .continue-reading-slot',
tolerance: 15,
shadowHosts: 'cp-article' // 关键:MSN 使用 Web Components
}
];
for (let adapter of adapters) {
if (adapter.match.test(currentHost)) {
if (adapter.targets) this.selectors.targets = adapter.targets;
if (adapter.ignores) this.selectors.ignores += `, ${adapter.ignores}`;
if (adapter.tolerance) this.tolerance = adapter.tolerance;
if (adapter.shadowHosts) this.selectors.shadowHosts = adapter.shadowHosts;
break;
}
}
}
};
// ==========================================
// 模块 2:颜色计算与 CSS 注入引擎 (核心突破:Shadow 穿透)
// ==========================================
class ColorEngine {
constructor() {
this.highlightsMap = {};
this.baseCssStr = '';
this.initPalettes();
}
interpolate(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]))
];
}
getGradientRGB(theme, progress) {
let rgb = progress < 0.5
? this.interpolate(theme.c1, theme.mid, progress / 0.5)
: this.interpolate(theme.mid, theme.c2, (progress - 0.5) / 0.5);
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
}
initPalettes() {
for (let i = 0; i <= Config.bucketCount; i++) {
const progress = i / Config.bucketCount;
['light', 'dark'].forEach(theme => {
const bucketName = `cf-${theme}-${i}`;
this.highlightsMap[bucketName] = new Highlight();
CSS.highlights.set(bucketName, this.highlightsMap[bucketName]);
this.baseCssStr += `::highlight(${bucketName}) { color: ${this.getGradientRGB(Config.themes[theme], progress)} !important; }\n`;
});
}
// 主文档注入
this.injectCSS(document);
}
// 动态将样式注入到目标作用域 (突破 Web Components 样式隔离)
injectCSS(root) {
const id = 'chromaflow-styles';
if (root.getElementById && root.getElementById(id)) return;
if (root.querySelector && root.querySelector(`#${id}`)) return;
const style = document.createElement('style');
style.id = id;
style.textContent = this.baseCssStr;
if (root === document) {
if (typeof GM_addStyle !== 'undefined') {
GM_addStyle(this.baseCssStr); // 兼容某些油猴特性
} else {
document.head.appendChild(style);
}
} else {
root.appendChild(style); // 注入到 ShadowRoot
}
}
clearAll() {
Object.values(this.highlightsMap).forEach(hl => hl.clear());
}
isLightText(colorStr) {
if (!colorStr) return false;
const rgbMatch = colorStr.match(/\d+/g);
if (!rgbMatch || rgbMatch.length < 3) return false;
const [r, g, b] = rgbMatch.map(Number);
return (((r * 299) + (g * 587) + (b * 114)) / 1000) >= 128;
}
assignRangeToBucket(range, bucketName, oldBucketName) {
if (oldBucketName === bucketName) return;
if (oldBucketName && this.highlightsMap[oldBucketName]) {
this.highlightsMap[oldBucketName].delete(range);
}
if (bucketName && this.highlightsMap[bucketName]) {
this.highlightsMap[bucketName].add(range);
}
}
}
// ==========================================
// 模块 3:核心文本解析与聚类 (健壮节点穿越)
// ==========================================
class TextProcessor {
constructor(colorEngine) {
this.colorEngine = colorEngine;
this.nodeRangesMap = new WeakMap();
this.processedBlocks = new WeakMap();
this.blockDirectionState = new WeakMap();
}
clearState(block) {
this.processedBlocks.set(block, false);
}
cleanupNode(node) {
const entries = this.nodeRangesMap.get(node);
if (entries) {
entries.forEach(entry => this.colorEngine.assignRangeToBucket(entry.range, null, entry.bucket));
this.nodeRangesMap.delete(node);
}
}
// 安全地向上跨越层级和影子DOM查找前驱状态
getPreviousDirectionState(block) {
let current = block;
let depth = 0;
while (current && depth < 12) {
let sibling = current.previousElementSibling;
while (sibling) {
if (sibling.querySelectorAll) {
const targets = sibling.querySelectorAll(Config.selectors.targets);
if (targets.length > 0) {
for (let i = targets.length - 1; i >= 0; i--) {
if (this.blockDirectionState.has(targets[i])) {
return this.blockDirectionState.get(targets[i]);
}
}
}
}
if (sibling.matches && sibling.matches(Config.selectors.targets) && this.blockDirectionState.has(sibling)) {
return this.blockDirectionState.get(sibling);
}
sibling = sibling.previousElementSibling;
}
// 没找到兄弟?往上爬。如果碰到了 ShadowRoot 边界,通过 host 跨越到外层 Light DOM 继续找
if (current.parentElement) {
current = current.parentElement;
} else if (current.getRootNode && current.getRootNode() instanceof ShadowRoot) {
current = current.getRootNode().host;
} else {
break;
}
depth++;
}
return 0;
}
processBlock(block, isResize = false) {
if (!Config.enabled || block.closest(Config.selectors.ignores)) return;
if (!block.isConnected) return; // 防御断开的 DOM
if (block.offsetWidth === 0 && block.offsetHeight === 0) {
if (window.getComputedStyle(block).display !== 'contents') return;
}
const originalColor = window.getComputedStyle(block).color;
const themePrefix = this.colorEngine.isLightText(originalColor) ? 'cf-dark-' : 'cf-light-';
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, {
acceptNode: node => {
if (!node.nodeValue.trim()) return NodeFilter.FILTER_SKIP;
if (node.parentNode && node.parentNode.closest(Config.selectors.ignores)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
const textNodes = [];
let currentNode;
while (currentNode = walker.nextNode()) textNodes.push(currentNode);
if (textNodes.length === 0) {
this.processedBlocks.set(block, true);
return;
}
let allWords = [];
textNodes.forEach(node => {
let wordEntries = this.nodeRangesMap.get(node);
if (!wordEntries || isResize) {
this.cleanupNode(node);
wordEntries = [];
const text = node.nodeValue;
Config.wordRegex.lastIndex = 0;
let match;
// 安全判断链接 (由于使用TreeWalker,parentNode必为Element)
const isLink = !!(node.parentNode.closest('a'));
while ((match = Config.wordRegex.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, isLink });
} catch(e) {}
}
this.nodeRangesMap.set(node, wordEntries);
}
wordEntries.forEach(entry => {
if(!entry.range.startContainer.isConnected) return;
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) {
this.processedBlocks.set(block, true);
return;
}
let directionToggle = this.getPreviousDirectionState(block);
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) => {
const lineLength = lineWords.length;
const isOdd = directionToggle % 2 !== 0;
lineWords.forEach((wordObj, wordIndex) => {
if (wordObj.entry.isLink) {
this.colorEngine.assignRangeToBucket(wordObj.entry.range, null, wordObj.entry.bucket);
wordObj.entry.bucket = null;
return;
}
let progress = lineLength > 1 ? wordIndex / (lineLength - 1) : 0;
if (isOdd) progress = 1 - progress;
const bucketIndex = Math.min(Config.bucketCount, Math.max(0, Math.round(progress * Config.bucketCount)));
const newBucket = `${themePrefix}${bucketIndex}`;
this.colorEngine.assignRangeToBucket(wordObj.entry.range, newBucket, wordObj.entry.bucket);
wordObj.entry.bucket = newBucket;
});
if (lineLength > 1) directionToggle++;
});
this.blockDirectionState.set(block, directionToggle);
this.processedBlocks.set(block, true);
}
}
// ==========================================
// 模块 4:生命周期与事件调度 (安全隔离探测)
// ==========================================
class ObserverManager {
constructor(processor, colorEngine) {
this.processor = processor;
this.colorEngine = colorEngine; // 引入 Engine 用于 CSS 注入
this.pendingBlocks = new Set();
this.processTimer = null;
this.observedShadowHosts = new WeakSet();
this.blockDisplayCache = new WeakMap();
this.initViewportObserver();
this.initMutationObserver();
this.initResizeObserver();
this.initFallbackScanner();
}
isTrueBlockLevel(element) {
const tag = element.tagName.toLowerCase();
if (['p', 'li', 'blockquote', 'dd', 'dt', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div'].includes(tag)) {
return true;
}
if (this.blockDisplayCache.has(element)) return this.blockDisplayCache.get(element);
if (!element.isConnected) return false;
const display = window.getComputedStyle(element).display;
const isBlock = !['inline', 'inline-block', 'contents', 'none'].includes(display);
this.blockDisplayCache.set(element, isBlock);
return isBlock;
}
// 安全获取逻辑块:过滤 ShadowRoot 导致的 TypeError
getEffectiveBlock(node) {
let current = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;
let fallback = null;
// 必须验证 Node.ELEMENT_NODE (节点类型 1),防止遇到 DocumentFragment(11) 和 Document(9) 崩溃
while (current && current.nodeType === Node.ELEMENT_NODE) {
if (current.tagName === 'BODY' || current.tagName === 'HTML') break;
// 此时 current.matches 绝对安全
if (current.matches(Config.selectors.targets) && !current.matches('article, main')) {
if (this.isTrueBlockLevel(current)) {
return current;
} else if (!fallback) {
fallback = current;
}
}
current = current.parentNode;
}
return fallback;
}
queueBlock(block) {
if (!Config.enabled) return;
this.pendingBlocks.add(block);
if (!this.processTimer) {
this.processTimer = setTimeout(() => {
requestAnimationFrame(() => {
this.pendingBlocks.forEach(b => {
if (b.isConnected) this.processor.processBlock(b, false);
});
this.pendingBlocks.clear();
this.processTimer = null;
});
}, 150);
}
clearTimeout(block._cfRefetchTimer);
block._cfRefetchTimer = setTimeout(() => {
if (block.isConnected && Config.enabled) {
this.processor.clearState(block);
this.processor.processBlock(block, true);
}
}, 2500);
}
initViewportObserver() {
this.viewportObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && Config.enabled) {
const block = entry.target;
if (!this.processor.processedBlocks.get(block)) this.queueBlock(block);
}
});
}, { rootMargin: '400px' });
}
observeNode(node) {
if (!this.processor.processedBlocks.has(node)) {
this.processor.clearState(node);
this.viewportObserver.observe(node);
}
}
observeShadowRoot(host) {
if (this.observedShadowHosts.has(host) || !host.shadowRoot) return;
this.observedShadowHosts.add(host);
// 【核心突破】向 ShadowRoot 内注入高亮 CSS!
this.colorEngine.injectCSS(host.shadowRoot);
const shadowObserver = new MutationObserver(mutations => this.handleMutations(mutations));
shadowObserver.observe(host.shadowRoot, { childList: true, characterData: true, subtree: true });
}
handleMutations(mutations) {
if (!Config.enabled) return;
const blocksToProcess = new Set();
mutations.forEach(m => {
let target = m.target;
const block = this.getEffectiveBlock(target);
if (block) {
this.processor.clearState(block);
blocksToProcess.add(block);
this.observeNode(block);
}
m.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE && Config.selectors.shadowHosts && node.matches(Config.selectors.shadowHosts)) {
this.observeShadowRoot(node);
}
});
});
blocksToProcess.forEach(block => this.queueBlock(block));
}
initMutationObserver() {
this.mutationObserver = new MutationObserver(m => this.handleMutations(m));
this.mutationObserver.observe(document.body, { childList: true, characterData: true, subtree: true });
}
initResizeObserver() {
let resizeTimer;
window.addEventListener('resize', () => {
if (!Config.enabled) return;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
this.scanAndObserve(true);
}, 300);
});
}
scanAndObserve(forceResize = false) {
if (!Config.enabled) return;
document.querySelectorAll(Config.selectors.targets).forEach(node => {
const block = this.getEffectiveBlock(node);
if (block) {
if (forceResize) this.processor.clearState(block);
this.observeNode(block);
if (forceResize && this.isElementInViewport(block)) this.processor.processBlock(block, true);
}
});
if (Config.selectors.shadowHosts) {
document.querySelectorAll(Config.selectors.shadowHosts).forEach(host => {
this.observeShadowRoot(host); // 注入 CSS 并监听
if (host.shadowRoot) {
host.shadowRoot.querySelectorAll(Config.selectors.targets).forEach(node => {
const block = this.getEffectiveBlock(node);
if (block) {
if (forceResize) this.processor.clearState(block);
this.observeNode(block);
if (forceResize && this.isElementInViewport(block)) this.processor.processBlock(block, true);
}
});
}
});
}
}
isElementInViewport(el) {
const rect = el.getBoundingClientRect();
return (rect.top <= (window.innerHeight + 400) && rect.bottom >= -400);
}
initFallbackScanner() {
let scanIntervalTime = 2000;
const scheduleNextScan = () => {
setTimeout(() => {
if (Config.enabled) this.scanAndObserve();
scanIntervalTime = Math.min(scanIntervalTime + 2000, 10000);
scheduleNextScan();
}, scanIntervalTime);
};
scheduleNextScan();
}
}
// ==========================================
// 模块 5:应用入口
// ==========================================
class ChromaFlowApp {
constructor() {
Config.initAdapters(); // 提取主机信息并加载适配器
this.colorEngine = new ColorEngine();
this.processor = new TextProcessor(this.colorEngine);
this.observer = new ObserverManager(this.processor, this.colorEngine);
this.initHotkeys();
}
initHotkeys() {
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'b' || e.key === 'B')) {
e.preventDefault();
Config.enabled = !Config.enabled;
if (!Config.enabled) {
this.colorEngine.clearAll();
const clearAllStates = (root) => {
root.querySelectorAll(Config.selectors.targets).forEach(b => this.processor.clearState(b));
};
clearAllStates(document);
if (Config.selectors.shadowHosts) {
document.querySelectorAll(Config.selectors.shadowHosts).forEach(host => {
if (host.shadowRoot) clearAllStates(host.shadowRoot);
});
}
} else {
this.observer.scanAndObserve();
}
}
});
}
}
new ChromaFlowApp();
})();