Last active
February 25, 2026 18:56
-
-
Save evanreichard/1a7351d9144935952c23cfeafb83660c to your computer and use it in GitHub Desktop.
emdash-highlighter.user.js
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 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 = ` | |
| <strong style="color:#89b4fa">Em Dash</strong> — 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(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment