// ==UserScript== // @name Em Dash Highlighter // @namespace Reichard // @version 1.0.0 // @description Detects em dashes on the page, highlights them, and shows a popup on hover/tap // @author Evan Reichard // @match *://*/* // @grant GM_addStyle // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ─── Constants ──────────────────────────────────────────────────────────── const EM_DASH = '\u2014'; const EMDASH_REGEX = /\u2014/g; const PROCESSED_ATTR = 'data-emdash-processed'; const POPUP_ID = 'emdash-popup'; // Tags whose text content we should never touch const SKIP_TAGS = new Set([ 'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT', 'BUTTON', 'PRE', 'SVG', 'MATH', ]); // ─── Styles ─────────────────────────────────────────────────────────────── GM_addStyle(` .emdash-highlight { display: inline; padding: 0 1px; border-radius: 2px; cursor: help; animation: emdash-hue 4s linear infinite; background-color: hsl(0, 80%, 70%); position: relative; } @keyframes emdash-hue { 0% { background-color: hsl(0, 80%, 70%); } 25% { background-color: hsl(90, 80%, 70%); } 50% { background-color: hsl(180, 80%, 70%); } 75% { background-color: hsl(270, 80%, 70%); } 100% { background-color: hsl(360, 80%, 70%); } } #${POPUP_ID} { position: fixed; z-index: 2147483647; background: #1e1e2e; color: #cdd6f4; border: 1px solid #89b4fa; border-radius: 6px; padding: 6px 10px; font: 13px/1.4 monospace; pointer-events: none; white-space: nowrap; box-shadow: 0 4px 12px rgba(0,0,0,0.5); opacity: 0; transition: opacity 0.15s ease; max-width: 280px; white-space: normal; } #${POPUP_ID}.visible { opacity: 1; } `); // ─── Popup singleton ────────────────────────────────────────────────────── const popup = document.createElement('div'); popup.id = POPUP_ID; popup.innerHTML = ` Em Dash — U+2014 `; document.documentElement.appendChild(popup); let hideTimer = null; function showPopup(anchorEl) { clearTimeout(hideTimer); const rect = anchorEl.getBoundingClientRect(); const popupHeight = popup.offsetHeight || 80; const popupWidth = popup.offsetWidth || 220; let top = rect.top - popupHeight - 8; let left = rect.left + rect.width / 2 - popupWidth / 2; // Flip below if not enough room above if (top < 4) top = rect.bottom + 8; // Clamp horizontally left = Math.max(6, Math.min(left, window.innerWidth - popupWidth - 6)); popup.style.top = `${top}px`; popup.style.left = `${left}px`; popup.classList.add('visible'); } function hidePopup(delay = 120) { hideTimer = setTimeout(() => popup.classList.remove('visible'), delay); } // ─── Span factory ───────────────────────────────────────────────────────── function createSpan() { const span = document.createElement('span'); span.className = 'emdash-highlight'; span.setAttribute('aria-label', 'Em dash character'); span.textContent = EM_DASH; // Desktop hover span.addEventListener('mouseenter', () => showPopup(span)); span.addEventListener('mouseleave', () => hidePopup()); // Mobile tap — show/hide without bubbling span.addEventListener('touchend', (e) => { e.stopPropagation(); e.preventDefault(); if (popup.classList.contains('visible')) { hidePopup(0); } else { showPopup(span); // Auto-hide after 3 s on mobile hideTimer = setTimeout(() => popup.classList.remove('visible'), 3000); } }, { passive: false }); // Prevent click bubbling (e.g. accordion toggles, links) span.addEventListener('click', (e) => { e.stopPropagation(); }); return span; } // ─── Core processor ─────────────────────────────────────────────────────── /** * Walk a DOM subtree and wrap every em dash in a text node with a span. * We operate on Text nodes directly to avoid serialising/deserialising HTML. */ function processNode(root) { // TreeWalker is faster than querySelectorAll for text nodes const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode(node) { const parent = node.parentElement; if (!parent) return NodeFilter.FILTER_REJECT; if (SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT; if (parent.isContentEditable) return NodeFilter.FILTER_REJECT; if (parent.classList.contains('emdash-highlight')) return NodeFilter.FILTER_REJECT; if (!node.nodeValue.includes(EM_DASH)) return NodeFilter.FILTER_SKIP; return NodeFilter.FILTER_ACCEPT; }, } ); // Collect first — mutating the DOM while walking breaks the walker const textNodes = []; let node; while ((node = walker.nextNode())) textNodes.push(node); for (const textNode of textNodes) { splitAndWrap(textNode); } } /** * Split a text node around em dashes and replace it with a fragment * containing plain text nodes and highlight spans. */ function splitAndWrap(textNode) { const text = textNode.nodeValue; const parts = text.split(EMDASH_REGEX); if (parts.length <= 1) return; // no em dash found const fragment = document.createDocumentFragment(); parts.forEach((part, i) => { if (part) fragment.appendChild(document.createTextNode(part)); if (i < parts.length - 1) fragment.appendChild(createSpan()); }); textNode.parentNode.replaceChild(fragment, textNode); } // ─── Mutation observer ──────────────────────────────────────────────────── const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'characterData') { // Text node changed in place const node = mutation.target; if ( node.nodeValue?.includes(EM_DASH) && node.parentElement && !SKIP_TAGS.has(node.parentElement.tagName) && !node.parentElement.classList.contains('emdash-highlight') ) { splitAndWrap(node); } } else if (mutation.type === 'childList') { for (const added of mutation.addedNodes) { if (added.nodeType === Node.TEXT_NODE) { if ( added.nodeValue?.includes(EM_DASH) && added.parentElement && !SKIP_TAGS.has(added.parentElement.tagName) && !added.parentElement.classList.contains('emdash-highlight') ) { splitAndWrap(added); } } else if (added.nodeType === Node.ELEMENT_NODE) { processNode(added); } } } } }); // ─── Global tap-outside to dismiss popup ────────────────────────────────── document.addEventListener('touchstart', () => hidePopup(0), { passive: true }); // ─── Init ───────────────────────────────────────────────────────────────── function init() { processNode(document.body); observer.observe(document.body, { childList: true, subtree: true, characterData: true, }); } // document-idle fires after DOMContentLoaded, but guard anyway if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();