Web Highlighter

Highlight selected text, saves locally, and edit or delete highlights

// ==UserScript==
// @name         Web Highlighter
// @author       Damodar Rajbhandari
// @namespace    physicslog.com.web-highlighter
// @version      1.33
// @description  Highlight selected text, saves locally, and edit or delete highlights
// @license      GNU GPL v3
// @match        *://*.wikipedia.org/*
// @grant        none
// @noframes
// ==/UserScript==

// @note: Please read any news or bugs at https://github.com/physicslog/web-highlighter.user.js

(function() {
    'use strict';

    const colors = ['#E5AE26', '#B895FF', '#54D171', '#D02848']; 
    const colors_title = ['Introduction / General / Well-known', 'Important / Spectacle / Interesting', 'Answer / Hint / Idea', 'Question / Critical / Hard / Sceptical'];
    let selectedColor = colors[0];

    // Load highlights from local storage
    const highlights = JSON.parse(localStorage.getItem('highlights') || '[]');
    
    // Adds a yellow dot at the top right corner of the webpage if highlights is present.
    if (highlights.length !== 0) {
        const dot = document.createElement('div');
        dot.title = 'Highlights exists! To view: do cmd+shift+v in the Mac';
        dot.style.width = '10px';
        dot.style.height = '10px';
        dot.style.backgroundColor = '#E5AE26';
        dot.style.borderRadius = '50%';
        dot.style.position = 'fixed';
        dot.style.top = '5px';
        dot.style.right = '5px';
        dot.style.zIndex = '1000';
        document.body.appendChild(dot);
    }

    console.log("Loaded highlights:", highlights);
    highlights.forEach(hl => {
        if (hl.url === window.location.href) {
            console.log("Restoring highlight:", hl);
            restoreHighlight(hl);
        }
    });

    // Save highlights to local storage
    function saveHighlights() {
        const serialized = Array.from(document.querySelectorAll('.highlighted')).map(el => ({
            text: el.innerText,
            color: el.style.backgroundColor,
            parentPath: getElementXPath(el.parentElement),
            url: window.location.href,
            timestamp: new Date().toISOString()
        }));
        console.log("Saving highlights to local storage:", serialized);
        localStorage.setItem('highlights', JSON.stringify(serialized));
    }

    // Restore a highlight
    function restoreHighlight({ text, color, parentPath }) {
        const parentElement = getElementByXPath(parentPath);
        if (parentElement) {
            const nodes = Array.from(parentElement.childNodes);
            nodes.forEach(node => {
                if (node.nodeType === Node.TEXT_NODE && node.nodeValue.includes(text)) {
                    const range = document.createRange();
                    range.setStart(node, node.nodeValue.indexOf(text));
                    range.setEnd(node, node.nodeValue.indexOf(text) + text.length);
                    wrapHighlight(range, color);
                    console.log("Highlight restored for text:", text);
                }
            });
        } else {
            console.error("Parent element not found for XPath:", parentPath);
        }
    }

    // Highlight selected text
    function wrapHighlight(range, color) {
        const span = document.createElement('span');
        span.style.backgroundColor = color;
        span.classList.add('highlighted');
        range.surroundContents(span);
    }

    // Get an XPath to an element
    function getElementXPath(element) {
        const paths = [];
        while (element && element.nodeType === Node.ELEMENT_NODE) {
            let index = 0;
            let sibling = element.previousSibling;
            while (sibling) {
                if (sibling.nodeType === Node.ELEMENT_NODE && sibling.nodeName === element.nodeName) {
                    index++;
                }
                sibling = sibling.previousSibling;
            }
            const tagName = element.nodeName.toLowerCase();
            const pathIndex = index ? `[${index + 1}]` : '';
            paths.unshift(`${tagName}${pathIndex}`);
            element = element.parentNode;
        }
        return paths.length ? `/${paths.join('/')}` : null;
    }

    // Retrieve an element by its XPath
    function getElementByXPath(xpath) {
        try {
            return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
        } catch (e) {
            console.error("Error evaluating XPath:", xpath, e);
            return null;
        }
    }

    // Event listener for text selection and highlighting
    document.addEventListener('mouseup', () => {
        const selection = window.getSelection();
        if (!event.target.closest('#displayHighlightsPopUp')) { // ignore if that is a popup to list all the highlights
            if (selection.rangeCount > 0) {
                const range = selection.getRangeAt(0);
                const selectedText = selection.toString().trim();
                if (selectedText.length > 0) {
                    wrapHighlight(range, selectedColor);
                    saveHighlights();
                    selection.removeAllRanges();
                }
            }
        }
    });

    // Event listener for clicking on existing highlights
    document.addEventListener('click', (event) => {
        if (event.target.classList.contains('highlighted')) {
            createPopup(event.target);
        }
    });

    // Popup for editing or deleting highlights
    function createPopup(element) {
        const popup = document.createElement('div');
        popup.style.position = 'absolute';
        popup.style.background = 'rgba(255, 255, 255, 0.9)';
        popup.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.1)';
        popup.style.border = '1px solid #ccc';
        popup.style.borderRadius = '8px';
        popup.style.padding = '5px';
        popup.style.zIndex = '1001';
        popup.style.transition = 'all 0.3s ease';

        // Color selection buttons to popup
        const buttonContainer = document.createElement('div');
        buttonContainer.style.display = 'flex';
        colors.forEach((color, index) => {
            const colorButton = document.createElement('button');
            colorButton.style.backgroundColor = color;
            colorButton.style.width = '20px';
            colorButton.style.height = '20px';
            colorButton.style.margin = '2px';
            colorButton.style.borderRadius = '50%';
            colorButton.style.border = 'none';
            colorButton.style.cursor = 'pointer';
            colorButton.title = colors_title[index];
            colorButton.onclick = () => {
                element.style.backgroundColor = color;
                saveHighlights();
            };
            buttonContainer.appendChild(colorButton);
        });

        // Add "X" button to popup
        const closeButton = document.createElement('button');
        closeButton.title = 'Delete';
        closeButton.innerText = '\u00D7';
        closeButton.style.width = '20px';
        closeButton.style.height = '20px';
        closeButton.style.margin = '2px';
        closeButton.style.lineHeight = '15px';
        closeButton.style.textAlign = 'center';
        closeButton.style.border = 'none';
        closeButton.style.backgroundColor = 'gray';
        closeButton.style.color = 'white';
        closeButton.style.fontWeight = 'bold';
        closeButton.style.borderRadius = '50%';
        closeButton.style.cursor = 'pointer';
        closeButton.onclick = () => {
            const parent = element.parentNode;
            parent.replaceChild(document.createTextNode(element.innerText), element);
            saveHighlights();
            document.body.removeChild(popup);
        };

        buttonContainer.appendChild(closeButton);
        popup.appendChild(buttonContainer);
        document.body.appendChild(popup);

        // Position the popup near the element
        const rect = element.getBoundingClientRect();
        popup.style.left = `${rect.left}px`;
        popup.style.top = `${rect.bottom + window.scrollY + 10}px`;

        // Remove popup when clicking outside        
        const outsideClickListener = (event) => { 
            if (!popup.contains(event.target)) { 
                document.body.removeChild(popup); 
                document.removeEventListener('click', outsideClickListener); 
            }
        };
        document.addEventListener('click', outsideClickListener); 
    } 
    
    // Another popup to show all the highlighted text on the present webpage
    // Get the XPath of the element
    function getElementByXPath(xpath) {
        return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
    }

    // Highlight the target element
    function flashingElement(element, color) {
        const originalBackgroundColor = element.style.backgroundColor;
        element.style.backgroundColor = color; // Flashing color
        setTimeout(() => {
            element.style.backgroundColor = originalBackgroundColor; // Restore original background color
        }, 1000); // flashing color duration
    }

    // Display highlights in a popup window
    function displayHighlightsPopup() {
        const highlights = JSON.parse(localStorage.getItem('highlights') || '[]');
        const popup = document.createElement('div');
        popup.id = 'displayHighlightsPopUp';
        popup.style.position = 'fixed';
        popup.style.top = '50%';
        popup.style.left = '50%';
        popup.style.transform = 'translate(-50%, -50%)';
        popup.style.width = '600px';
        popup.style.height = '400px';
        popup.style.backgroundColor = 'black';
        popup.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.1)';
        popup.style.border = '1px solid #ccc';
        popup.style.borderRadius = '8px';
        popup.style.padding = '10px';
        popup.style.zIndex = '1002';
        popup.style.overflowY = 'scroll';
        popup.style.userSelect = 'none'; // Disable text selection

        popup.innerHTML = '<h3>All Saved Highlights of this webpage</h3><ul></ul>';
        const list = popup.querySelector('ul');
        highlights.forEach((hl, index) => {
            const listItem = document.createElement('li');
            listItem.innerHTML = `<b>${new Date(hl.timestamp).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true })}:</b> <span style="color:${hl.color}">${hl.text}</span><button style="margin-left:10px;" class="delete-btn" data-index="${index}">X</button>`;
            listItem.style.cursor = 'pointer';
            listItem.onclick = (event) => {
                if (event.target.classList.contains('delete-btn')) {
                    event.stopPropagation(); // Prevent event propagation to the popup
                    const index = event.target.getAttribute('data-index');
                    deleteHighlight(index);
                } else {
                    console.log(`XPath: ${hl.parentPath}`); // Debug log
                    const parentElement = getElementByXPath(hl.parentPath);
                    if (parentElement) {
                        parentElement.scrollIntoView({ behavior: 'smooth' });
                        flashingElement(parentElement, hl.color); // Highlight the target element
                    } else {
                        console.error('Element not found for XPath:', hl.parentPath); // Debug error
                    }
                }
            };
            list.appendChild(listItem);
        });

        document.body.appendChild(popup);

        // Remove popup when clicking outside
        const outsideClickListener = (event) => {
            if (!popup.contains(event.target)) {
                document.body.removeChild(popup);
                document.removeEventListener('click', outsideClickListener);
            }
        };
        document.addEventListener('click', outsideClickListener);
    }

    // Delete highlight function 
    function deleteHighlight(index) { 
        let highlights = JSON.parse(localStorage.getItem('highlights') || '[]');
        if (index >= 0 && index < highlights.length) {
            // Remove highlight from local storage
            const [deletedHighlight] = highlights.splice(index, 1);
            localStorage.setItem('highlights', JSON.stringify(highlights));

            // Remove highlight from the DOM
            removeHighlight(deletedHighlight.parentPath, deletedHighlight.text);

            // Update indices
            document.getElementById('displayHighlightsPopUp').remove(); 
            displayHighlightsPopup();
        }
    }

    // Remove highlight from the DOM
    function removeHighlight(parentPath, text) {
        const parentElement = getElementByXPath(parentPath);
        if (parentElement) {
            const highlights = Array.from(parentElement.querySelectorAll('.highlighted'));
            const span = highlights.find(span => span.textContent.includes(text));
            if (span) {
                while (span.firstChild) {
                    parentElement.insertBefore(span.firstChild, span);
                }
                parentElement.removeChild(span);
            }
        }
    }

    // Listen for Command + V to display highlights popup
    document.addEventListener('keydown', (event) => {
        if (event.key === 'v' && (event.metaKey || event.ctrlKey)) {
            event.preventDefault();
            displayHighlightsPopup();
        }
    });

})();