// 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, items = ITEMS;
// A textarea may add its own entries (data-slash-extra, a JSON list of
// {label, hint, text}): the filing form's field commands.
function itemsFor(ta) {
try {
var extra = JSON.parse(ta.getAttribute("data-slash-extra") || "[]");
return Array.isArray(extra) ? extra.concat(ITEMS) : ITEMS;
} catch (e) {
return ITEMS;
}
}
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;
items = itemsFor(ta);
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");
}
// --- rich: block editing over the production renderer --------------
// The draft as rendered blocks (markup-blocks: runs of lines between
// blank lines, fences and <details> whole). Tapping a block opens ITS
// markdown in place; leaving it renders it again. Only that block's
// text changes, so every other byte of the draft is kept — a saved
// post's next version differs only where it was edited. The textarea
// stays the source of truth: each edit writes it and fires input.
var SVG = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true">';
var ICON = {
link: '<svg class="ic" aria-hidden="true"><use href="#i-link"/></svg>',
code: SVG + '<path d="M5.5 4 1.5 8l4 4M10.5 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>',
list: SVG + '<circle cx="2.5" cy="4" r="1.2"/><circle cx="2.5" cy="8" r="1.2"/><circle cx="2.5" cy="12" r="1.2"/><path d="M6 4h8.5M6 8h8.5M6 12h8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>',
quote: SVG + '<path d="M3 3.5v9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M6.5 5h8M6.5 8h8M6.5 11h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'
};
function richEditor(ta, pane) {
var url = pane.getAttribute("data-blocks");
var blocks = [], editing = -1, seq = 0;
// The open block's commit, while one is open. A post sent with a
// block still open must carry what was typed in it, so the form's
// submit commits it before the textarea is read.
var pending = null;
if (ta.form) {
ta.form.addEventListener("submit", function () { if (pending) pending(); }, true);
}
function join() {
return blocks.map(function (b) { return b.source; }).join("");
}
function trailing(src) {
var m = /\n*$/.exec(src);
return m ? m[0] : "";
}
function load() {
var mine = ++seq;
if (ta.value.trim() === "") {
blocks = [];
editing = -1;
draw();
return;
}
pane.setAttribute("aria-busy", "true");
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "body=" + encodeURIComponent(ta.value)
}).then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (out) {
if (mine !== seq) return;
blocks = out.blocks || [];
editing = -1;
draw();
})
.catch(function () {
if (mine !== seq) return;
pane.textContent = "The rich view could not load; Write still has your text.";
})
.then(function () { if (mine === seq) pane.removeAttribute("aria-busy"); });
}
function draw() {
pane.textContent = "";
if (blocks.length === 0) {
// An empty draft is one tap from its first block; the page does
// not open it on load, which would take focus from the page.
var start = document.createElement("button");
start.type = "button";
start.className = "be-start";
start.setAttribute("data-hook", "rich-start");
start.textContent = pane.getAttribute("data-placeholder") || "Write something";
start.addEventListener("click", addBlock);
pane.appendChild(start);
return;
}
blocks.forEach(function (b, i) {
if (i === editing) { pane.appendChild(editor(i)); return; }
var d = document.createElement("div");
d.className = "be-block";
d.tabIndex = 0;
d.setAttribute("role", "button");
d.setAttribute("aria-label", "edit this block");
if (b.html.trim() === "") {
// Link definitions and the like render as nothing; the block
// still has to be reachable.
d.classList.add("be-quiet");
d.textContent = "link definitions and other markdown that shows nothing";
} else {
d.innerHTML = b.html; // sanitized by the server's renderer
}
d.addEventListener("click", function (ev) {
if (ev.target.closest("a, button, input, select, summary, textarea")) return;
open(i);
});
d.addEventListener("keydown", function (ev) {
if (ev.key === "Enter" && ev.target === d) { ev.preventDefault(); open(i); }
});
pane.appendChild(d);
});
var add = document.createElement("button");
add.type = "button";
add.className = "be-add";
add.setAttribute("data-hook", "rich-add");
add.textContent = "+ Add a block";
add.addEventListener("click", addBlock);
pane.appendChild(add);
}
function open(i) {
editing = i;
draw();
}
function addBlock() {
// A new block needs a blank line after the one before it.
var last = blocks[blocks.length - 1];
if (last) {
var t = trailing(last.source);
if (t.length < 2) last.source += t.length === 1 ? "\n" : "\n\n";
}
blocks.push({ source: "", html: "" });
open(blocks.length - 1);
}
function prefixLines(t, mark) {
var v = t.value, a = v.lastIndexOf("\n", t.selectionStart - 1) + 1, z = t.selectionEnd;
var e = v.indexOf("\n", z);
if (e < 0) e = v.length;
var lines = v.slice(a, e).split("\n").map(function (l) {
return l.indexOf(mark) === 0 ? l.slice(mark.length) : mark + l;
}).join("\n");
t.setRangeText(lines, a, e, "end");
t.focus();
t.dispatchEvent(new Event("input", { bubbles: true }));
}
function wrapWith(t, before, after, empty) {
var a = t.selectionStart, z = t.selectionEnd;
var inner = t.value.slice(a, z) || empty;
t.setRangeText(before + inner + after, a, z, "end");
t.selectionStart = a + before.length;
t.selectionEnd = a + before.length + inner.length;
t.focus();
t.dispatchEvent(new Event("input", { bubbles: true }));
}
function editor(i) {
var b = blocks[i];
var tail = trailing(b.source);
var wrap = document.createElement("div");
wrap.className = "be-edit";
var bar = document.createElement("div");
bar.className = "be-bar";
var t = document.createElement("textarea");
t.className = "be-src";
t.setAttribute("data-slash", "");
t.setAttribute("aria-label", "this block's markdown");
t.value = b.source.slice(0, b.source.length - tail.length);
// Grouped like a word processor's: inline marks, then what
// inserts, then what makes the line a block; Done on its own.
var groups = [
[["bold", "<b>B</b>", function () { wrapWith(t, "**", "**", "bold"); }],
["italic", "<i>I</i>", function () { wrapWith(t, "_", "_", "italic"); }],
["strikethrough", "<s>S</s>", function () { wrapWith(t, "~~", "~~", "struck"); }]],
[["link", ICON.link, function () { wrapWith(t, "[", "](https://)", "text"); }],
["inline code", ICON.code, function () { wrapWith(t, "`", "`", "code"); }]],
[["heading", "<b>H</b>", function () { prefixLines(t, "## "); }],
["bulleted list", ICON.list, function () { prefixLines(t, "- "); }],
["quote", ICON.quote, function () { prefixLines(t, "> "); }]]
];
groups.forEach(function (g) {
var grp = document.createElement("span");
grp.className = "be-grp";
grp.setAttribute("role", "group");
g.forEach(function (k) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "be-ic";
btn.innerHTML = k[1];
btn.title = k[0];
btn.setAttribute("aria-label", k[0]);
btn.addEventListener("mousedown", function (ev) { ev.preventDefault(); k[2](); });
grp.appendChild(btn);
});
bar.appendChild(grp);
});
var done = document.createElement("button");
done.type = "button";
done.className = "be-done";
done.setAttribute("data-hook", "rich-done");
done.textContent = "Done";
done.addEventListener("mousedown", function (ev) { ev.preventDefault(); commit(); });
bar.appendChild(done);
var committed = false;
pending = commit;
function commit() {
if (committed) return;
committed = true;
pending = null;
var text = t.value.replace(/\n+$/, "");
b.source = text === "" ? "" : text + (tail || (i < blocks.length - 1 ? "\n\n" : "\n"));
var next = join();
if (next !== ta.value) {
ta.value = next;
ta.dispatchEvent(new Event("input", { bubbles: true }));
}
load();
}
function size() {
t.style.height = "auto";
t.style.height = t.scrollHeight + 2 + "px";
}
t.addEventListener("input", size);
t.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" || (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey))) {
ev.preventDefault();
commit();
}
});
// Leaving the block renders it, unless focus went to its own bar
// or to the "/" menu, which keep focus with mousedown.
t.addEventListener("blur", function () {
setTimeout(function () {
if (!committed && document.activeElement !== t && !document.querySelector(".slash-menu")) commit();
}, 150);
});
wrap.appendChild(bar);
wrap.appendChild(t);
setTimeout(function () { size(); t.focus(); }, 0);
return wrap;
}
return { load: load };
}
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.
// Rich | Markdown: the draft as rendered blocks, or its raw text.
// Rich is the default; a reader who picks Markdown keeps it on every
// composer, remembered in this browser only. Without script neither
// tab exists and the composer is the textarea.
var tabs = root.querySelector(".f-tabs");
var split = root.querySelector(".f-split");
var richPane = root.querySelector(".f-rich");
if (tabs && split && richPane) {
var rich = richEditor(ta, richPane);
var show = function (which, user) {
tabs.querySelectorAll("[data-tab]").forEach(function (b) {
b.classList.toggle("here", b.getAttribute("data-tab") === which);
});
split.hidden = which !== "write";
richPane.hidden = which !== "rich";
if (user) {
try { localStorage.setItem("quarry.composer.tab", which); } catch (e) {}
}
if (which === "rich") rich.load();
else if (user) ta.focus();
};
tabs.hidden = false;
tabs.addEventListener("click", function (ev) {
var btn = ev.target.closest("[data-tab]");
if (btn) show(btn.getAttribute("data-tab"), true);
});
var first = "rich";
try { if (localStorage.getItem("quarry.composer.tab") === "write") first = "write"; } catch (e) {}
show(first, false);
}
});
// One press, one post. A composer's submit button stayed live while
// the request was in flight, so a slow page took a second tap as a
// second comment — two identical posts, seconds apart, is what that
// looks like afterwards. The button is disabled for the life of the
// navigation and put back if the page is restored from the back /
// forward cache, which is the one case where the form is still on
// screen and still wants to work.
document.addEventListener("submit", function (ev) {
var form = ev.target;
if (!form || form.method.toLowerCase() !== "post" || ev.defaultPrevented) return;
var btns = form.querySelectorAll("button[type=submit], button:not([type]), input[type=submit]");
// Disabled after the event has been dispatched, so the pressed
// button's own name and value still reach the server.
setTimeout(function () {
Array.prototype.forEach.call(btns, function (b) { b.disabled = true; });
}, 0);
});
window.addEventListener("pageshow", function (ev) {
if (!ev.persisted) return;
document.querySelectorAll("button[disabled], input[type=submit][disabled]").forEach(function (b) {
b.disabled = false;
});
});
})();