Greasy Fork is available in English.
Highlight text on any site (even dynamic SPAs like React). Smart contrast, persistent, minimal UI.
// ==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);
});
})();