// ==UserScript== // @name 文本高亮工具 - 分组规则面板版 // @namespace http://tampermonkey.net/ // @version 2026-05-07-002 // @description 支持多个高亮分组、当前分组启停、规则启停、删除规则、新增规则、新增分组、删除当前分组 // @author 周利斌 // @match *://*/* // @grant GM_registerMenuCommand // @grant GM_getValue // @grant GM_setValue // @run-at document-end // @license MIT // ==/UserScript== (function () { 'use strict'; function getValue(key, value) { const gmGetValueExists = window.GM_getValue && typeof GM_getValue !== "undefined"; return gmGetValueExists ? GM_getValue(key, value) : (localStorage.getItem(key) === null ? value : JSON.parse(localStorage.getItem(key))); } function setValue(key, value) { const gmSetValueExists = window.GM_setValue && typeof GM_setValue !== "undefined"; return gmSetValueExists ? GM_setValue(key, value) : localStorage.setItem(key, JSON.stringify(value)); } function delay(ms = 0) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); } function throttle(func, interval = 1000, immediate = true) { if (immediate === undefined) immediate = true; let lastExecuteTime = 0, timer = null; return function (...args) { const context = this, currentTime = Date.now(); immediate ? (currentTime - lastExecuteTime >= interval && (func.apply(context, args), lastExecuteTime = currentTime)) : (timer && clearTimeout(timer), (!lastExecuteTime || currentTime - lastExecuteTime >= interval) && (timer = setTimeout(function () { func.apply(context, args); lastExecuteTime = Date.now(); timer = null; }, interval))); }; } function debounce(func, wait = 1000, immediate = false) { if (immediate === undefined) immediate = false; let timer = null; return function (...args) { const context = this; if (timer) clearTimeout(timer); immediate ? (!timer && (timer = setTimeout(function () { timer = null; }, wait), func.apply(context, args))) : (timer = setTimeout(function () { func.apply(context, args); timer = null; }, wait)); }; } function waitUtilAsync(fn, timeout = 100000, interval = 1000, ctrl = { cancelled: false }) { if (timeout === undefined) timeout = 10000; if (interval === undefined) interval = 50; if (ctrl === undefined) ctrl = { cancelled: false }; return new Promise(function (resolve) { const start = performance.now(); (async function loop() { if (ctrl.cancelled) return resolve(false); const result = await fn(); if (result) return resolve(result); if (performance.now() - start > timeout) return resolve(false); setTimeout(loop, interval); })(); }); } /** * Create (or return if exists) a floating control panel with optional font-size controls, * draggable handle, and a close button. Position and font-size are persisted by `id`. * @param {Object} [options={}] - Panel options * @param {string} [options.id='_zlb_root_div_'] - Unique DOM id for the panel container * @param {boolean} [options.fontsize=true] - Whether to render font-size controls * @param {boolean} [options.drag=true] - Whether to render a draggable handle * @param {boolean|function} [options.close=true] - true to show a default close button; a function to run before removal * @param {keyof HTMLElementTagNameMap} [options.tagName='button'] - Tag name for interactive controls * @returns {HTMLDivElement} The panel DOM element */ function getPanel({ id = '_zlb_root_div_', fontsize = true, drag = true, close = true, tagName = 'button' } = {}) { const closeButtonId = id + '_close_'; let panelElement = document.getElementById(id); const createCloseButton = (host) => markUI(appendTo({ parent: host, id: closeButtonId }, tagName, 'X关闭X', () => { if (typeof close === 'function') close(); host.remove(); }), 'sys'); if (panelElement) { if (close) createCloseButton(panelElement); return/** @type {HTMLDivElement} */(panelElement); } panelElement = document.createElement('div'); panelElement.id = id; panelElement.classList.add('notranslate'); panelElement.setAttribute('translate', 'no'); panelElement.onmousedown = panelElement.oncontextmenu = (event) => event.stopPropagation(); document.body.appendChild(panelElement); let currentFontSize = Number(getValue(id + ':fs', 12)); let leftPercent = Math.min(Math.max(Number(getValue(id + ':L', 50)), 0), 95); let topPercent = Math.min(Math.max(Number(getValue(id + ':T', 50)), 0), 95); const styleElement = document.createElement('style'); panelElement.appendChild(styleElement); const updatePanelStyles = () => { styleElement.textContent = ` #${id}{position:fixed;z-index:999999;background-color:rgba(187, 180, 180, 0.9);border:1px solid rgba(191, 70, 173, 0.9);max-width:50vw;left:${leftPercent}%;top:${topPercent}%;user-select:none;font-size:${currentFontSize}px;display:flex;flex-wrap:wrap;transition: all .25s;} #${id} button{border-radius:${currentFontSize}px;min-width:auto;display: inline-flex;padding:0 4px;font-size:${currentFontSize}px;transition: all .25s;} #${id} span{margin:0 2px} #${id} label{margin:0px 2px;display: inline-flex;border:1px solid rgba(117,70,227,.7);border-radius:${currentFontSize}px;transition: all .01s;}`; }; updatePanelStyles(); if (fontsize) { markUI(appendTo(panelElement, tagName, '-字号-', () => { currentFontSize = Math.max(6, currentFontSize * 0.9); setValue(id + ':fs', currentFontSize); updatePanelStyles(); }), 'sys'); markUI(appendTo(panelElement, tagName, '+字号+', () => { currentFontSize = currentFontSize * 1.1; setValue(id + ':fs', currentFontSize); updatePanelStyles(); }), 'sys'); } if (drag) { const dragHandleButton = markUI(appendTo(panelElement, tagName, '✥拖动✥'), 'sys'); dragHandleButton.addEventListener('mousedown', (event) => { const rect = panelElement.getBoundingClientRect(); const deltaX = event.clientX - rect.left; const deltaY = event.clientY - rect.top; const moveHandler = (moveEvent) => { panelElement.style.left = (moveEvent.clientX - deltaX) + 'px'; panelElement.style.top = (moveEvent.clientY - deltaY) + 'px'; }; const upHandler = () => { document.removeEventListener('mousemove', moveHandler); document.removeEventListener('mouseup', upHandler); const leftInPercent = (parseFloat(panelElement.style.left) / document.documentElement.clientWidth) * 100; const topInPercent = (parseFloat(panelElement.style.top) / document.documentElement.clientHeight) * 100; leftPercent = Math.min(Math.max(leftInPercent, 0), 95); topPercent = Math.min(Math.max(topInPercent, 0), 95); panelElement.style.left = leftPercent + '%'; panelElement.style.top = topPercent + '%'; setValue(id + ':L', leftPercent); setValue(id + ':T', topPercent); updatePanelStyles(); }; document.addEventListener('mousemove', moveHandler); document.addEventListener('mouseup', upHandler); }); } if (close) createCloseButton(panelElement); setTimeout(() => { if (panelElement.children.length <= 1 + !!fontsize * 2 + !!drag + !!close) panelElement.remove(); }, 100); return/** @type {HTMLDivElement} */(panelElement); } /** * 创建或复用一个 HTML 元素,并插入到指定位置。 可以在parentOrOption中写任意属性 * * 支持三种插入方式(按优先顺序): * 1. `parent`:插入到该元素内部末尾; * 2. `afterend`:插入到该元素之后; * 3. `beforebegin`:插入到该元素之前。 * * 可设置样式、类名、属性与事件。若指定 id 且元素已存在,则复用原元素。 * * @param {Object|HTMLElement|null} [parentOrOption=null] - 父元素或配置对象。 * @param {HTMLElement} [parentOrOption.parent] - 插入到该元素内部。 * @param {HTMLElement} [parentOrOption.afterend] - 插入到该元素之后。 * @param {HTMLElement} [parentOrOption.beforebegin] - 插入到该元素之前。 * @param {keyof HTMLElementTagNameMap} [parentOrOption.tagName="a"] - 元素标签名。 * @param {string} [parentOrOption.textContent=""] - 元素文本内容。 * @param {Object} [parentOrOption.functions={}] - 事件集合。 * @param {string|Partial} [parentOrOption.style] - 内联样式。 * @param {string|string[]|DOMTokenList} [parentOrOption.className|classList] - 类名。 * @param {string} [parentOrOption.id] - 元素 ID(复用已有元素)。 * @param {Object} [parentOrOption.other] - 其他任意属性。 * @param {string} [tagName] - (简写模式)标签名。 * @param {string} [textContent] - (简写模式)文本内容。 * @param {Function} [click] - (简写模式)点击事件。 * @param {string} [id] - (简写模式)元素 ID。 * @returns {HTMLElement} 创建或复用的元素。 */ function appendTo(parentOrOption = null, tagName = null, textContent = null, click = null, id = null) { const isObj = parentOrOption && typeof parentOrOption === "object" && !(parentOrOption instanceof HTMLElement); const base = { ...(isObj ? parentOrOption : { parent: parentOrOption }), ...(tagName && { tagName }), ...(textContent && { textContent }), ...(click && { click }), ...(id && { id }) }; const { parent = null, afterend = null, beforebegin = null, tagName: tag = "a", textContent: txt = "", id: i = "", functions = {}, click: c, ...other } = base; let el = i && document.getElementById(i); if (!el) el = document.createElement(tag); if (parent instanceof HTMLElement && parent !== el.parentElement) parent.appendChild(el); else if (afterend instanceof HTMLElement) afterend.insertAdjacentElement("afterend", el); else if (beforebegin instanceof HTMLElement) beforebegin.insertAdjacentElement("beforebegin", el); if (i) el.id = i; if (txt) el.textContent = txt; const fns = { ...functions }; for (const [k, v] of Object.entries(other)) { if (!v) continue; if (k === "style") typeof v === "string" ? (el.style.cssText = v) : Object.assign(el.style, v); else if (k === "className" || k === "classList") { const classes = Array.isArray(v) ? v : typeof v === "string" ? v.split(/\s+/) : [...v]; el.classList.add(...classes.filter(Boolean)); } else if (typeof v === "function") fns[k] = v; else (k in el ? (el[k] = v) : el.setAttribute(k, v)); } if (c) fns.click = c; for (const [ev, fn] of Object.entries(fns)) el.addEventListener(ev, e => fn(e, el)); return el; } /** * @param {HTMLElement} parent * @param {string} savekey * @param {string[]} status * @param {((btn: HTMLElement) => void)[]} funcs - 单个函数或者 与状态列表一一对应的回调函数数组 */ function toggleButton(parent, savekey, statusList, funcs = [], bgColors = ["", "#ffb6c1", "#a8d08d", "#f0e68c", "#add8e6", "#ff6347", "#98fb98", "#7b7070", "#ffd700", "#ff1493", "#90ee90", "#ff4500", "#8a2be2", "#32cd32", "#ff8c00", "#d2691e", "#ff0000", "#b0e0e6", "#dcdcdc", "#c7c7c7"]) { const initialState = getValue(savekey, statusList[0]); let currentIndex = statusList.indexOf(initialState) === -1 ? 0 : statusList.indexOf(initialState); let currentStatusList = [...statusList]; const updateButton = (index, state) => { btn.textContent = String(state); if (Array.isArray(bgColors) && bgColors.length) { btn.style.backgroundColor = bgColors[index % bgColors.length] || ""; } btn.dataset.index = String(index); btn.dataset.state = String(state); const targetFunc = Array.isArray(funcs) && funcs.length ? funcs[index % funcs.length] : funcs; targetFunc?.call(btn, btn, state, index); }; const btn = appendTo(parent, "button", initialState, () => { currentIndex = (currentIndex + 1) % currentStatusList.length; const newState = currentStatusList[currentIndex]; setValue(savekey, newState); updateButton(currentIndex, newState); } ); updateButton(currentIndex, initialState); btn.setStatus = (newStatusList) => { currentStatusList = [...newStatusList]; const newIndex = currentStatusList.indexOf(btn.dataset.state) === -1 ? 0 : currentStatusList.indexOf(btn.dataset.state); currentIndex = newIndex; updateButton(currentIndex, currentStatusList[currentIndex]); }; return btn; } // ====================== 面板通用工具 ====================== function markUI(el, name = 'sys') { if (!el) return el; el.classList.add(String(name)); el.dataset.uiName = String(name); return el; } function getPanelEnableKey(name) { return `panel_enable_${name}`; } function isPanelEnabled(name) { return Number(getValue(getPanelEnableKey(name), 1)) ? 1 : 0; } function setPanelEnabled(name, enable) { setValue(getPanelEnableKey(name), enable ? 1 : 0); } function getRootPanel() { return document.querySelector('[data-zlb-panel="1"]'); } function getUIList(panel, name = '') { if (!panel) return []; return Array.from(panel.children).filter(el => { if (el.tagName === 'STYLE') return false; if (!el.dataset || !el.dataset.uiName) return false; return !name || el.dataset.uiName === String(name); }); } function clearUI(panel, name) { getUIList(panel, name).forEach(el => el.remove()); } function panelOnlyHasSys(panel) { return getUIList(panel).every(el => el.dataset.uiName === 'sys'); } function closePanelUI(name) { setPanelEnabled(name, 0); const panel = getRootPanel(); if (!panel) return; clearUI(panel, name); if (panelOnlyHasSys(panel)) { panel.remove(); } } function insertAfterLastUI(panel, name, tagName, textContent, click) { const uiList = getUIList(panel); const lastUI = uiList.length ? uiList[uiList.length - 1] : null; const el = lastUI ? appendTo({ afterend: lastUI }, tagName, textContent, click) : appendTo(panel, tagName, textContent, click); return markUI(el, name); } function registerPanelMenu(name, showFn) { if (typeof GM_registerMenuCommand !== 'undefined') { GM_registerMenuCommand('面板-' + name, () => { const panel = getRootPanel(); const hasThisUI = panel && getUIList(panel, name).length; if (hasThisUI) { closePanelUI(name); } else { setPanelEnabled(name, 1); showFn(); } }); } } // ====================== 核心配置 ====================== const SEARCH_PANEL_NAME = '搜索'; const SEARCH_GROUP_RULES_KEY = 'search_group_rules'; const SEARCH_GROUP_NAME_KEY = 'search_group_name'; const HIGHLIGHT_COLORS = [ "#FFF59D", "#FFE0B2", "#FFCCBC", "#F8BBD0", "#E1BEE7", "#D1C4E9", "#BBDEFB", "#B3E5FC", "#C8E6C9", "#DCEDC8" ]; const SKIP_TAGS = new Set([ 'SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'SVG', 'LINK', 'SUP', 'SUB', 'TEXTAREA', 'INPUT', 'SELECT' ]); let search_group_rules = { 搜索1: { enable: 1, rules: { '没有': 1 } } }; let search_group_name = '搜索1'; let observer = null; let isProcessing = false; let isRenderingRulePanel = false; // ====================== 分组状态 ====================== function ensureDefaultGroup() { if (!search_group_rules || typeof search_group_rules !== 'object' || Array.isArray(search_group_rules)) { search_group_rules = {}; } if (!Object.keys(search_group_rules).length) { search_group_rules = { 搜索1: { enable: 1, rules: { '没有': 1 } } }; search_group_name = '搜索1'; } } function normalizeSearchGroupRules() { ensureDefaultGroup(); Object.keys(search_group_rules).forEach(groupName => { const group = search_group_rules[groupName] || {}; const rules = group.rules && typeof group.rules === 'object' && !Array.isArray(group.rules) ? group.rules : {}; Object.keys(rules).forEach(rule => { rules[rule] = Number(rules[rule]) ? 1 : 0; }); search_group_rules[groupName] = { enable: Number(group.enable) ? 1 : 0, rules }; }); ensureDefaultGroup(); } function getGroupNames() { return Object.keys(search_group_rules).sort((a, b) => { const ma = String(a).match(/^搜索(\d+)$/); const mb = String(b).match(/^搜索(\d+)$/); if (ma && mb) return Number(ma[1]) - Number(mb[1]); if (ma) return -1; if (mb) return 1; return String(a).localeCompare(String(b)); }); } function getNextGroupName() { const usedIndexes = new Set(); Object.keys(search_group_rules).forEach(name => { const match = String(name).match(/^搜索(\d+)$/); if (match) { usedIndexes.add(Number(match[1])); } }); let index = 1; while (usedIndexes.has(index)) { index++; } return `搜索${index}`; } function saveSearchGroupState() { setValue(SEARCH_GROUP_RULES_KEY, search_group_rules); setValue(SEARCH_GROUP_NAME_KEY, search_group_name); } function loadSearchGroupState() { const savedGroupRules = getValue(SEARCH_GROUP_RULES_KEY, null); if (savedGroupRules && typeof savedGroupRules === 'object' && !Array.isArray(savedGroupRules)) { search_group_rules = savedGroupRules; } normalizeSearchGroupRules(); const savedGroupName = getValue(SEARCH_GROUP_NAME_KEY, search_group_name); search_group_name = search_group_rules[savedGroupName] ? savedGroupName : getGroupNames()[0]; saveSearchGroupState(); } function createNewGroup(rules = {}) { const newGroupName = getNextGroupName(); search_group_rules[newGroupName] = { enable: 1, rules }; search_group_name = newGroupName; saveSearchGroupState(); return newGroupName; } function getCurrentGroup() { ensureDefaultGroup(); if (!search_group_rules[search_group_name]) { search_group_name = getGroupNames()[0]; saveSearchGroupState(); } return search_group_rules[search_group_name]; } // ====================== 文本状态 ====================== function getGroupTabText(groupName) { const group = search_group_rules[groupName]; const enabled = group && Number(group.enable); if (groupName !== search_group_name) { return groupName; } return enabled ? `++${groupName}++` : `--${groupName}--`; } function getRuleEnabledText(rule) { return `${rule}【启用】`; } function getRuleDisabledText(rule) { return `${rule}【停用】`; } function getRuleButtonSaveKey(groupName, rule) { const group = search_group_rules[groupName]; const enable = group && group.rules && Number(group.rules[rule]) ? 1 : 0; return `search_group_rule_${encodeURIComponent(groupName)}_${encodeURIComponent(rule)}_${enable}`; } // ====================== 规则解析 ====================== function parseRule(line) { if (line.startsWith('/')) { try { const lastSlash = line.lastIndexOf('/'); if (lastSlash <= 0) { return line; } const pattern = line.slice(1, lastSlash); const flags = line.slice(lastSlash + 1); const safeFlags = flags.includes('g') ? flags : flags + 'g'; return new RegExp(pattern, safeFlags); } catch (e) { return line; } } return line; } function loadRules() { const group = getCurrentGroup(); if (!group || !Number(group.enable)) { return []; } return Object.entries(group.rules || {}) .filter(([, enabled]) => Number(enabled)) .map(([rule]) => parseRule(rule)); } // ====================== 高亮处理 ====================== function removeAllHighlights() { const spans = document.querySelectorAll('.user-highlight'); spans.forEach(span => { const parent = span.parentNode; if (parent) { const textNode = document.createTextNode(span.textContent); parent.replaceChild(textNode, span); parent.normalize(); } }); } function highlight(target, color) { const regex = target instanceof RegExp ? target : new RegExp(target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'); function walk(node) { if (!node) return; if (node.nodeType === Node.ELEMENT_NODE) { if (SKIP_TAGS.has(node.tagName)) return; if (node.classList && node.classList.contains('user-highlight')) return; if (node.classList && node.classList.contains('zlb-highlight-control-panel')) return; } if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent; if (!text || !text.trim()) return; regex.lastIndex = 0; let match; let lastIndex = 0; const fragment = document.createDocumentFragment(); while ((match = regex.exec(text)) !== null) { if (match[0] === '') { regex.lastIndex++; continue; } if (match.index > lastIndex) { fragment.appendChild( document.createTextNode(text.slice(lastIndex, match.index)) ); } const span = document.createElement('span'); span.className = 'user-highlight'; span.style.backgroundColor = color; span.style.padding = '0 2px'; span.style.borderRadius = '2px'; span.style.color = '#000'; span.textContent = match[0]; fragment.appendChild(span); lastIndex = regex.lastIndex; } if (lastIndex === 0) return; if (lastIndex < text.length) { fragment.appendChild( document.createTextNode(text.slice(lastIndex)) ); } if (node.parentNode) { node.parentNode.replaceChild(fragment, node); } return; } Array.from(node.childNodes).forEach(child => walk(child)); } walk(document.body); } function startHighlight() { if (isProcessing) return; isProcessing = true; try { const rules = loadRules(); rules.forEach((rule, index) => { const color = HIGHLIGHT_COLORS[index % HIGHLIGHT_COLORS.length]; highlight(rule, color); }); } finally { isProcessing = false; } } function refreshHighlight() { removeAllHighlights(); startHighlight(); } // ====================== 分组 UI ====================== function addGroupTabButton(panel, groupName) { const btn = insertAfterLastUI(panel, SEARCH_PANEL_NAME, 'button', getGroupTabText(groupName), () => { if (search_group_name !== groupName) { search_group_name = groupName; saveSearchGroupState(); showSearchRulePanel(); refreshHighlight(); return; } const group = getCurrentGroup(); group.enable = Number(group.enable) ? 0 : 1; saveSearchGroupState(); btn.textContent = getGroupTabText(groupName); btn.style.backgroundColor = Number(group.enable) ? '#ffb6c1' : '#a8d08d'; refreshHighlight(); }); if (groupName === search_group_name) { const group = search_group_rules[groupName]; btn.style.backgroundColor = Number(group.enable) ? '#ffb6c1' : '#a8d08d'; } btn.title = groupName === search_group_name ? '当前分组:点击启用/停用' : `切换到 ${groupName}`; } function addNewGroupButton(panel) { insertAfterLastUI(panel, SEARCH_PANEL_NAME, 'button', '增加', () => { createNewGroup(); showSearchRulePanel(); refreshHighlight(); }); } function addDeleteCurrentGroupButton(panel) { insertAfterLastUI(panel, SEARCH_PANEL_NAME, 'button', '删除分组', () => { if (!confirm(`确定删除当前分组「${search_group_name}」吗?`)) { return; } delete search_group_rules[search_group_name]; ensureDefaultGroup(); search_group_name = getGroupNames()[0]; saveSearchGroupState(); showSearchRulePanel(); refreshHighlight(); }); } // ====================== 规则 UI ====================== function addSearchRuleButtons(panel, rule) { const group = getCurrentGroup(); if (!group.rules || !(rule in group.rules)) return; const enabledText = getRuleEnabledText(rule); const disabledText = getRuleDisabledText(rule); const statusList = Number(group.rules[rule]) ? [enabledText, disabledText] : [disabledText, enabledText]; const funcs = statusList.map(state => { return () => { const currentGroup = getCurrentGroup(); if (!currentGroup.rules || !(rule in currentGroup.rules)) return; currentGroup.rules[rule] = String(state).includes('启用') ? 1 : 0; saveSearchGroupState(); if (!isRenderingRulePanel) { refreshHighlight(); } }; }); const ruleBtn = toggleButton( insertAfterLastUI(panel, SEARCH_PANEL_NAME, 'span', '', null), getRuleButtonSaveKey(search_group_name, rule), statusList, funcs, ['#ffb6c1','#a8d08d'] // ['#a8d08d', '#d0d0d0'] ); markUI(ruleBtn, SEARCH_PANEL_NAME); ruleBtn.title = rule; const deleteBtn = appendTo( { afterend: ruleBtn }, 'button', '删', () => { if (!confirm(`确定删除规则「${rule}」吗?`)) { return; } const currentGroup = getCurrentGroup(); if (currentGroup.rules && rule in currentGroup.rules) { delete currentGroup.rules[rule]; saveSearchGroupState(); } ruleBtn.remove(); deleteBtn.remove(); refreshHighlight(); } ); markUI(deleteBtn, SEARCH_PANEL_NAME); } function addNewRuleButton(panel) { insertAfterLastUI(panel, SEARCH_PANEL_NAME, 'button', '+规则', () => { const input = prompt( '请输入新的高亮规则:\n普通文本直接输入;正则用 /pattern/flags\n例如:没有\n例如:/错误.*/i' ); if (!input || !input.trim()) return; const newRule = input.trim(); const group = getCurrentGroup(); if (!group.rules) { group.rules = {}; } if (newRule in group.rules) return; group.rules[newRule] = 1; saveSearchGroupState(); addSearchRuleButtons(panel, newRule); refreshHighlight(); }); } // ====================== 高亮规则面板 ====================== function showSearchRulePanel() { setPanelEnabled(SEARCH_PANEL_NAME, 1); isRenderingRulePanel = true; const panel = getPanel({ fontsize: true, drag: true, close: () => { setPanelEnabled(SEARCH_PANEL_NAME, 0); }, tagName: 'button' }); panel.dataset.zlbPanel = '1'; panel.classList.add('zlb-highlight-control-panel'); clearUI(panel, SEARCH_PANEL_NAME); getGroupNames().forEach(groupName => { addGroupTabButton(panel, groupName); }); addNewGroupButton(panel); addDeleteCurrentGroupButton(panel); addNewRuleButton(panel); Object.keys(getCurrentGroup().rules || {}).forEach(rule => { addSearchRuleButtons(panel, rule); }); isRenderingRulePanel = false; } // ====================== 动态监听 ====================== function observeChanges() { if (observer) observer.disconnect(); const run = debounce(() => { startHighlight(); }, 300); observer = new MutationObserver(() => { run(); }); observer.observe(document.body, { childList: true, subtree: true, characterData: true }); } // ====================== 菜单注册 ====================== registerPanelMenu(SEARCH_PANEL_NAME, showSearchRulePanel); // ====================== 初始化 ====================== function init() { loadSearchGroupState(); startHighlight(); if (isPanelEnabled(SEARCH_PANEL_NAME)) { showSearchRulePanel(); } observeChanges(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();