Last active
December 23, 2025 23:57
-
-
Save DiyarD/3eb78190dfd1e6b5dea429f750e08fd1 to your computer and use it in GitHub Desktop.
Highlight text on any site. Smart contrast, persistent, minimal UI.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Smart Text Highlighter | |
| // @namespace http://tampermonkey.net/ | |
| // @version 1.4 | |
| // @description Highlight text on any site (even dynamic SPAs like React). Smart contrast, persistent, minimal UI, commenting. | |
| // @author Diyar Baban | |
| // @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(); | |
| 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 { | |
| display: inline-block; margin-left: 2px; | |
| cursor: pointer; font-size: 10px; line-height: 1; | |
| vertical-align: super; text-shadow: 0 0 2px #fff; | |
| position: relative; z-index: 10; | |
| } | |
| .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; | |
| // Remove existing markers first to ensure clean indices for deserialization | |
| document.querySelectorAll('.tm-comment-marker').forEach(el => el.remove()); | |
| 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(); | |
| } | |
| function renderMarkers() { | |
| highlights.forEach(h => { | |
| if (!h.comment) return; | |
| try { | |
| const range = deserializeRange(h); | |
| if (!range) return; | |
| const marker = document.createElement('span'); | |
| marker.className = 'tm-comment-marker'; | |
| marker.textContent = '💬'; | |
| marker.dataset.comment = h.comment; | |
| const markerRange = range.cloneRange(); | |
| markerRange.collapse(false); | |
| markerRange.insertNode(marker); | |
| } catch (e) { } | |
| }); | |
| } | |
| // --- DOM / Range Utilities --- | |
| function getCssSelector(el) { | |
| if (!el || el.nodeType !== 1) return 'body'; | |
| if (el.id) return `#${CSS.escape(el.id)}`; | |
| const path = []; | |
| while (el.nodeType === 1 && el !== document.body) { | |
| let selector = el.tagName.toLowerCase(); | |
| if (el.parentElement) { | |
| const siblings = el.parentElement.children; | |
| if (siblings.length > 1) { | |
| let index = 1; | |
| for (let i = 0; i < siblings.length; i++) { | |
| if (siblings[i] === el) { | |
| selector += `:nth-child(${index})`; | |
| break; | |
| } | |
| index++; | |
| } | |
| } | |
| } | |
| path.unshift(selector); | |
| el = el.parentElement; | |
| } | |
| return path.join(' > '); | |
| } | |
| function serializeRange(range) { | |
| const startNode = range.startContainer; | |
| const endNode = range.endContainer; | |
| // We store the parent element selector and the index of the text node within it | |
| // This is robust against text node splitting (common in hydration) | |
| const getPath = (node) => { | |
| const parent = node.nodeType === 3 ? node.parentElement : node; | |
| const textNodeIndex = node.nodeType === 3 | |
| ? Array.prototype.indexOf.call(parent.childNodes, node) | |
| : -1; | |
| return { | |
| selector: getCssSelector(parent), | |
| textNodeIndex: textNodeIndex | |
| }; | |
| }; | |
| const start = getPath(startNode); | |
| const end = getPath(endNode); | |
| // Smart Contrast Detection | |
| 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, | |
| startIndex: start.textNodeIndex, | |
| startOffset: range.startOffset, | |
| endSelector: end.selector, | |
| endIndex: end.textNodeIndex, | |
| endOffset: range.endOffset, | |
| 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 startNode = h.startIndex === -1 ? startParent : startParent.childNodes[h.startIndex]; | |
| const endNode = h.endIndex === -1 ? endParent : endParent.childNodes[h.endIndex]; | |
| if (!startNode || !endNode) return null; | |
| // Simple validation: check if text vaguely matches to avoid highlighting wrong things after update | |
| // (Optional strictness) | |
| const range = document.createRange(); | |
| range.setStart(startNode, h.startOffset); | |
| range.setEnd(endNode, h.endOffset); | |
| return range; | |
| } | |
| // --- 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 observer = new MutationObserver((mutations) => { | |
| const isInternal = mutations.every(m => | |
| Array.from(m.addedNodes).every(n => n.nodeType === 1 && n.classList.contains('tm-comment-marker')) && | |
| Array.from(m.removedNodes).every(n => n.nodeType === 1 && n.classList.contains('tm-comment-marker')) | |
| ); | |
| if (isInternal) return; | |
| clearTimeout(debounceTimer); | |
| debounceTimer = setTimeout(() => { | |
| renderHighlights(); | |
| }, DEBOUNCE_MS); | |
| }); | |
| observer.observe(document.body, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true // in case text content updates | |
| }); | |
| // Initial Render | |
| renderHighlights(); | |
| // Menu Command to Clear All | |
| GM_registerMenuCommand("Clear All Highlights", () => { | |
| if(confirm("Remove all highlights for this page?")) { | |
| highlights = []; | |
| saveHighlights(); | |
| renderHighlights(); | |
| } | |
| }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment