Smart Text Highlighter

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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);
});

})();