249 lines
8.3 KiB
JavaScript
249 lines
8.3 KiB
JavaScript
// ==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 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();
|
|
}
|
|
})(); |