Smart Text Highlighter

Highlight text on any site (even dynamic SPAs like React). Smart contrast, persistent, minimal UI.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Smart Text Highlighter
// @namespace    http://tampermonkey.net/
// @version      1.6
// @description  Highlight text on any site (even dynamic SPAs like React). Smart contrast, persistent, minimal UI.
// @author       Diyar Baban
// @license      MIT
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const STORAGE_KEY = `tm_highlights_${window.location.hostname}${window.location.pathname}`;
    const UI_Z_INDEX = 2147483647;
    const DEBOUNCE_MS = 300;

    // --- State ---
    let highlights = loadHighlights();
    window.getHighlights = () => highlights;
    let colorPrefs = GM_getValue('tm_color_prefs', {});
    let isMenuOpen = false;
    let hoverButton = null;

    // --- CSS Styles ---
    // Inject the Highlight API styles and our UI styles
    const style = document.createElement('style');
    style.textContent = `
        /* Smart Highlight Colors - Layers 0-4 for blending */
        :root {
            --tm-y: 255, 214, 0;
            --tm-c: 0, 229, 255;
            --tm-p: 255, 64, 129;
        }
        /* Generate classes for 5 layers to ensure overlaps blend */
        ::highlight(tm-yellow-0), ::highlight(tm-yellow-1), ::highlight(tm-yellow-2), ::highlight(tm-yellow-3), ::highlight(tm-yellow-4)
        { background-color: rgba(var(--tm-y), 0.7); color: #000; }

        ::highlight(tm-cyan-0), ::highlight(tm-cyan-1), ::highlight(tm-cyan-2), ::highlight(tm-cyan-3), ::highlight(tm-cyan-4)
        { background-color: rgba(var(--tm-c), 0.7); color: #000; }

        ::highlight(tm-pink-0), ::highlight(tm-pink-1), ::highlight(tm-pink-2), ::highlight(tm-pink-3), ::highlight(tm-pink-4)
        { background-color: rgba(var(--tm-p), 0.7); color: #000; }

        /* Minimalist Button UI */
        #tm-hl-btn {
            position: fixed;
            z-index: ${UI_Z_INDEX};
            background: #222;
            color: #fff;
            border-radius: 4px;
            padding: 4px 8px;
            cursor: pointer;
            font-family: sans-serif;
            font-size: 12px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.3);
            display: none;
            user-select: none;
            transition: opacity 0.2s;
            pointer-events: auto;
        }
        #tm-hl-btn:hover { background: #000; }
        .tm-hl-opt { display: inline-block; cursor: pointer; margin: 0 4px; vertical-align: middle; }
        .tm-color-dot { width: 12px; height: 12px; border-radius: 50%; border: 1px solid #fff; transition: transform 0.1s; }
        .tm-color-dot:hover { transform: scale(1.2); }
        #tm-hl-btn::after {
            content: '';
            position: absolute;
            top: 100%;
            left: 50%;
            margin-left: -5px;
            border-width: 5px;
            border-style: solid;
            border-color: #222 transparent transparent transparent;
        }
        .tm-comment-marker {
            position: fixed;
            cursor: pointer; font-size: 10px; line-height: 1;
            text-shadow: 0 0 2px #fff;
            z-index: ${UI_Z_INDEX - 1};
            pointer-events: auto;
        }
        .tm-comment-marker:hover::after {
            content: attr(data-comment);
            position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%);
            background: #222; color: #fff; padding: 4px 8px; border-radius: 4px;
            font-size: 12px; white-space: nowrap; pointer-events: none;
            box-shadow: 0 2px 5px rgba(0,0,0,0.3);
        }
    `;
    document.head.appendChild(style);

    // --- Persistence ---
    function loadHighlights() {
        const data = GM_getValue(STORAGE_KEY, []);
        return Array.isArray(data) ? data : [];
    }

    function saveHighlights() {
        GM_setValue(STORAGE_KEY, highlights);
    }

    // --- Core Logic: Render Highlights ---
    // We use CSS.highlights (Performance + React Safety)
    function renderHighlights() {
        if (!CSS.highlights) return;

        const buckets = {};

        highlights.forEach((h, index) => {
            try {
                const range = deserializeRange(h);
                if (range) {
                    const baseColor = h.color || 'tm-yellow';
                    const layer = index % 5;
                    const bucketKey = `${baseColor}-${layer}`;
                    if (!buckets[bucketKey]) buckets[bucketKey] = [];
                    buckets[bucketKey].push(range);
                }
            } catch (e) { }
        });

        CSS.highlights.clear();
        for (const [name, ranges] of Object.entries(buckets)) {
            CSS.highlights.set(name, new Highlight(...ranges));
        }
        renderMarkers();
    }

    // Add this line to expose data to the console
    window.getHighlights = () => highlights;


    function renderMarkers() {
        document.querySelectorAll('.tm-comment-marker').forEach(el => el.remove());
        highlights.forEach(h => {
            if (!h.comment) return;
            try {
                const range = deserializeRange(h);
                if (!range) return;
                const rects = range.getClientRects();
                if (!rects.length) return;
                const last = rects[rects.length - 1];

                const marker = document.createElement('span');
                marker.className = 'tm-comment-marker';
                marker.textContent = '💬';
                marker.dataset.comment = h.comment;
                // Fixed so getClientRects() coords are directly usable; repositioned on scroll.
                marker.style.cssText = `left:${Math.round(last.right)}px;top:${Math.round(last.top)}px;`;
                document.body.appendChild(marker);
            } catch (e) { }
        });
    }
    // --- DOM / Range Utilities ---

    function getCssSelector(el) {
        if (!el || el.nodeType !== 1) return 'body';
        if (el === document.body) return 'body';
        if (el.id) {
            const fast = `#${CSS.escape(el.id)}`;
            if (document.querySelectorAll(fast).length === 1) return fast;
        }
        const path = [];
        let cur = el;
        while (cur && cur !== document.body && cur.nodeType === 1) {
            if (cur.id) {
                path.unshift(`#${CSS.escape(cur.id)}`);
                break;
            }
            let part = cur.tagName.toLowerCase();
            if (cur.parentElement) {
                const idx = Array.prototype.indexOf.call(cur.parentElement.children, cur) + 1;
                part += `:nth-child(${idx})`;
            }
            path.unshift(part);
            cur = cur.parentElement;
            if (document.querySelectorAll(path.join(' > ')).length === 1) break;
        }
        return path.join(' > ') || 'body';
    }

    function serializeRange(range) {
        const startNode = range.startContainer;
        const endNode   = range.endContainer;

        // Serialize as a character offset within the nearest element ancestor.
        // This is immune to text-node splitting from insertNode / framework hydration.
        const getCharInfo = (node, offsetInNode) => {
            const parent = node.nodeType === 3 ? node.parentElement : node;
            const walker = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT, null);
            let chars = 0, n;
            while ((n = walker.nextNode())) {
                if (n === node) { chars += offsetInNode; break; }
                chars += n.textContent.length;
            }
            return { selector: getCssSelector(parent), charOffset: chars };
        };

        const start = getCharInfo(startNode, range.startOffset);
        const end   = getCharInfo(endNode,   range.endOffset);

        const element = startNode.nodeType === 3 ? startNode.parentElement : startNode;
        const bgColor = getRealBackgroundColor(element);
        const contrastColor = getSmartColor(bgColor);

        return {
            id: Date.now().toString(36) + Math.random().toString(36).substr(2),
            startSelector:   start.selector,
            startCharOffset: start.charOffset,
            endSelector:     end.selector,
            endCharOffset:   end.charOffset,
            text:  range.toString(),
            color: contrastColor
        };
    }

    function deserializeRange(h) {
        const startParent = document.querySelector(h.startSelector);
        const endParent   = document.querySelector(h.endSelector);
        if (!startParent || !endParent) return null;

        const resolvePoint = (parent, charOffset, legacyIndex, legacyOffset) => {
            if (charOffset !== undefined) {
                // New format: walk text nodes counting characters
                const walker = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT, null);
                let remaining = charOffset, n;
                while ((n = walker.nextNode())) {
                    if (remaining <= n.textContent.length) return { node: n, offset: remaining };
                    remaining -= n.textContent.length;
                }
                return null;
            }
            // Legacy format: text-node by childNodes index
            const node = legacyIndex === -1 ? parent : parent.childNodes[legacyIndex];
            if (!node) return null;
            return { node, offset: legacyOffset };
        };

        const start = resolvePoint(startParent, h.startCharOffset, h.startIndex, h.startOffset);
        const end   = resolvePoint(endParent,   h.endCharOffset,   h.endIndex,   h.endOffset);
        if (!start || !end) return null;

        try {
            const range = document.createRange();
            range.setStart(start.node, start.offset);
            range.setEnd(end.node,     end.offset);
            return range;
        } catch (e) {
            return null;
        }
    }

    // --- Smart Contrast Logic ---
    function getRealBackgroundColor(elem) {
        while (elem && elem.nodeType === 1) {
            const style = window.getComputedStyle(elem);
            const color = style.backgroundColor;
            if (color && color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent') {
                return color;
            }
            elem = elem.parentElement;
        }
        return 'rgb(255, 255, 255)'; // Default to white if no bg found
    }

    function getThemeKey(rgbaString) {
        if (!rgbaString || rgbaString === 'rgba(0, 0, 0, 0)') return 'light';
        const rgba = rgbaString.match(/\d+/g);
        if (!rgba) return 'light';
        const r = parseInt(rgba[0]), g = parseInt(rgba[1]), b = parseInt(rgba[2]);
        const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;

        if (luminance < 0.5) return 'dark';
        if (r > 200 && g > 200 && b < 100) return 'yellowish';
        return 'light';
    }

    function getSmartColor(rgbaString) {
        const theme = getThemeKey(rgbaString);
        // Check preferences first
        if (colorPrefs[theme]) return colorPrefs[theme];
        // Defaults
        if (theme === 'dark') return 'tm-cyan';
        if (theme === 'yellowish') return 'tm-pink';
        return 'tm-yellow';
    }

    // --- UI Logic ---

    function createButton() {
        const btn = document.createElement('div');
        btn.id = 'tm-hl-btn';
        // Right-click to dismiss
        btn.addEventListener('contextmenu', (e) => {
            e.preventDefault();
            hideButton();
        });

        document.body.appendChild(btn);
        hoverButton = btn;
        return btn;
    }

    function showButton(x, y, type, actionCallback, highlightIndex = -1) {
        let btn = document.getElementById('tm-hl-btn');
        if (!btn) btn = createButton();
        btn.onclick = null; // Remove old listener
        btn.innerHTML = ''; // Clear content

        // Main Action Icon (Add or Trash)
        const mainIcon = document.createElement('span');
        mainIcon.className = 'tm-hl-opt';
        mainIcon.innerHTML = type === 'add' ? '🖊️' : '🗑️';
        mainIcon.title = type === 'add' ? 'Highlight' : 'Remove';
        mainIcon.onclick = (e) => {
            e.preventDefault(); e.stopPropagation();
            actionCallback();
            hideButton();
        };
        btn.appendChild(mainIcon);

        // If in 'remove' mode (editing existing), show color options
        if (type === 'remove' && highlightIndex !== -1) {
            const commentBtn = document.createElement('span');
            commentBtn.className = 'tm-hl-opt';
            commentBtn.innerHTML = '💬';
            commentBtn.title = 'Add/Edit Comment';
            commentBtn.onclick = (e) => {
                e.preventDefault(); e.stopPropagation();
                const newComment = prompt('Edit comment:', highlights[highlightIndex].comment || '');
                if (newComment !== null) {
                    highlights[highlightIndex].comment = newComment;
                    saveHighlights();
                    renderHighlights();
                    hideButton();
                }
            };
            btn.appendChild(commentBtn);

            const colors = [
                { name: 'tm-yellow', hex: '#FFD600' },
                { name: 'tm-cyan', hex: '#00E5FF' },
                { name: 'tm-pink', hex: '#FF4081' }
            ];

            colors.forEach(c => {
                const dot = document.createElement('span');
                dot.className = 'tm-hl-opt tm-color-dot';
                dot.style.backgroundColor = c.hex;
                dot.title = 'Change color & set as default for this theme';
                dot.onclick = (e) => {
                    e.preventDefault(); e.stopPropagation();

                    // 1. Update Highlight Color
                    highlights[highlightIndex].color = c.name;

                    // 2. Update Preference for this Theme
                    try {
                        const h = highlights[highlightIndex];
                        const el = document.querySelector(h.startSelector);
                        if (el) {
                            const node = el.nodeType === 3 ? el.parentElement : el;
                            const bg = getRealBackgroundColor(node);
                            const theme = getThemeKey(bg);
                            colorPrefs[theme] = c.name;
                            GM_setValue('tm_color_prefs', colorPrefs);
                        }
                    } catch(err) { console.error(err); }

                    saveHighlights();
                    renderHighlights();
                    hideButton();
                };
                btn.appendChild(dot);
            });
        }

        btn.style.display = 'block';

        // Positioning logic (Fixed position, ignore scrollY)
        const rect = btn.getBoundingClientRect();
        let top = y - 40;
        let left = x - (rect.width / 2);
        if (top < 0) top = y + 20;

        btn.style.top = `${top}px`;
        btn.style.left = `${left}px`;
    }

    function hideButton() {
        const btn = document.getElementById('tm-hl-btn');
        if (btn) btn.style.display = 'none';
        isMenuOpen = false;
    }

    // --- Event Listeners ---


