Move userscripts into scripts/ subfolder
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
// ==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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user