370 lines
15 KiB
JavaScript
370 lines
15 KiB
JavaScript
/* selection-popup.js — Custom text-selection toolbar.
|
|
Appears automatically after a short delay on any non-empty selection.
|
|
Adapts its buttons based on the context of the selection:
|
|
|
|
code (known lang) → Copy · [MDN / Hoogle / Docs…]
|
|
code (unknown) → Copy
|
|
math → Copy · nLab · OEIS · Wolfram
|
|
prose (multi-word) → Annotate* · BibTeX · Copy · DuckDuckGo · Here · Wikipedia
|
|
prose (one word) → Annotate* · BibTeX · Copy · Define · DuckDuckGo · Here · Wikipedia
|
|
|
|
(* = placeholder, not yet wired)
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var SHOW_DELAY = 450;
|
|
|
|
var popup = null;
|
|
var showTimer = null;
|
|
|
|
/* ------------------------------------------------------------------
|
|
Documentation providers keyed by Prism language identifier.
|
|
Label: short button text. url: base search URL (query appended).
|
|
------------------------------------------------------------------ */
|
|
|
|
var DOC_PROVIDERS = {
|
|
'javascript': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' },
|
|
'typescript': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' },
|
|
'jsx': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' },
|
|
'tsx': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' },
|
|
'html': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' },
|
|
'css': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' },
|
|
'haskell': { label: 'Hoogle', url: 'https://hoogle.haskell.org/?hoogle=' },
|
|
'python': { label: 'Docs', url: 'https://docs.python.org/3/search.html?q=' },
|
|
'rust': { label: 'Docs', url: 'https://doc.rust-lang.org/std/?search=' },
|
|
'c': { label: 'Docs', url: 'https://en.cppreference.com/mwiki/index.php?search=' },
|
|
'cpp': { label: 'Docs', url: 'https://en.cppreference.com/mwiki/index.php?search=' },
|
|
'java': { label: 'Docs', url: 'https://docs.oracle.com/en/java/javase/21/docs/api/search.html?q=' },
|
|
'go': { label: 'Docs', url: 'https://pkg.go.dev/search?q=' },
|
|
'ruby': { label: 'Docs', url: 'https://ruby-doc.org/core/search?q=' },
|
|
'r': { label: 'Docs', url: 'https://www.rdocumentation.org/search?q=' },
|
|
'lua': { label: 'Docs', url: 'https://www.lua.org/search.html?q=' },
|
|
'scala': { label: 'Docs', url: 'https://docs.scala-lang.org/search/?q=' },
|
|
};
|
|
|
|
/* ------------------------------------------------------------------
|
|
Init
|
|
------------------------------------------------------------------ */
|
|
|
|
function init() {
|
|
popup = document.createElement('div');
|
|
popup.className = 'selection-popup';
|
|
popup.setAttribute('role', 'toolbar');
|
|
popup.setAttribute('aria-label', 'Text selection options');
|
|
document.body.appendChild(popup);
|
|
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
document.addEventListener('keyup', onKeyUp);
|
|
document.addEventListener('mousedown', onMouseDown);
|
|
document.addEventListener('keydown', onKeyDown);
|
|
window.addEventListener('scroll', hide, { passive: true });
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Event handlers
|
|
------------------------------------------------------------------ */
|
|
|
|
function onMouseUp(e) {
|
|
if (popup.contains(e.target)) return;
|
|
clearTimeout(showTimer);
|
|
showTimer = setTimeout(tryShow, SHOW_DELAY);
|
|
}
|
|
|
|
function onKeyUp(e) {
|
|
if (e.shiftKey || e.key === 'End' || e.key === 'Home') {
|
|
clearTimeout(showTimer);
|
|
showTimer = setTimeout(tryShow, SHOW_DELAY);
|
|
}
|
|
}
|
|
|
|
function onMouseDown(e) {
|
|
if (popup.contains(e.target)) return;
|
|
hide();
|
|
}
|
|
|
|
function onKeyDown(e) {
|
|
if (e.key === 'Escape') hide();
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Context detection
|
|
------------------------------------------------------------------ */
|
|
|
|
function getContext(sel) {
|
|
if (!sel.rangeCount) return 'prose';
|
|
var range = sel.getRangeAt(0);
|
|
var node = range.commonAncestorContainer;
|
|
var el = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node;
|
|
if (!el) return 'prose';
|
|
|
|
if (el.closest('pre, code, .sourceCode, .highlight')) return 'code';
|
|
if (el.closest('.math, .katex, .katex-html, .katex-display')) return 'math';
|
|
|
|
/* Fallback: commonAncestorContainer can land at <p> when selection
|
|
starts/ends just outside a KaTeX span — check via intersectsNode. */
|
|
var mathEls = document.querySelectorAll('.math');
|
|
for (var i = 0; i < mathEls.length; i++) {
|
|
if (range.intersectsNode(mathEls[i])) return 'math';
|
|
}
|
|
|
|
return 'prose';
|
|
}
|
|
|
|
/* Returns the Prism language identifier for the code block containing
|
|
the current selection, or null if the language is not annotated. */
|
|
function getCodeLanguage(sel) {
|
|
if (!sel.rangeCount) return null;
|
|
var node = sel.getRangeAt(0).commonAncestorContainer;
|
|
var el = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node;
|
|
if (!el) return null;
|
|
/* Prism puts language-* on the <code> element; our Code.hs filter
|
|
ensures the class is always present when a language is specified. */
|
|
var code = el.closest('code[class*="language-"]');
|
|
if (!code) return null;
|
|
var m = code.className.match(/language-(\w+)/);
|
|
return m ? m[1].toLowerCase() : null;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Core logic
|
|
------------------------------------------------------------------ */
|
|
|
|
function tryShow() {
|
|
var sel = window.getSelection();
|
|
var text = sel ? sel.toString().trim() : '';
|
|
|
|
if (!text || text.length < 2 || !sel.rangeCount) { hide(); return; }
|
|
|
|
var range = sel.getRangeAt(0);
|
|
var rect = range.getBoundingClientRect();
|
|
if (!rect.width && !rect.height) { hide(); return; }
|
|
var context = getContext(sel);
|
|
var oneWord = isSingleWord(text);
|
|
var codeLang = (context === 'code') ? getCodeLanguage(sel) : null;
|
|
|
|
popup.innerHTML = buildHTML(context, oneWord, codeLang);
|
|
popup.style.visibility = 'hidden';
|
|
popup.classList.add('is-visible');
|
|
|
|
position(rect);
|
|
popup.style.visibility = '';
|
|
bindActions(text);
|
|
}
|
|
|
|
function hide() {
|
|
clearTimeout(showTimer);
|
|
if (popup) popup.classList.remove('is-visible');
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Positioning — centred above selection, flip below if needed
|
|
------------------------------------------------------------------ */
|
|
|
|
function position(rect) {
|
|
var pw = popup.offsetWidth;
|
|
var ph = popup.offsetHeight;
|
|
var GAP = 10;
|
|
var sy = window.scrollY;
|
|
var sx = window.scrollX;
|
|
var vw = window.innerWidth;
|
|
|
|
var left = rect.left + sx + rect.width / 2 - pw / 2;
|
|
left = Math.max(sx + GAP, Math.min(left, sx + vw - pw - GAP));
|
|
|
|
var top = rect.top + sy - ph - GAP;
|
|
if (top < sy + GAP) {
|
|
top = rect.bottom + sy + GAP;
|
|
popup.classList.add('is-below');
|
|
} else {
|
|
popup.classList.remove('is-below');
|
|
}
|
|
|
|
popup.style.left = left + 'px';
|
|
popup.style.top = top + 'px';
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Helpers
|
|
------------------------------------------------------------------ */
|
|
|
|
function isSingleWord(text) {
|
|
return !/\s/.test(text);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
HTML builder — context-aware button sets
|
|
------------------------------------------------------------------ */
|
|
|
|
function buildHTML(context, oneWord, codeLang) {
|
|
if (context === 'code') {
|
|
var provider = codeLang ? DOC_PROVIDERS[codeLang] : null;
|
|
return btn('copy', 'Copy')
|
|
+ (provider ? docsBtn(provider) : '');
|
|
}
|
|
|
|
if (context === 'math') {
|
|
/* Alphabetical: Copy · nLab · OEIS · Wolfram */
|
|
return btn('copy', 'Copy')
|
|
+ btn('nlab', 'nLab')
|
|
+ btn('oeis', 'OEIS')
|
|
+ btn('wolfram', 'Wolfram');
|
|
}
|
|
|
|
/* Prose — alphabetical: BibTeX · Copy · [Define] · DuckDuckGo · Here · Wikipedia */
|
|
return btn('cite', 'BibTeX')
|
|
+ btn('copy', 'Copy')
|
|
+ (oneWord ? btn('define', 'Define') : '')
|
|
+ btn('search', 'DuckDuckGo')
|
|
+ btn('here', 'Here')
|
|
+ btn('wikipedia', 'Wikipedia');
|
|
}
|
|
|
|
function btn(action, label, placeholder) {
|
|
var cls = 'selection-popup-btn' + (placeholder ? ' selection-popup-btn--placeholder' : '');
|
|
var extra = placeholder ? ' aria-disabled="true" title="Coming soon"' : '';
|
|
return '<button class="' + cls + '" data-action="' + action + '"' + extra + '>'
|
|
+ label + '</button>';
|
|
}
|
|
|
|
|
|
/* Docs button embeds the base URL so dispatch can read it without a lookup. */
|
|
function docsBtn(provider) {
|
|
return '<button class="selection-popup-btn" data-action="docs"'
|
|
+ ' data-docs-url="' + provider.url + '">'
|
|
+ provider.label + '</button>';
|
|
}
|
|
|
|
|
|
/* ------------------------------------------------------------------
|
|
Action bindings
|
|
------------------------------------------------------------------ */
|
|
|
|
function bindActions(text) {
|
|
popup.querySelectorAll('[data-action]').forEach(function (el) {
|
|
if (el.getAttribute('aria-disabled') === 'true') return;
|
|
el.addEventListener('click', function () {
|
|
dispatch(el.getAttribute('data-action'), text, el);
|
|
hide();
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
BibTeX/BibLaTeX builder for the Cite action
|
|
------------------------------------------------------------------ */
|
|
|
|
/* Escape LaTeX special characters in a BibTeX field value. */
|
|
function escBib(s) {
|
|
return String(s)
|
|
.replace(/\\/g, '\\textbackslash{}')
|
|
.replace(/[#$%&_{}]/g, function (c) { return '\\' + c; })
|
|
.replace(/~/g, '\\textasciitilde{}')
|
|
.replace(/\^/g, '\\textasciicircum{}');
|
|
}
|
|
|
|
/* "Levi Neuwirth" → "Neuwirth, Levi" */
|
|
function toBibAuthor(name) {
|
|
var parts = name.trim().split(/\s+/);
|
|
if (parts.length < 2) return name;
|
|
return parts[parts.length - 1] + ', ' + parts.slice(0, -1).join(' ');
|
|
}
|
|
|
|
function buildBibTeX(selectedText) {
|
|
/* Title — h1.page-title is most reliable; fall back to document.title */
|
|
var titleEl = document.querySelector('h1.page-title');
|
|
var title = titleEl
|
|
? titleEl.textContent.trim()
|
|
: document.title.split(' \u2014 ')[0].trim();
|
|
|
|
/* Author(s) — read from .meta-authors, default to site owner */
|
|
var authorEls = document.querySelectorAll('.meta-authors a');
|
|
var authors = authorEls.length
|
|
? Array.from(authorEls).map(function (a) {
|
|
return toBibAuthor(a.textContent.trim());
|
|
}).join(' and ')
|
|
: 'Neuwirth, Levi';
|
|
|
|
/* Year — scrape from the first version-history entry ("14 March 2026 · Created"),
|
|
fall back to current year. */
|
|
var year = String(new Date().getFullYear());
|
|
var vhEl = document.querySelector('#version-history li');
|
|
if (vhEl) {
|
|
var ym = vhEl.textContent.match(/\b(\d{4})\b/);
|
|
if (ym) year = ym[1];
|
|
}
|
|
|
|
/* Access date */
|
|
var now = new Date();
|
|
var urldate = now.getFullYear() + '-'
|
|
+ String(now.getMonth() + 1).padStart(2, '0') + '-'
|
|
+ String(now.getDate()).padStart(2, '0');
|
|
|
|
/* Citation key: lastname + year + first_content_word_of_title */
|
|
var lastName = authors.split(',')[0].toLowerCase().replace(/[^a-z]/g, '');
|
|
var stopwords = /^(the|and|for|with|from|that|this|into|about|over)$/i;
|
|
var keyWord = title.split(/\s+/).filter(function (w) {
|
|
return w.length > 2 && !stopwords.test(w);
|
|
})[0] || 'untitled';
|
|
keyWord = keyWord.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
var key = lastName + year + keyWord;
|
|
|
|
return [
|
|
'@online{' + key + ',',
|
|
' author = {' + escBib(authors) + '},',
|
|
' title = {' + escBib(title) + '},',
|
|
' year = {' + year + '},',
|
|
' url = {' + window.location.href + '},',
|
|
' urldate = {' + urldate + '},',
|
|
' note = {\\enquote{' + escBib(selectedText) + '}},',
|
|
'}',
|
|
].join('\n');
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Action dispatch
|
|
------------------------------------------------------------------ */
|
|
|
|
function dispatch(action, text, el) {
|
|
var q = encodeURIComponent(text);
|
|
if (action === 'search') {
|
|
window.open('https://duckduckgo.com/?q=' + q, '_blank', 'noopener,noreferrer');
|
|
|
|
} else if (action === 'copy') {
|
|
if (navigator.clipboard) navigator.clipboard.writeText(text).catch(function () {});
|
|
|
|
} else if (action === 'docs') {
|
|
var base = el.getAttribute('data-docs-url');
|
|
if (base) window.open(base + q, '_blank', 'noopener,noreferrer');
|
|
|
|
} else if (action === 'wolfram') {
|
|
window.open('https://www.wolframalpha.com/input?i=' + q, '_blank', 'noopener,noreferrer');
|
|
|
|
} else if (action === 'oeis') {
|
|
window.open('https://oeis.org/search?q=' + q, '_blank', 'noopener,noreferrer');
|
|
|
|
} else if (action === 'nlab') {
|
|
window.open('https://ncatlab.org/nlab/search?query=' + q, '_blank', 'noopener,noreferrer');
|
|
|
|
} else if (action === 'wikipedia') {
|
|
/* Always use Special:Search — never jumps to an article directly,
|
|
so phrases and ambiguous terms always show the results page. */
|
|
window.open('https://en.wikipedia.org/wiki/Special:Search?search=' + q, '_blank', 'noopener,noreferrer');
|
|
|
|
} else if (action === 'define') {
|
|
/* English Wiktionary — only rendered for single-word selections. */
|
|
window.open('https://en.wiktionary.org/wiki/' + q, '_blank', 'noopener,noreferrer');
|
|
|
|
} else if (action === 'cite') {
|
|
var citation = buildBibTeX(text);
|
|
if (navigator.clipboard) navigator.clipboard.writeText(citation).catch(function () {});
|
|
|
|
} else if (action === 'here') {
|
|
/* Site search via Pagefind — opens search page with query pre-filled. */
|
|
window.open('/search.html?q=' + q, '_blank', 'noopener,noreferrer');
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
}());
|