// composer.js — slash commands for quarry's markdown composers.
// First-party, dependency-free. A textarea opts in with data-slash.
// Typing "/" at the start of a line opens a snippet menu; Escape or
// clicking away closes it; without this script the textarea is simply
// a textarea. Snippets insert plain markdown — the raw text stays the
// source of truth.
(function () {
"use strict";
var ITEMS = [
{ label: "Heading", hint: "## ", text: "## " },
{ label: "Bold", hint: "**b**", text: "**bold**" },
{ label: "Code block", hint: "```", text: "```\ncode\n```\n" },
{ label: "Quote", hint: "> ", text: "> " },
{ label: "Bullet list", hint: "- ", text: "- " },
{ label: "Task list", hint: "- [ ]", text: "- [ ] " },
{ label: "Details", hint: "fold", text: "<details><summary>summary</summary>\n\nbody\n\n</details>\n" },
{ label: "Table", hint: "| |", text: "| a | b |\n| --- | --- |\n| 1 | 2 |\n" },
{ label: "Poll", hint: "ballot", text: "```poll\nquestion: Your question?\n- option one\n- option two\ncloses: 2026-12-31\n```\n" }
];
var menu = null, target = null, slashAt = -1, sel = 0;
function close() {
if (menu) menu.remove();
menu = null; target = null; slashAt = -1; sel = 0;
}
function insert(item) {
var ta = target, at = slashAt;
close();
ta.setRangeText(item.text, at, ta.selectionStart, "end");
ta.focus();
ta.dispatchEvent(new Event("input", { bubbles: true }));
}
function render() {
var btns = menu.querySelectorAll("button");
for (var i = 0; i < btns.length; i++) btns[i].classList.toggle("sel", i === sel);
}
function open(ta) {
close();
target = ta; slashAt = ta.selectionStart - 1;
menu = document.createElement("div");
menu.className = "slash-menu";
ITEMS.forEach(function (item, i) {
var b = document.createElement("button");
b.type = "button";
b.textContent = item.label;
var s = document.createElement("small");
s.textContent = item.hint;
b.appendChild(s);
b.addEventListener("mousedown", function (ev) { ev.preventDefault(); insert(item); });
b.addEventListener("mouseenter", function () { sel = i; render(); });
menu.appendChild(b);
});
var r = ta.getBoundingClientRect();
menu.style.left = (r.left + window.scrollX + 12) + "px";
menu.style.top = (r.top + window.scrollY + 28) + "px";
document.body.appendChild(menu);
render();
}
document.addEventListener("input", function (ev) {
var ta = ev.target;
if (!ta.matches || !ta.matches("textarea[data-slash]")) return;
var p = ta.selectionStart, v = ta.value;
if (p > 0 && v[p - 1] === "/" && (p === 1 || v[p - 2] === "\n")) { open(ta); return; }
if (menu) close();
});
document.addEventListener("keydown", function (ev) {
if (!menu) return;
if (ev.key === "Escape") { close(); return; }
if (ev.key === "ArrowDown") { ev.preventDefault(); sel = (sel + 1) % ITEMS.length; render(); return; }
if (ev.key === "ArrowUp") { ev.preventDefault(); sel = (sel + ITEMS.length - 1) % ITEMS.length; render(); return; }
if (ev.key === "Enter" || ev.key === "Tab") { ev.preventDefault(); insert(ITEMS[sel]); }
});
document.addEventListener("click", function (ev) {
if (menu && !menu.contains(ev.target)) close();
// The mention completion list: the server renders the candidates,
// this inserts the chosen one. Replace the trailing @word the
// server matched, keep the caret after it, and tell the binding.
var pick = ev.target.closest && ev.target.closest("#mentions li[data-handle]");
if (pick) {
var ul = pick.closest("#mentions");
var form = ul.closest("form");
var ta = form && form.querySelector("textarea");
if (ta) {
var m = /@[A-Za-z0-9_.-]*$/.exec(ta.value.slice(0, ta.selectionStart));
var at = m ? ta.selectionStart - m[0].length : ta.selectionStart;
ta.setRangeText("@" + pick.dataset.handle + " ", at, ta.selectionStart, "end");
ta.focus();
ta.dispatchEvent(new Event("input", { bubbles: true }));
}
ul.hidden = true;
}
});
})();
// --- live rendered authoring -----------------------------------------
// The split pane renders the draft through the PRODUCTION renderer
// (markup-preview?plain=1), debounced. A fenced poll block additionally
// grows a structured BUILDER — question, options, close date — whose
// inputs rewrite the block in the raw source. Markdown remains the
// single source of truth; the widgets are just hands on it.
(function () {
"use strict";
var DEBOUNCE = 350;
function parsePollBlock(v) {
var m = v.match(/(^|\n)```poll\n([\s\S]*?)\n```/);
if (!m) return null;
var start = m.index + m[1].length;
var def = { question: "", options: [], closes: "", start: start, end: m.index + m[0].length };
m[2].split("\n").forEach(function (l) {
l = l.trim();
if (l.indexOf("question:") === 0) def.question = l.slice(9).trim();
else if (l.indexOf("closes:") === 0) def.closes = l.slice(7).trim();
else if (l.indexOf("- ") === 0) def.options.push(l.slice(2).trim());
});
return def;
}
function pollBlock(def) {
var out = "```poll\nquestion: " + def.question + "\n";
def.options.forEach(function (o) { out += "- " + o + "\n"; });
if (def.closes) out += "closes: " + def.closes + "\n";
return out + "```";
}
function buildPollUI(host, ta, def, refresh) {
host.textContent = "";
var h = document.createElement("h4");
h.textContent = "Poll";
host.appendChild(h);
function write(mut) {
var cur = parsePollBlock(ta.value);
if (!cur) return;
mut(cur);
ta.value = ta.value.slice(0, cur.start) + pollBlock(cur) + ta.value.slice(cur.end);
refresh();
}
function field(label, value, oninput, type) {
var l = document.createElement("label");
l.textContent = label;
var i = document.createElement("input");
i.type = type || "text";
i.value = value;
i.addEventListener("input", function () { oninput(i.value); });
l.appendChild(i);
host.appendChild(l);
return i;
}
field("question", def.question, function (v) { write(function (d) { d.question = v; }); });
var ol = document.createElement("label");
ol.textContent = "options";
host.appendChild(ol);
def.options.forEach(function (opt, idx) {
var row = document.createElement("div");
row.className = "pb-row";
var i = document.createElement("input");
i.value = opt;
i.addEventListener("input", function () { write(function (d) { d.options[idx] = i.value; }); });
var del = document.createElement("button");
del.type = "button"; del.textContent = "×"; del.title = "remove option";
del.addEventListener("click", function () {
write(function (d) { d.options.splice(idx, 1); });
var nd = parsePollBlock(ta.value);
if (nd) buildPollUI(host, ta, nd, refresh);
});
row.appendChild(i); row.appendChild(del);
host.appendChild(row);
});
var add = document.createElement("button");
add.type = "button"; add.className = "pb-add"; add.textContent = "+ add option";
add.addEventListener("click", function () {
write(function (d) { d.options.push("option " + (d.options.length + 1)); });
var nd = parsePollBlock(ta.value);
if (nd) buildPollUI(host, ta, nd, refresh);
});
host.appendChild(add);
field("closes (optional)", def.closes, function (v) { write(function (d) { d.closes = v; }); }, "date");
}
document.querySelectorAll(".f-composer[data-live]").forEach(function (root) {
var ta = root.querySelector("textarea[data-slash]");
var live = root.querySelector(".f-live");
var pane = root.querySelector(".f-live-pane");
var builder = root.querySelector(".f-poll-builder");
if (!ta || !live || !pane) return;
live.hidden = false;
var timer = null, inflight = null, focusedBuilder = false;
function render() {
if (inflight) inflight.abort();
inflight = new AbortController();
fetch(root.getAttribute("data-live"), {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "body=" + encodeURIComponent(ta.value),
signal: inflight.signal
}).then(function (r) { return r.ok ? r.text() : ""; }).then(function (html) {
pane.innerHTML = html;
var def = parsePollBlock(ta.value);
// Rebuilding the builder mid-keystroke would steal its focus;
// only rebuild when the poll's SHAPE changed or from the textarea.
if (def && !focusedBuilder) buildPollUI(builder, ta, def, schedule);
if (!def) builder.textContent = "";
}).catch(function () {});
}
function schedule() {
clearTimeout(timer);
timer = setTimeout(render, DEBOUNCE);
}
builder.addEventListener("focusin", function () { focusedBuilder = true; });
builder.addEventListener("focusout", function () { focusedBuilder = false; });
ta.addEventListener("input", schedule);
if (ta.value.trim() !== "") render();
// Write | Preview tabs: GitHub's shape. Preview renders the draft
// through the same endpoint as the live pane, into THIS composer's
// pane — a page may carry several composers.
var tabs = root.querySelector(".f-tabs");
var split = root.querySelector(".f-split");
var preview = root.querySelector(".f-preview");
if (tabs && split && preview) {
tabs.hidden = false;
tabs.addEventListener("click", function (ev) {
var btn = ev.target.closest("[data-tab]");
if (!btn) return;
var showPreview = btn.getAttribute("data-tab") === "preview";
tabs.querySelectorAll("[data-tab]").forEach(function (b) { b.classList.toggle("here", b === btn); });
split.hidden = showPreview;
preview.hidden = !showPreview;
if (showPreview) {
preview.textContent = "Rendering\u2026";
fetch(root.getAttribute("data-live"), {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "body=" + encodeURIComponent(ta.value)
}).then(function (r) { return r.ok ? r.text() : "Preview failed."; })
.then(function (html) { preview.innerHTML = html || "<p><em>Nothing to preview.</em></p>"; })
.catch(function () { preview.textContent = "Preview failed."; });
} else {
ta.focus();
}
});
}
});
})();