// 1. Text Selection (Add Highlight)
    document.addEventListener('mouseup', (e) => {
        if (e.button !== 0) return; // Ignore right-clicks
        // Wait slightly for selection to finalize


        setTimeout(() => {
            const selection = window.getSelection();
            if (selection.isCollapsed || selection.rangeCount === 0) {
                // If no selection, check if we clicked an existing highlight
                checkHighlightClick(e);
                return;
            }

            const range = selection.getRangeAt(0);
            const text = range.toString().trim();
            if (text.length < 1) return;

            // Use cursor position
            showButton(e.clientX, e.clientY, 'add', () => {
                const hData = serializeRange(range);
                highlights.push(hData);
                saveHighlights();
                renderHighlights();
                window.getSelection().removeAllRanges();
            });
        }, 10);
    });

    // 2. Click on Existing Highlight (Remove)
    function checkHighlightClick(e) {
        if (!CSS.highlights) return;

        // Hide button if clicking elsewhere
        if (isMenuOpen && e.target.id !== 'tm-hl-btn') {
            hideButton();
            return;
        }

        // Hit testing for Custom Highlights is tricky because they aren't DOM elements.
        // We use caretRangeFromPoint to get the text node under cursor.
        let range;
        if (document.caretRangeFromPoint) {
            range = document.caretRangeFromPoint(e.clientX, e.clientY);
        } else if (document.caretPositionFromPoint) {
            // Firefox specific
            const pos = document.caretPositionFromPoint(e.clientX, e.clientY);
            if (pos) {
                range = document.createRange();
                range.setStart(pos.offsetNode, pos.offset);
                range.setEnd(pos.offsetNode, pos.offset);
            }
        }

        if (!range) return;

        const node = range.startContainer;
        const offset = range.startOffset;

        // Check if this point overlaps with any stored highlight
        const matches = [];
        highlights.forEach((h, index) => {
            try {
                const hRange = deserializeRange(h);
                if (hRange && hRange.isPointInRange(node, offset)) {
                    matches.push({ index: index, length: h.text.length });
                }
            } catch(e) {}
        });

        if (matches.length > 0) {
            // Sort: 1. Smallest Text Length (Specific) -> 2. Newest (Highest Index)
            matches.sort((a, b) => a.length - b.length || b.index - a.index);
            const targetIndex = matches[0].index;

            showButton(e.clientX, e.clientY, 'remove', () => {
                highlights.splice(targetIndex, 1);
                saveHighlights();
                renderHighlights();
            }, targetIndex);
            isMenuOpen = true;
        } else {
            hideButton();
        }
    }

    // --- Dynamic Content Handling ---
    // The MutationObserver ensures highlights reappear if React re-renders the DOM
    let debounceTimer;
    const isMarkerNode = n => n.nodeType === 1 && n.classList && n.classList.contains('tm-comment-marker');

    const observer = new MutationObserver((mutations) => {
        // Ignore batches that are purely our own marker add/remove operations
        const isInternalOnly = mutations.every(m => {
            const added   = Array.from(m.addedNodes);
            const removed = Array.from(m.removedNodes);
            if (added.length === 0 && removed.length === 0) return true; // attribute / nothing
            return added.every(isMarkerNode) && removed.every(isMarkerNode);
        });
        if (isInternalOnly) return;

        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(renderHighlights, DEBOUNCE_MS);
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
        // characterData intentionally omitted — fires on every keystroke and causes render storms
    });

    // Reposition fixed comment markers after scroll/resize
    let markerScrollTimer;
    window.addEventListener('scroll', () => {
        clearTimeout(markerScrollTimer);
        markerScrollTimer = setTimeout(renderMarkers, 150);
    }, { passive: true, capture: true });
    window.addEventListener('resize', renderMarkers, { passive: true });

    // Initial Render
    renderHighlights();

    // Menu Command to Clear All
    GM_registerMenuCommand("Clear All Highlights", () => {
        if(confirm("Remove all highlights for this page?")) {
            highlights = [];
            saveHighlights();
            renderHighlights();
        }
    });

    GM_registerMenuCommand("Export Highlights to Console", () => {
    console.log("--- CURRENT HIGHLIGHTS ---");
    console.table(highlights);
});

})();