(() => { // Inject Quote buttons into Pandoc-style headings and copy a section-range quote to clipboard. // // Assumptions (matches your sample): // - Headings are h1/h2 with stable ids (anchors). // - A "section" is: the heading + all following sibling elements until the next heading // of the same or higher level (H2 stops at next H2 or H1; H1 stops at next H1). // // Primary target: GroupMe. We optimize text/plain to be readable and "quote-like". // We still provide text/html for rich paste targets (Docs/Email). // // No dependencies. const HEADING_SELECTOR = "h1[id], h2[id]"; // change to "h2[id]" if you only want section-level document.addEventListener("DOMContentLoaded", () => { injectQuoteButtons(); }); function injectQuoteButtons() { document.querySelectorAll(HEADING_SELECTOR).forEach((heading) => { // Avoid double-injection if (heading.querySelector(":scope > .quote-btn")) return; const btn = document.createElement("button"); btn.type = "button"; btn.className = "quote-btn"; btn.textContent = ""; btn.innerHTML = ` `; btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); onQuoteClick(btn, heading); }); // Add a little separation from heading text heading.appendChild(document.createTextNode(" ")); heading.appendChild(btn); }); } async function onQuoteClick(btn, heading) { const originalText = btn.textContent; try { btn.textContent = "Copying…"; btn.disabled = true; await copyQuotedSection(heading); btn.textContent = "Copied"; setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 800); } catch (err) { console.error(err); btn.textContent = "Failed"; setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 1200); } } async function copyQuotedSection(heading) { if (!heading.id) throw new Error("Heading missing id."); const level = heading.tagName; // H1 or H2 // Clone heading + subsequent siblings for the quote range const nodes = []; nodes.push(heading.cloneNode(true)); let el = heading.nextElementSibling; while (el) { if (isBoundary(level, el)) break; nodes.push(el.cloneNode(true)); el = el.nextElementSibling; } // Remove injected button from cloned heading (so it doesn't show up in pasted content) nodes[0].querySelector(".quote-btn")?.remove(); const container = document.createElement("div"); nodes.forEach((n) => container.appendChild(n)); const href = buildAnchorUrl(heading.id); // HTML (for rich paste targets) const html = buildHtmlQuote(container.innerHTML, href); // Plain text (optimized for GroupMe) const plain = buildGroupMePlainQuote(container, href); await writeClipboardMultiFormat({ html, plain, url: href }); } function isBoundary(level, el) { // Stop at next heading of same or higher rank. // H2 stops at next H1 or H2. // H1 stops at next H1. if (level === "H1") return el.tagName === "H1"; if (level === "H2") return el.tagName === "H1" || el.tagName === "H2"; return false; } function buildAnchorUrl(id) { const url = new URL(window.location.href); url.hash = id; return url.toString(); } function buildHtmlQuote(innerHtml, href) { const safeHref = escapeHtmlAttr(href); const safeText = escapeHtml(href); return `
${innerHtml}`.trim(); } function buildGroupMePlainQuote(container, href) { // Create a readable quote-like block: // - Prefix lines with "> " to visually indicate quoting (works even if not rendered specially) // - Include Source link const text = normalizePlainText(container); const quoted = text .split("\n") .map((line) => (line.trim().length ? `> ${line}` : `>`)) .join("\n"); return `${quoted}\n> \n> Source: ${href}`; } function normalizePlainText(container) { // Use innerText to approximate what the user sees (captures list numbering reasonably in many browsers). // Then normalize spacing. return (container.innerText || "") .replace(/\r\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); } async function writeClipboardMultiFormat({ html, plain, url }) { // Best path: multi-format clipboard write (Chromium/Edge, etc.) if (navigator.clipboard?.write) { const item = new ClipboardItem({ "text/html": new Blob([html], { type: "text/html" }), "text/plain": new Blob([plain], { type: "text/plain" }), // Best-effort URL type; some paste targets prefer this "text/uri-list": new Blob([url], { type: "text/uri-list" }), }); await navigator.clipboard.write([item]); return; } // Fallback: plain text only if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(plain); return; } throw new Error("Clipboard API unavailable."); } function escapeHtml(s) { return String(s) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function escapeHtmlAttr(s) { return escapeHtml(s); } })();