Skip to content

Instantly share code, notes, and snippets.

@DiyarD
Last active December 23, 2025 23:57
Show Gist options
  • Select an option

  • Save DiyarD/3eb78190dfd1e6b5dea429f750e08fd1 to your computer and use it in GitHub Desktop.

Select an option

Save DiyarD/3eb78190dfd1e6b5dea429f750e08fd1 to your computer and use it in GitHub Desktop.
Highlight text on any site. Smart contrast, persistent, minimal UI.
// ==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