您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Highlighter with debounced adjacent span merging
// ==UserScript== // @name Ultimate Persistent Highlighter // @namespace http://tampermonkey.net/ // @version 2.4 // @description Highlighter with debounced adjacent span merging // @author You // @match *://*/* // @grant none // ==/UserScript== (function() { 'use strict'; // Configuration const STORAGE_KEY = location.href + '-highlights'; const DEFAULT_COLOR = '#ffff00'; const DEBOUNCE_TIME = 100; // Single debounce time for all cases const INIT_DELAY = 1000; const HIGHLIGHT_CLASS = 'persistent-highlight'; const BATCH_SIZE = 50; // State let selectedColor = DEFAULT_COLOR; let highlightsCache = []; let observer = null; // DOM Utilities const createElement = (tag, props) => Object.assign(document.createElement(tag), props); const createTextNode = text => document.createTextNode(text); const querySelectorAll = selector => document.querySelectorAll(selector); // Get text nodes in selection function getSelectedTextNodes(range) { const selectedNodes = []; const startNode = range.startContainer; const endNode = range.endContainer; // Special case when selection is within a single text node if (startNode === endNode && startNode.nodeType === Node.TEXT_NODE) { console.log('within a single text node: ', startNode) return [startNode]; } // Walk through all text nodes in the range const treeWalker = document.createTreeWalker( range.commonAncestorContainer, NodeFilter.SHOW_TEXT, { acceptNode: node => { if (node === startNode || node === endNode || range.intersectsNode(node)) { return NodeFilter.FILTER_ACCEPT; } return NodeFilter.FILTER_SKIP; } } ); while (treeWalker.nextNode()) { selectedNodes.push(treeWalker.currentNode); } return selectedNodes; } // Generate XPath for node function getXPath(node) { if (!node) return ''; if (node.nodeType === Node.TEXT_NODE) { return getXPath(node.parentNode) + "/text()"; } if (node.nodeType !== Node.ELEMENT_NODE) { return ''; } if (node.id) return `id("${node.id}")`; if (node === document.body) return '/html/body'; let ix = 0; const siblings = node.parentNode?.childNodes || []; for (let i = 0; i < siblings.length; i++) { const sibling = siblings[i]; if (sibling === node) { return `${getXPath(node.parentNode)}/${node.tagName.toLowerCase()}[${ix + 1}]`; } if (sibling.nodeType === 1 && sibling.tagName === node.tagName) ix++; } return ''; } // Find element by XPath function getElementByXPath(xpath) { try { return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; } catch (e) { return null; } } /*-------------------------------*/ // Shared debounce function function debounce(func, timeout = DEBOUNCE_TIME) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => func.apply(this, args), timeout); }; } // Pause observer during DOM operations function withObserverPaused(callback) { if (observer) observer.disconnect(); try { callback(); } finally { if (observer) observer.observe(document.body, { childList: true, subtree: true }); } } // Apply cached highlights function applyCachedHighlights() { for (let i = 0; i < highlightsCache.length; i += BATCH_SIZE) { const batch = highlightsCache.slice(i, i + BATCH_SIZE); batch.forEach(({ xpath, start, end, color }) => { const node = getElementByXPath(xpath); if (node) applyHighlight(node, start, end, color); }); } } const debouncedApplyHighlights = debounce(() => { withObserverPaused(() => { applyCachedHighlights() }) }); // Highlight selected text function highlightSelection() { const selection = window.getSelection(); if (!selection.rangeCount || selection.isCollapsed) return; const range = selection.getRangeAt(0); const selectedNodes = getSelectedTextNodes(range); if (!selectedNodes.length) return; withObserverPaused(() => { selectedNodes.forEach((node, index) => { console.log('selectedNode', node); if (node.parentNode.classList.contains(HIGHLIGHT_CLASS)) return; const xpath = getXPath(node); const isFirstNode = index === 0; const isLastNode = index === selectedNodes.length - 1; const start = isFirstNode ? range.startOffset : 0; const end = isLastNode ? range.endOffset : node.nodeValue.length; console.log(`start=${start} end=${end}`); if (start >= end) return; const existing = highlightsCache.find(h => h.xpath === xpath && h.start === start && h.end === end ); console.log('exist?', existing); if (existing) return; highlightsCache.push({ xpath, start, end, color: selectedColor }); applyHighlight(node, start, end, selectedColor); }); }); debouncedMergeHighlights(); selection.removeAllRanges(); } // Apply highlight to text node function applyHighlight(textNode, start, end, color) { console.log('applyHighlight', textNode, start, end); const textContent = textNode.nodeValue; const parent = textNode.parentNode; const beforeNode = start > 0 ? createTextNode(textContent.substring(0, start)) : null; const highlightNode = createElement('span', { style: `background-color:${color}`, className: HIGHLIGHT_CLASS, textContent: textContent.substring(start, end), onclick: removeHighlight }); const afterNode = end < textContent.length ? createTextNode(textContent.substring(end)) : null; const fragment = document.createDocumentFragment(); if (beforeNode) fragment.appendChild(beforeNode); fragment.appendChild(highlightNode); if (afterNode) fragment.appendChild(afterNode); parent.replaceChild(fragment, textNode); } /*------------------ Remove Highlights ------------------*/ // Remove single highlight function removeHighlight(event) { withObserverPaused(() => { const span = event.target; const textNode = createTextNode(span.textContent); span.replaceWith(textNode); const xpath = getXPath(span); highlightsCache = highlightsCache.filter(h => h.xpath !== xpath); mergeAllAdjacentTextNodes(); }); } // De-highlight selection function dehighlightSelection() { const selection = window.getSelection(); if (!selection.rangeCount) return; const range = selection.getRangeAt(0); const selectedNodes = getSelectedTextNodes(range); withObserverPaused(() => { selectedNodes.forEach(node => { if (node.parentNode.classList.contains(HIGHLIGHT_CLASS)) { const span = node.parentNode; span.replaceWith(createTextNode(span.textContent)); highlightsCache = highlightsCache.filter(h => h.xpath !== getXPath(span)); } }); mergeAllAdjacentTextNodes(); }); selection.removeAllRanges(); } // Clear all highlights function clearAllHighlights() { withObserverPaused(() => { querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(span => { span.replaceWith(createTextNode(span.textContent)); }); highlightsCache = []; mergeAllAdjacentTextNodes(); }); } /*------------------ Clean DOM ------------------*/ function mergeAdjacentHighlights() { const processedSpans = new Set(); const shouldMerge = (a, b) => b?.nodeType === Node.ELEMENT_NODE && b.classList.contains(HIGHLIGHT_CLASS) && b.style.backgroundColor === a.style.backgroundColor && !processedSpans.has(b); const mergeHighlights = (baseSpan, targetSpan) => { const baseXPath = getXPath(baseSpan); const targetXPath = getXPath(targetSpan); const baseHighlight = highlightsCache.find(h => h.xpath === baseXPath); const targetHighlight = highlightsCache.find(h => h.xpath === targetXPath); if (baseHighlight && targetHighlight) { baseHighlight.end = targetHighlight.end; highlightsCache = highlightsCache.filter(h => h !== targetHighlight); processedSpans.add(targetSpan); } baseSpan.textContent += targetSpan.textContent; targetSpan.remove(); }; querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(span => { if (processedSpans.has(span)) return; let currentSpan = span; let prev = currentSpan.previousSibling; // Merge backwards while (shouldMerge(currentSpan, prev)) { mergeHighlights(prev, currentSpan); currentSpan = prev; prev = currentSpan.previousSibling; } // Merge forwards only if not already merged backwards if (currentSpan === span) { let next = currentSpan.nextSibling; while (shouldMerge(currentSpan, next)) { mergeHighlights(currentSpan, next); next = currentSpan.nextSibling; } } }); } // Merge adjacent text nodes (Edge fix) function mergeAllAdjacentTextNodes(root = document.body) { const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode: () => NodeFilter.FILTER_ACCEPT } ); let currentNode; while ((currentNode = walker.nextNode())) { while (currentNode.nextSibling?.nodeType === Node.TEXT_NODE) { const nextNode = currentNode.nextSibling; const x1 = getXPath(currentNode); const x2 = getXPath(nextNode); const t1 = currentNode.textContent; const t2 = nextNode.textContent; console.log('Merge text node', t1 , ' with ', t2 , 'x1=', x1, 'x2=', x2); currentNode.textContent += nextNode.textContent; nextNode.remove(); // Don't advance walker; keep merging until no adjacent text nodes remain } } } const debouncedMergeHighlights = debounce(() => { withObserverPaused(() => { mergeAdjacentHighlights(); mergeAllAdjacentTextNodes(); }); }); /*------------------ Initialize things ------------------*/ // Create UI controls function createUI() { const style = { position: 'fixed', bottom: '20px', right: '20px', padding: '10px', backgroundColor: 'rgba(255, 255, 255, 0.9)', border: '1px solid #ccc', borderRadius: '8px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', zIndex: '10000', fontFamily: 'sans-serif' }; const container = createElement('div', { id: 'highlight-toolbox', style: Object.entries(style).map(([k, v]) => `${k}:${v}`).join(';') }); const closeBtn = createElement('span', { innerHTML: '×', title: 'Close toolbox', style: 'float:right;cursor:pointer;margin-bottom:5px;font-size:16px;color:#888', onclick: () => container.remove() }); const colorInput = createElement('input', { type: 'color', value: selectedColor, style: 'margin-bottom:6px', oninput: e => selectedColor = e.target.value }); const makeButton = (text, onClick) => createElement('button', { innerText: text, style: 'margin:4px 2px;padding:6px 10px;border:none;border-radius:4px;cursor:pointer;' + 'font-size:14px;background-color:#1976d2;color:#fff;transition:background-color 0.2s', onmouseover: e => e.target.style.backgroundColor = '#1565c0', onmouseout: e => e.target.style.backgroundColor = '#1976d2', onclick: onClick }); container.append( closeBtn, document.createElement('br'), colorInput, document.createElement('br'), makeButton('Highlight', highlightSelection), makeButton('De-highlight', dehighlightSelection), makeButton('Clear All', clearAllHighlights) ); document.body.appendChild(container); } // Initialize MutationObserver function initializeObserver() { observer = new MutationObserver(mutations => { if (!highlightsCache.length) return; for (const mutation of mutations) { if (mutation.addedNodes.length) { debouncedApplyHighlights(); break; } } }); } // Load highlights from storage function loadHighlights() { try { const stored = localStorage.getItem(STORAGE_KEY); highlightsCache = stored ? JSON.parse(stored) : []; if (highlightsCache.length) { debouncedApplyHighlights(); } } catch (e) { console.error('Error loading highlights:', e); highlightsCache = []; } } // Save highlights to storage function saveHighlights() { try { if (highlightsCache.length) { localStorage.setItem(STORAGE_KEY, JSON.stringify(highlightsCache)); } else { localStorage.removeItem(STORAGE_KEY); } } catch (e) { console.error('Error saving highlights:', e); } } // Initialize everything const init = () => { initializeObserver(); loadHighlights(); createUI(); observer.observe(document.body, { childList: true, subtree: true }); }; // Start after page load with delay if (document.readyState === 'complete') { setTimeout(init, INIT_DELAY); } else { window.addEventListener('load', () => setTimeout(init, INIT_DELAY)); } // Save before page unload window.addEventListener('beforeunload', saveHighlights); })();