Artifact 2f3e10d2d33bc90313bd241e3162f51fed4530b6beecd5c4b600d5d9c7b2cec4:
- File
internal/web/assets/composer.js
— part of check-in
[420206e59f]
at
2026-08-21 20:40:05
on branch trunk
— markdown composer: Write/Preview tabs, slash commands, described check-in links; forum hashes resolve
Preview runs the production render pipeline (markup.preview op). Check-in refs carry their comment as hover title + visible excerpt. composer.js loads only on composer pages — the base stays one-script. (user: dmestas, size: 3343) [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(); }); })();