This commit is contained in:
Tony
2026-01-15 11:06:01 -06:00
parent d851081d17
commit d089951f60

185
js/quote.js Normal file
View File

@@ -0,0 +1,185 @@
(() => {
// 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 = `
<span class="quote-icon" aria-hidden="true"></span>
`;
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 `
<blockquote>
${innerHtml}
<p><a href="${safeHref}">${safeText}</a></p>
</blockquote>
`.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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function escapeHtmlAttr(s) {
return escapeHtml(s);
}
})();