Web Highlighter

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
        }
    });

})();