Skip to content
quarry Artifact [85410ba010]
Login

Artifact [85410ba010]

Artifact 85410ba01008b2f633714f130465388fa17ef68f45afb37388a9fef39edef488:


// 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");
  }

  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();
        }
      });
    }
  });

  // 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;
    });
  });
})();