// ==UserScript==
// @name 牢高亮
// @namespace http://tampermonkey.net/
// @version 2.8
// @description 高亮
// @author Hanabi
// @match *://*/*
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
// ===================== 全局常量与变量 =====================
let tooltip = null;
let domChangeTimer = null;
let isHighlightEnabled = true;
let configPanel = null;
let configMask = null;
let HIGHLIGHT_GROUPS = [];
let mutationObserver = null;
// ===================== 样式定义 =====================
GM_addStyle(`
/* 高亮样式(仅添加背景色,不修改元素布局) */
.custom-highlight {
background: var(--highlight-bg-color) !important;
color: var(--highlight-text-color) !important;
padding: 0 1px !important;
border-radius: 2px !important;
cursor: help !important;
box-sizing: content-box !important;
display: inline !important;
}
/* 气泡备注样式 - 新增skip-highlight类,排除高亮处理 */
.highlight-tooltip.skip-highlight {
position: fixed;
z-index: 999999;
padding: 8px 12px;
background: #333;
color: #fff;
font-size: 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
max-width: 280px;
word-wrap: break-word;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
.highlight-tooltip.skip-highlight::before {
content: '';
position: absolute;
bottom: -6px;
left: 10px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid #333;
}
/* 禁用状态样式 */
body.highlight-disabled .custom-highlight {
background: transparent !important;
color: inherit !important;
cursor: default !important;
}
body.highlight-disabled .highlight-tooltip {
display: none !important;
}
/* 配置面板样式 */
.highlight-config-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 99999999;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
padding: 20px;
width: 80%;
max-width: 700px;
max-height: 80vh;
overflow-y: auto;
display: none;
}
.highlight-config-panel.show {
display: block;
}
.config-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.config-panel-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.config-header-actions {
display: flex;
gap: 8px;
}
.import-export-btn {
padding: 4px 8px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
background: #607d8b;
color: #fff;
}
.import-export-btn:hover {
background: #506978;
}
.config-close-btn {
background: #f5f5f5;
border: none;
border-radius: 4px;
width: 28px;
height: 28px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.config-close-btn:hover {
background: #e0e0e0;
}
.group-item {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 16px;
margin-bottom: 16px;
background: #fafafa;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.group-info {
flex: 1;
}
.group-name-input {
width: 200px;
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
margin-bottom: 4px;
}
.group-remark-input {
width: 300px;
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.color-group {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.group-bgcolor-input {
width: 60px;
height: 28px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.group-textcolor-input {
width: 60px;
height: 28px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.group-actions {
display: flex;
gap: 4px;
}
.group-delete-btn {
padding: 4px 8px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
background: #f44336;
color: #fff;
}
.group-delete-btn:hover {
background: #d32f2f;
}
.config-item {
margin-bottom: 16px;
}
.config-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #333;
font-weight: 500;
}
.highlight-textarea {
width: 100%;
height: 100px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
resize: vertical;
line-height: 1.5;
}
.config-tip {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.add-group-btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
background: #2196f3;
color: #fff;
margin: 8px 0;
}
.add-group-btn:hover {
background: #1976d2;
}
.config-save-btn {
display: block;
width: 100%;
padding: 8px 0;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
background: #4caf50;
color: #fff;
margin-top: 16px;
}
.config-save-btn:hover {
background: #43a047;
}
.config-mask {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 99999998;
display: none;
}
.config-mask.show {
display: block;
}
.empty-tip {
text-align: center;
color: #999;
padding: 40px 0;
font-size: 14px;
}
#import-config-input {
display: none;
}
/* 应急悬浮按钮样式(备用方案) */
.highlight-emergency-btn {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 99999999;
width: 50px;
height: 50px;
border-radius: 50%;
background: #2196f3;
color: white;
border: none;
font-size: 20px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
}
.highlight-emergency-btn:hover {
background: #1976d2;
}
`);
// ===================== 核心:读取本地配置 =====================
function loadLocalConfig() {
try {
const savedGroups = GM_getValue('highlightGroups', []);
const savedEnabled = GM_getValue('highlightEnabled', true);
// 兼容旧配置,补充字体颜色字段
HIGHLIGHT_GROUPS = Array.isArray(savedGroups) ? savedGroups.map(group => ({
...group,
textColor: group.textColor || "#000000" // 默认字体色为黑色
})) : [];
isHighlightEnabled = typeof savedEnabled === 'boolean' ? savedEnabled : true;
document.body.classList.toggle('highlight-disabled', !isHighlightEnabled);
console.log('✅ 配置读取成功', {
groups: HIGHLIGHT_GROUPS.length,
enabled: isHighlightEnabled
});
} catch (e) {
console.error('❌ 读取配置失败', e);
HIGHLIGHT_GROUPS = [];
isHighlightEnabled = true;
}
}
// ===================== 配置导入导出 =====================
function exportConfig() {
const exportData = {
version: '2.8',
createTime: new Date().toISOString(),
highlightEnabled: isHighlightEnabled,
highlightGroups: HIGHLIGHT_GROUPS
};
const jsonStr = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `高亮配置_${new Date().getTime()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
alert('配置导出成功!');
}
function importConfig(file) {
if (!file || (!file.type.includes('json') && !file.name.endsWith('.json'))) {
alert('请选择JSON格式的配置文件!');
return;
}
const reader = new FileReader();
reader.onload = function(e) {
try {
const importData = JSON.parse(e.target.result);
if (!importData.highlightGroups || !Array.isArray(importData.highlightGroups)) {
throw new Error('缺少高亮分组数据');
}
// 兼容导入的配置,补充字体颜色字段
HIGHLIGHT_GROUPS = importData.highlightGroups.map(group => ({
...group,
textColor: group.textColor || "#000000",
color: group.color || "#ff9800"
})).filter(group => {
if (!group.groupName || !Array.isArray(group.words)) return false;
return true;
});
if (typeof importData.highlightEnabled === 'boolean') {
isHighlightEnabled = importData.highlightEnabled;
GM_setValue('highlightEnabled', isHighlightEnabled);
document.body.classList.toggle('highlight-disabled', !isHighlightEnabled);
}
GM_setValue('highlightGroups', HIGHLIGHT_GROUPS);
renderConfigPanel();
clearAllHighlight();
initHighlight(true);
alert('配置导入成功!');
} catch (error) {
alert(`导入失败:${error.message}`);
console.error('导入错误:', error);
}
};
reader.readAsText(file);
}
function triggerImport() {
let importInput = document.getElementById('import-config-input');
if (!importInput) {
importInput = document.createElement('input');
importInput.id = 'import-config-input';
importInput.type = 'file';
importInput.accept = '.json';
document.body.appendChild(importInput);
importInput.addEventListener('change', function(e) {
if (this.files.length > 0) {
importConfig(this.files[0]);
this.value = '';
}
});
}
importInput.click();
}
// ===================== 配置面板 =====================
function initConfigPanelEvents() {
const closeBtn = configPanel.querySelector('.config-close-btn');
if (closeBtn) {
closeBtn.removeEventListener('click', hideConfigPanel);
closeBtn.addEventListener('click', hideConfigPanel);
}
const exportBtn = configPanel.querySelector('.export-btn');
if (exportBtn) {
exportBtn.removeEventListener('click', exportConfig);
exportBtn.addEventListener('click', exportConfig);
}
const importBtn = configPanel.querySelector('.import-btn');
if (importBtn) {
importBtn.removeEventListener('click', triggerImport);
importBtn.addEventListener('click', triggerImport);
}
configPanel.querySelectorAll('.add-group-btn').forEach(btn => {
btn.removeEventListener('click', addGroup);
btn.addEventListener('click', addGroup);
});
configPanel.querySelectorAll('.group-delete-btn').forEach((btn, index) => {
btn.removeEventListener('click', () => deleteGroup(index));
btn.addEventListener('click', () => deleteGroup(index));
});
const saveBtn = configPanel.querySelector('.config-save-btn');
if (saveBtn) {
saveBtn.removeEventListener('click', saveConfig);
saveBtn.addEventListener('click', saveConfig);
}
}
function createConfigPanel() {
if (configPanel && configMask) return;
// 创建遮罩层
configMask = document.createElement('div');
configMask.className = 'config-mask';
configMask.addEventListener('click', hideConfigPanel);
document.body.appendChild(configMask);
// 创建配置面板
configPanel = document.createElement('div');
configPanel.className = 'highlight-config-panel';
document.body.appendChild(configPanel);
renderConfigPanel();
}
function renderConfigPanel() {
if (HIGHLIGHT_GROUPS.length === 0) {
configPanel.innerHTML = `
暂无高亮分组,请点击下方按钮添加
`;
} else {
configPanel.innerHTML = `
${HIGHLIGHT_GROUPS.map((group, groupIndex) => `
提示:词汇之间用单个空格分隔,空行和多个连续空格会自动忽略
`).join('')}
`;
}
initConfigPanelEvents();
}
function showConfigPanel() {
createConfigPanel();
configMask.classList.add('show');
configPanel.classList.add('show');
}
function hideConfigPanel() {
if (configMask) configMask.classList.remove('show');
if (configPanel) configPanel.classList.remove('show');
}
function addGroup() {
HIGHLIGHT_GROUPS.push({
groupName: "新分组",
groupRemark: "",
color: "#ff9800", // 默认背景色
textColor: "#000000", // 默认字体色
words: []
});
renderConfigPanel();
}
function deleteGroup(index) {
if (HIGHLIGHT_GROUPS.length <= 1) {
alert('至少保留一个分组!');
return;
}
HIGHLIGHT_GROUPS.splice(index, 1);
renderConfigPanel();
}
function saveConfig() {
const newGroups = [];
configPanel.querySelectorAll('.group-item').forEach(item => {
const groupName = item.querySelector('.group-name-input').value.trim() || "未命名分组";
const groupRemark = item.querySelector('.group-remark-input').value.trim();
const groupBgColor = item.querySelector('.group-bgcolor-input').value;
const groupTextColor = item.querySelector('.group-textcolor-input').value;
const textarea = item.querySelector('.highlight-textarea');
// 处理词汇输入
const words = textarea.value
.replace(/\s+/g, ' ')
.trim()
.split(' ')
.filter(word => word !== '')
.filter((word, index, arr) => arr.indexOf(word) === index);
if (words.length > 0) {
newGroups.push({
groupName,
groupRemark,
color: groupBgColor,
textColor: groupTextColor,
words
});
}
});
// 保存配置
HIGHLIGHT_GROUPS = newGroups;
GM_setValue('highlightGroups', HIGHLIGHT_GROUPS);
// 清除旧高亮并重新生效
clearAllHighlight();
if (isHighlightEnabled) {
initHighlight(true);
}
hideConfigPanel();
alert(`配置保存成功!共设置 ${newGroups.length} 个分组`);
}
// ===================== 辅助函数 =====================
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function toggleHighlight() {
isHighlightEnabled = !isHighlightEnabled;
GM_setValue('highlightEnabled', isHighlightEnabled);
document.body.classList.toggle('highlight-disabled', !isHighlightEnabled);
if (isHighlightEnabled) {
initHighlight(true);
} else {
clearAllHighlight();
}
alert(`高亮功能已${isHighlightEnabled ? '开启' : '关闭'}!`);
}
// 核心:彻底清除高亮,完全恢复原有结构
function clearAllHighlight() {
// 1. 移除所有高亮span,恢复原始文本节点
document.querySelectorAll('.custom-highlight').forEach(span => {
const textNode = document.createTextNode(span.textContent);
span.parentNode.replaceChild(textNode, span);
});
// 2. 停止DOM监听
if (mutationObserver) {
mutationObserver.disconnect();
mutationObserver = null;
}
// 3. 移除滚动监听
window.removeEventListener('scroll', handleScroll);
}
// 创建气泡提示 - 新增skip-highlight类排除高亮
function createTooltip() {
if (tooltip) return tooltip;
tooltip = document.createElement('div');
tooltip.className = 'highlight-tooltip skip-highlight'; // 关键:添加skip-highlight类
document.body.appendChild(tooltip);
return tooltip;
}
// 气泡位置计算
function positionTooltip(target, tooltip) {
const rect = target.getBoundingClientRect();
const scrollX = window.scrollX || document.documentElement.scrollLeft;
const scrollY = window.scrollY || document.documentElement.scrollTop;
// 基础位置:高亮词右侧偏上
let left = rect.left + scrollX + 5;
let top = rect.top + scrollY - tooltip.offsetHeight - 5;
// 边界处理
if (top < scrollY + 10) top = rect.bottom + scrollY + 5;
if (left + tooltip.offsetWidth > scrollX + window.innerWidth - 10) {
left = rect.right + scrollX - tooltip.offsetWidth - 5;
}
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
}
// ===================== 核心:文本高亮逻辑(重构) =====================
function highlightTextInElement(element) {
// 跳过不需要处理的元素:新增skip-highlight类(气泡提示)、原有排除项
const skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'INPUT', 'TEXTAREA', 'SELECT'];
if (
skipTags.includes(element.tagName?.toUpperCase()) ||
element.classList.contains('custom-highlight') ||
element.classList.contains('skip-highlight') // 关键:排除气泡提示元素
) {
return;
}
// 遍历子节点
const childNodes = Array.from(element.childNodes);
childNodes.forEach(node => {
// 文本节点 - 处理高亮
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent.trim();
if (!text) return;
// 收集所有需要高亮的词汇
let allWords = [];
HIGHLIGHT_GROUPS.forEach(group => {
group.words.forEach(word => {
if (word && word.trim()) {
allWords.push({
word: word.trim(),
bgColor: group.color, // 背景色
textColor: group.textColor, // 字体色
remark: group.groupRemark
});
}
});
});
if (allWords.length === 0) return;
// 按词汇长度降序排序(避免短词匹配覆盖长词)
allWords.sort((a, b) => b.word.length - a.word.length);
// 构建正则表达式
const wordList = allWords.map(item => item.word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const regex = new RegExp(`(${wordList.join('|')})`, 'g');
// 没有匹配项则跳过
if (!regex.test(node.textContent)) return;
regex.lastIndex = 0;
// 创建文档片段
const frag = document.createDocumentFragment();
let lastIndex = 0;
let match;
// 匹配并替换
while ((match = regex.exec(node.textContent)) !== null) {
const matchedWord = match[0];
const wordConfig = allWords.find(item => item.word === matchedWord);
if (!wordConfig) continue;
// 添加匹配前的文本
if (match.index > lastIndex) {
frag.appendChild(document.createTextNode(node.textContent.slice(lastIndex, match.index)));
}
// 创建高亮span - 同时设置背景色和字体色
const span = document.createElement('span');
span.className = 'custom-highlight';
span.style.setProperty('--highlight-bg-color', wordConfig.bgColor); // 背景色
span.style.setProperty('--highlight-text-color', wordConfig.textColor); // 字体色
span.textContent = matchedWord;
span.setAttribute('data-remark', wordConfig.remark || '无备注');
// 气泡事件
span.addEventListener('mouseenter', (e) => {
if (!isHighlightEnabled) return;
const tip = createTooltip();
tip.textContent = span.getAttribute('data-remark');
positionTooltip(span, tip);
tip.style.opacity = '1';
e.stopPropagation();
});
span.addEventListener('mouseleave', () => {
if (tooltip) tooltip.style.opacity = '0';
});
frag.appendChild(span);
lastIndex = regex.lastIndex;
}
// 添加剩余文本
if (lastIndex < node.textContent.length) {
frag.appendChild(document.createTextNode(node.textContent.slice(lastIndex)));
}
// 替换原文本节点
if (frag.childNodes.length > 0) {
node.parentNode.replaceChild(frag, node);
}
}
// 元素节点 - 递归处理
else if (node.nodeType === Node.ELEMENT_NODE) {
highlightTextInElement(node);
}
});
}
// 高亮整个页面
function highlightAllText() {
if (!isHighlightEnabled || HIGHLIGHT_GROUPS.length === 0) return;
highlightTextInElement(document.body);
}
// 监听DOM变化
function observeDomChanges() {
if (mutationObserver) return;
mutationObserver = new MutationObserver((mutations) => {
if (!isHighlightEnabled || HIGHLIGHT_GROUPS.length === 0) return;
clearTimeout(domChangeTimer);
domChangeTimer = setTimeout(() => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE && !node.classList.contains('skip-highlight')) {
highlightTextInElement(node);
}
});
});
// 兜底处理
highlightAllText();
}, 50);
});
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
characterData: true
});
}
// 初始化高亮
function initHighlight() {
if (!isHighlightEnabled || HIGHLIGHT_GROUPS.length === 0) return;
// 先清除旧高亮
clearAllHighlight();
// 高亮所有文本
highlightAllText();
// 启动DOM监听
observeDomChanges();
// 滚动监听
window.removeEventListener('scroll', handleScroll);
window.addEventListener('scroll', handleScroll);
}
// 滚动处理
function handleScroll() {
clearTimeout(domChangeTimer);
domChangeTimer = setTimeout(() => {
highlightAllText();
}, 50);
}
// 创建应急悬浮按钮(防止菜单失效)
function createEmergencyButton() {
// 避免重复创建
if (document.querySelector('.highlight-emergency-btn')) return;
const btn = document.createElement('button');
btn.className = 'highlight-emergency-btn';
btn.innerHTML = '⚙️';
btn.title = '高亮配置';
// 点击显示配置面板
btn.addEventListener('click', (e) => {
e.stopPropagation();
// 显示菜单选项
const menu = document.createElement('div');
menu.style.position = 'fixed';
menu.style.bottom = '80px';
menu.style.right = '20px';
menu.style.zIndex = '99999999';
menu.style.background = 'white';
menu.style.borderRadius = '8px';
menu.style.boxShadow = '0 2px 10px rgba(0,0,0,0.2)';
menu.style.padding = '8px 0';
// 配置项
const configItem = document.createElement('div');
configItem.style.padding = '8px 16px';
configItem.style.cursor = 'pointer';
configItem.textContent = '📝 配置高亮分组';
configItem.addEventListener('click', () => {
showConfigPanel();
document.body.removeChild(menu);
});
configItem.addEventListener('mouseenter', () => {
configItem.style.background = '#f5f5f5';
});
configItem.addEventListener('mouseleave', () => {
configItem.style.background = 'white';
});
// 开关项
const toggleItem = document.createElement('div');
toggleItem.style.padding = '8px 16px';
toggleItem.style.cursor = 'pointer';
toggleItem.textContent = `🔄 ${isHighlightEnabled ? '关闭高亮' : '开启高亮'}`;
toggleItem.addEventListener('click', () => {
toggleHighlight();
toggleItem.textContent = `🔄 ${isHighlightEnabled ? '关闭高亮' : '开启高亮'}`;
document.body.removeChild(menu);
});
toggleItem.addEventListener('mouseenter', () => {
toggleItem.style.background = '#f5f5f5';
});
toggleItem.addEventListener('mouseleave', () => {
toggleItem.style.background = 'white';
});
menu.appendChild(configItem);
menu.appendChild(toggleItem);
document.body.appendChild(menu);
// 点击其他区域关闭菜单
const closeMenu = (e) => {
if (!menu.contains(e.target) && e.target !== btn) {
document.body.removeChild(menu);
document.removeEventListener('click', closeMenu);
}
};
document.addEventListener('click', closeMenu);
});
document.body.appendChild(btn);
}
// ===================== 全局初始化 =====================
function init() {
// 读取配置
loadLocalConfig();
// 初始化高亮
if (isHighlightEnabled && HIGHLIGHT_GROUPS.length > 0) {
initHighlight();
}
// 创建应急按钮(备用方案)
createEmergencyButton();
}
// ========== 关键修复:菜单注册 ==========
// 1. 立即注册菜单(最外层执行,确保时机正确)
try {
// 注册配置菜单
GM_registerMenuCommand('📝 配置高亮分组', showConfigPanel);
// 注册开关菜单
GM_registerMenuCommand('🔄 切换高亮开关', toggleHighlight);
console.log('✅ 菜单注册成功');
} catch (e) {
console.warn('⚠️ 菜单注册失败,已启用应急按钮', e);
}
// 2. 页面加载完成后再次尝试注册(双重保险)
if (document.readyState === 'complete') {
init();
} else {
window.addEventListener('load', init);
}
})();