Ultimate Persistent Highlighter

Highlighter with debounced adjacent span merging

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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: '&times;',
            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);
})();