Artifact 802bd35462f1921d9187e9c1bdce7828ae35848f2d3a1326fb5b62d22978c5c5:
- File
internal/web/assets/composer.js
— part of check-in
[1b34fa7b8e]
at
2026-08-21 23:12:54
on branch trunk
— live rendered authoring: split pane renders as you type; poll builder edits the source
Production renderer feeds the pane (plain fragment endpoint); a fenced poll grows structured inputs that rewrite the block. Raw markdown stays the source of truth; no-JS is still a plain textarea. (user: dmestas, size: 8393) [annotate] [blame] [check-ins using]
// 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(); }); })(); // --- 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(); }); })();