Skip to content

Instantly share code, notes, and snippets.

@evanreichard
Last active February 25, 2026 18:56
Show Gist options
  • Select an option

  • Save evanreichard/1a7351d9144935952c23cfeafb83660c to your computer and use it in GitHub Desktop.

Select an option

Save evanreichard/1a7351d9144935952c23cfeafb83660c to your computer and use it in GitHub Desktop.
emdash-highlighter.user.js
// ==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> &mdash; 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