// 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;
}
});
})();
// --- the composer's two views ------------------------------------------
// Rich, the draft as one editable document, and Markdown, the textarea
// the form posts.
(function () {
"use strict";
// --- rich: one document over the production renderer ---------------
// Rich is the draft as one editable document. Every block is first
// drawn by the PRODUCTION renderer (markup-blocks), so Rich reads as
// the post will. Stepping into a block turns it into its editing
// form, plain elements this script can write back as markdown; on
// leaving, a block that changed is rendered by the server again and
// one that did not goes back to exactly what it was. A block nobody
// edits keeps every byte of its source, so a saved post's next
// version differs only where it was edited. Tables, polls, diagrams
// and markdown this script cannot write back are cards, each with an
// editor of its own. The textarea stays the source of truth: every
// change writes it and fires input, and the Markdown tab is it.
var ZW = "";
function esc(s) {
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
function el(tag, attrs, text) {
var e = document.createElement(tag);
Object.keys(attrs || {}).forEach(function (k) { e.setAttribute(k, attrs[k]); });
if (text) e.textContent = text;
return e;
}
function icon(id) { return '<svg class="ic" aria-hidden="true"><use href="#' + id + '"/></svg>'; }
function fenceOf(l) { var m = /^ {0,3}(`{3,}|~{3,})/.exec(l); return m ? m[1] : ""; }
function listItem(l) { return /^ {0,3}([-*+]|\d{1,9}[.)])[ \t]/.test(l); }
function fenceBody(src) {
var l = src.replace(/\n+$/, "").split("\n");
return l.slice(1, l.length - (l.length > 1 && fenceOf(l[l.length - 1]) ? 1 : 0)).join("\n");
}
// split is markup.SplitBlocks, for a block's own text.
function split(src) {
var lines = src.split(/(?<=\n)/), out = [], cur = "", fence = "", after = false, content = false, list = false, html = 0;
lines.forEach(function (ln) {
var blank = ln.trim() === "", t = ln.trim().toLowerCase();
if (fence) {
var f = fenceOf(ln);
if (f && f[0] === fence[0] && f.length >= fence.length && ln.trim().split(fence[0]).join("") === "") fence = "";
} else if (!blank) {
if (after && html === 0 && !/^[ \t]/.test(ln) && content && !(list && listItem(ln))) { out.push(cur); cur = ""; content = false; }
if (!content) list = listItem(ln);
content = true;
var o = fenceOf(ln);
if (o) fence = o;
if (t[0] === "<") { html += (t.match(/<details/g) || []).length - (t.match(/<\/details/g) || []).length; if (html < 0) html = 0; }
}
after = blank && !fence;
cur += ln;
});
if (cur) out.push(cur);
return out;
}
// What a block is, from its source: prose is edited in place, the
// rest are cards.
function kindOf(src) {
var t = src.replace(/^\n+|\n+$/g, ""), lines = t.split("\n"), f = fenceOf(lines[0]);
if (f) {
var lang = lines[0].trim().slice(f.length).trim();
return lang === "pikchr" ? "diagram" : lang === "poll" ? "poll" : "prose";
}
if (/^\|.*\|\s*$/.test(lines[0]) && lines[1] && /^\|[\s:|-]+\|\s*$/.test(lines[1])) return "table";
if (/^<details/i.test(lines[0])) return "prose";
if (/^</.test(lines[0]) || /^\[\^[^\]]+\]:/.test(lines[0])) return "source";
if (lines.every(function (l) { return /^ {0,3}\[[^\]]+\]:\s*\S/.test(l) || !l.trim(); })) return "source";
return faithful(t) ? "prose" : "source";
}
// A block is edited in place only when its editing form writes back
// the same words; anything this script would lose stays a card.
function words(s) { return s.replace(/\\(.)/g, "$1").replace(/[^\p{L}\p{N}]+/gu, " ").trim(); }
function faithful(md) {
var box = document.createElement("div");
box.innerHTML = renderAll(md + "\n");
var back = Array.prototype.map.call(box.children, mdNode).join("\n\n");
return words(back) === words(md);
}
// ---- markdown -> the editing form -----------------------------------
var REF_IC = { ticket: "i-tkt-open", landing: "i-landing", person: "i-person", wiki: "i-book", "check-in": "i-commit" };
function refHTML(tok) {
var kind = tok[0] === "@" ? "person" : tok.slice(0, 2) === "[[" ? "wiki" : tok.indexOf("!") >= 0 ? "landing" : tok[0] === "[" ? "check-in" : "ticket";
var label = kind === "wiki" ? tok.slice(2, -2) : tok;
return '<a class="ref rt-ref" contenteditable="false" href="#" data-tok="' + esc(tok) + '" title="' + kind + '">' + icon(REF_IC[kind]) + esc(label) + "</a>";
}
// keepHTML is markdown this script shows but does not edit: an
// attachment, a footnote mark, inline HTML. It is written back as is.
function keepHTML(md, label, cls) {
return '<span class="rt-keep' + (cls ? " " + cls : "") + '" contenteditable="false" data-md="' + esc(md) + '">' + esc(label) + "</span>";
}
var staged = {}; // attach:NAME -> a blob URL, for files staged in this page
function inline(s) {
var stash = [];
function keep(h) { stash.push(h); return "\u0000" + (stash.length - 1) + "\u0000"; }
s = s.replace(/`([^`]+)`/g, function (_, c) { return keep("<code>" + esc(c) + "</code>"); });
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, function (m, a, u) {
if (staged[u]) return keep('<img alt="' + esc(a) + '" src="' + esc(staged[u]) + '" data-upload="' + esc(u) + '">');
if (/^https:\/\//.test(u)) return keep('<img alt="' + esc(a) + '" src="' + esc(u) + '">');
return keep(keepHTML(m, a || u.replace(/^attach:/, ""), "rt-file"));
});
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function (_, t, u) { return keep('<a href="' + esc(u) + '">' + esc(t) + "</a>"); });
s = s.replace(/\[\^([^\]]+)\]/g, function (m, n) { return keep(keepHTML(m, n, "rt-fn")); });
s = s.replace(/\[\[[^\]\n]+\]\]/g, function (m) { return keep(refHTML(m)); });
s = s.replace(/<\/?[a-zA-Z][^>]*>/g, function (m) { return keep(keepHTML(m, m)); });
s = s.replace(/(^|[\s(])((?:[\w.-]+:)?[#!][1-9]\d{0,8}|@[A-Za-z0-9](?:[\w.-]{0,62}\w)?|\[[0-9a-f]{6,40}\])(?![\w])/g, function (_, pre, tok) { return pre + keep(refHTML(tok)); });
s = s.replace(/(^|\s)(https?:\/\/[^\s<]+[^\s<.,;:!?)])/g, function (_, pre, u) { return pre + keep('<a href="' + esc(u) + '">' + esc(u) + "</a>"); });
s = esc(s)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^\w*])\*([^*\s][^*]*)\*(?!\*)/g, "$1<em>$2</em>")
.replace(/(^|[^\w])_([^_\s][^_]*)_(?!\w)/g, "$1<em>$2</em>")
.replace(/~~([^~]+)~~/g, "<s>$1</s>")
.replace(/\\([*_`~\\#>\-!@\[\]|<])/g, "$1");
return s.replace(/\u0000(\d+)\u0000/g, function (_, i) { return stash[+i]; });
}
function listHTML(lines) {
// Items by indentation: deeper lines nest under the item above them.
var root = { kids: [], depth: -1 }, stack = [root];
lines.forEach(function (l) {
var m = /^(\s*)([-*+]|\d{1,9}[.)])\s+(\[[ xX]\]\s+)?(.*)$/.exec(l);
if (!m) { var top = stack[stack.length - 1]; if (top.text !== undefined && l.trim()) top.text += " " + l.trim(); return; }
var d = m[1].replace(/\t/g, " ").length;
while (stack.length > 1 && stack[stack.length - 1].depth >= d) stack.pop();
var it = { depth: d, ord: /\d/.test(m[2]), task: m[3] ? (/x/i.test(m[3]) ? "x" : " ") : null, text: m[4], kids: [] };
stack[stack.length - 1].kids.push(it);
stack.push(it);
});
function out(items) {
if (!items.length) return "";
var tag = items[0].ord ? "ol" : "ul";
return "<" + tag + ">" + items.map(function (it) {
var box = it.task !== null ? '<input type="checkbox" contenteditable="false"' + (it.task === "x" ? " checked" : "") + ">" : "";
return "<li" + (it.task !== null ? ' class="task"' : "") + ">" + box + inline(it.text) + out(it.kids) + "</li>";
}).join("") + "</" + tag + ">";
}
return out(root.kids);
}
var ALERTS = { note: "Note", tip: "Tip", important: "Important", warning: "Warning", caution: "Caution" };
function renderBlock(b) {
var t = b.replace(/^\n+|\n+$/g, ""), lines = t.split("\n"), m;
if (!t.trim()) return "";
var f = fenceOf(lines[0]);
if (f) {
var lang = lines[0].trim().slice(f.length).trim();
return '<pre data-lang="' + esc(lang) + '"><code>' + esc(fenceBody(t)) + "</code></pre>";
}
if (/^<details/i.test(lines[0])) return foldHTML(t);
// A heading is drawn a level down, as the post draws it (the page
// owns the h1): ## is an h3.
if ((m = /^(#{1,6}) (.*)$/.exec(t)) && lines.length === 1) {
var h = Math.min(m[1].length + 1, 6);
return "<h" + h + ">" + inline(m[2]) + "</h" + h + ">";
}
if (/^(---+|\*\*\*+|___+)$/.test(t)) return "<hr>";
if (lines.every(function (l) { return /^>/.test(l); })) {
var body = lines.map(function (l) { return l.replace(/^> ?/, ""); }), alert = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$/i.exec(body[0]);
if (alert) body = body.slice(1);
var paras = body.join("\n").split(/\n\s*\n/).map(function (p) { return "<p>" + inline(p).replace(/\n/g, "<br>") + "</p>"; }).join("");
if (alert) {
var k = alert[1].toLowerCase();
return '<blockquote data-alert="' + k + '" data-label="' + ALERTS[k] + '">' + paras + "</blockquote>";
}
return "<blockquote>" + paras + "</blockquote>";
}
if (listItem(lines[0])) return listHTML(lines);
return "<p>" + inline(lines.join("\n")).replace(/\n/g, "<br>") + "</p>";
}
// A fold is edited in place: its summary is a line of text and what it
// hides is ordinary Rich content inside the open fold.
function foldHTML(t) {
var sum = /<summary>([\s\S]*?)<\/summary>/i.exec(t);
var body = t.replace(/^[\s\S]*?<\/summary>/i, "").replace(/<\/details>\s*$/i, "").trim();
return '<details open class="rt-fold"><summary>' + inline(sum ? sum[1].trim() : "Details") + "</summary>" +
(body ? renderAll(body + "\n") : "<p><br></p>") + "</details>";
}
function renderAll(src) { return split(src).map(renderBlock).join(""); }
// ---- the editing form -> markdown -------------------------------------
function mdText(s) {
s = s.replace(//g, "").replace(/ /g, " ").replace(/([*`\\]|~~|<(?=[a-zA-Z\/]))/g, "\\$1");
// An underscore inside a word is a letter, not emphasis.
return s.replace(/_/g, function (u, i) { return /\w/.test(s[i - 1] || "") && /\w/.test(s[i + 1] || "") ? u : "\\_"; });
}
function mdInline(node, skipLists) {
var out = "";
node.childNodes.forEach(function (n) {
if (n.nodeType === 3) { out += mdText(n.nodeValue); return; }
if (n.nodeType !== 1) return;
var t = n.tagName;
if (skipLists && (t === "UL" || t === "OL")) return;
if (t === "INPUT") return;
if (n.hasAttribute("data-md")) { out += n.getAttribute("data-md"); return; }
if (t === "A" && n.hasAttribute("data-tok")) { out += n.getAttribute("data-tok"); return; }
if (t === "IMG") { out += " || n.getAttribute("src")) + ")"; return; }
var inner = mdInline(n);
if (!inner.trim() && t !== "BR") { out += inner; return; }
if (t === "STRONG" || t === "B") out += "**" + inner + "**";
else if (t === "EM" || t === "I") out += "_" + inner + "_";
else if (t === "S" || t === "DEL" || t === "STRIKE") out += "~~" + inner + "~~";
else if (t === "CODE") out += "`" + n.textContent.replace(//g, "") + "`";
else if (t === "A") out += inner === n.getAttribute("href") ? inner : "[" + inner + "](" + n.getAttribute("href") + ")";
else if (t === "BR") out += "\n";
else out += inner;
});
return out;
}
// A line that would read as a heading, list, quote or rule is escaped.
function guardLines(s) {
return s.split("\n").map(function (l) { return /^(#{1,6}\s|[-*+]\s|\d{1,9}[.)]\s|>|---|\|)/.test(l) ? "\\" + l : l; }).join("\n");
}
function mdList(list, depth) {
var i = 0, pad = " ".repeat(depth);
return Array.prototype.filter.call(list.children, function (c) { return c.tagName === "LI"; }).map(function (li) {
i++;
var box = li.querySelector(":scope > input[type=checkbox]");
var head = pad + (list.tagName === "OL" ? i + ". " : "- ") + (box ? (box.checked ? "[x] " : "[ ] ") : "") + mdInline(li, true).trim();
var subs = Array.prototype.filter.call(li.children, function (c) { return c.tagName === "UL" || c.tagName === "OL"; }).map(function (s) { return mdList(s, depth + 1); });
return [head].concat(subs).join("\n");
}).join("\n");
}
function mdNode(n) {
var t = n.tagName;
if (/^H[1-6]$/.test(t)) return "######".slice(0, Math.max(1, +t[1] - 1)) + " " + mdInline(n).trim();
if (t === "UL" || t === "OL") return mdList(n, 0);
if (t === "BLOCKQUOTE") {
var ps = n.children.length ? Array.prototype.slice.call(n.children) : [n];
var body = ps.map(function (c) { return mdInline(c).trim().split("\n").map(function (l) { return "> " + l; }).join("\n"); }).join("\n>\n");
var a = n.getAttribute("data-alert");
return a ? "> [!" + a.toUpperCase() + "]\n" + body : body;
}
if (t === "PRE") return "```" + (n.getAttribute("data-lang") || "") + "\n" + n.textContent.replace(//g, "").replace(/\n$/, "") + "\n```";
if (t === "HR") return "---";
if (t === "DETAILS") {
var summ = n.querySelector(":scope > summary"), parts = [];
Array.prototype.forEach.call(n.children, function (c) {
if (c.tagName === "SUMMARY") return;
var m = mdNode(c);
if (m.replace(//g, "").trim()) parts.push(m);
});
return "<details><summary>" + (summ ? mdInline(summ).trim() : "Details") + "</summary>\n\n" + parts.join("\n\n") + "\n\n</details>";
}
return guardLines(mdInline(n).replace(/[ \t]+$/gm, "").replace(/\n+$/, ""));
}
// ---- table and poll sources --------------------------------------------
function pollOf(src) {
var body = fenceBody(src), q = /^question:\s*(.*)$/m.exec(body), c = /^closes:\s*(.*)$/m.exec(body);
return { q: q ? q[1] : "", opts: body.split("\n").filter(function (l) { return /^- /.test(l); }).map(function (l) { return l.slice(2); }), closes: c ? c[1].trim() : "" };
}
function pollSrc(d) {
return "```poll\nquestion: " + d.q + "\n" + d.opts.map(function (o) { return "- " + o; }).join("\n") + (d.closes ? "\ncloses: " + d.closes : "") + "\n```";
}
function tableOf(src) {
var lines = src.replace(/\n+$/, "").split("\n").filter(function (l) { return l.trim(); });
var cells = function (l) { return l.trim().replace(/^\||\|$/g, "").split(/(?<!\\)\|/).map(function (c) { return c.trim(); }); };
return { rows: lines.filter(function (l, i) { return i !== 1; }).map(cells), align: lines[1] ? cells(lines[1]) : [] };
}
function tableSrc(t) {
var n = Math.max.apply(null, t.rows.map(function (r) { return r.length; }));
var row = function (r) { var c = r.slice(); while (c.length < n) c.push(""); return "| " + c.join(" | ") + " |"; };
var al = [];
for (var i = 0; i < n; i++) al.push(t.align[i] && /^:?-+:?$/.test(t.align[i]) ? t.align[i] : "---");
return [row(t.rows[0])].concat(["| " + al.join(" | ") + " |"], t.rows.slice(1).map(row)).join("\n");
}
// pikchr-studio takes the source in the fragment, as the rendered
// diagram's own link does (markup.StudioLink): base64url of raw
// DEFLATE, so the source never reaches a server.
var STUDIO = "https://pikchr-studio.daniel-mestas.workers.dev/";
function studioLink(body, done) {
if (!window.CompressionStream) { done(STUDIO); return; }
var cs = new CompressionStream("deflate-raw"), w = cs.writable.getWriter();
w.write(new TextEncoder().encode(body));
w.close();
new Response(cs.readable).arrayBuffer().then(function (buf) {
var bin = "", a = new Uint8Array(buf);
for (var i = 0; i < a.length; i++) bin += String.fromCharCode(a[i]);
done(STUDIO + "#z=" + btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""));
});
}
function richEditor(ta, host, root) {
var blocksURL = host.getAttribute("data-blocks"), suggestURL = host.getAttribute("data-suggest");
var hint = "Write, or type / to insert";
var touch = matchMedia("(pointer: coarse)").matches || innerWidth < 640;
var doc = el("div", { class: "rt-doc", contenteditable: "true", role: "textbox", "aria-multiline": "true",
"aria-label": "the post, as it will read", spellcheck: "true", "data-hook": "rich-doc" });
host.appendChild(doc);
try { document.execCommand("defaultParagraphSeparator", false, "p"); } catch (e) {}
// The formatting bar: a bubble over a selection, docked above the
// keyboard on a phone. Its controls write the editing form; the
// markdown follows from it.
var fmt = el("div", { class: "rt-pop rt-fmt", role: "toolbar", "aria-label": "formatting", "data-hook": "rich-bar" });
var styles = [["p", "Text"], ["h1", "Heading 1"], ["h2", "Heading 2"], ["h3", "Heading 3"], ["ul", "Bulleted list"], ["ol", "Numbered list"],
["task", "Task list"], ["blockquote", "Quote"], ["note", "Note"], ["tip", "Tip"], ["warning", "Warning"]];
fmt.innerHTML = '<div class="rt-tools"><select class="rt-style" aria-label="paragraph style">' +
styles.map(function (s) { return '<option value="' + s[0] + '">' + s[1] + "</option>"; }).join("") + "</select>" +
'<span class="rt-sep"></span>' +
'<button type="button" data-cmd="bold" aria-label="bold" title="Bold (⌘B)"><b>B</b></button>' +
'<button type="button" data-cmd="italic" aria-label="italic" title="Italic (⌘I)"><i>I</i></button>' +
'<button type="button" data-cmd="strikeThrough" aria-label="strikethrough" title="Strikethrough"><s>S</s></button>' +
'<button type="button" data-cmd="code" aria-label="code" title="Code"><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><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></button>' +
'<button type="button" data-cmd="link" aria-label="link" title="Link (⌘K)">' + icon("i-link") + "</button>" +
'<button type="button" data-cmd="attach" aria-label="attach a file" title="Attach a file" hidden><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 7.5 8 13a3.5 3.5 0 0 1-5-5l5.8-5.8a2.3 2.3 0 0 1 3.3 3.3L6.3 11.3a1.2 1.2 0 0 1-1.7-1.7L9.8 4.4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>' +
'<div class="rt-lang"><label>Language <input class="rt-lang-in" placeholder="go, sh, text" list="rt-langs"></label>' +
'<datalist id="rt-langs"><option value="go"><option value="sh"><option value="js"><option value="sql"><option value="zig"><option value="diff"><option value="text"></datalist></div>' +
'<div class="rt-linkin"><input class="rt-href" type="url" placeholder="https://" aria-label="link address"><button type="button" class="btn rt-link-ok">Link</button></div>';
var lp = el("div", { class: "rt-pop rt-linkpop", role: "dialog", "aria-label": "link" });
lp.innerHTML = '<input class="rt-lp-href" type="url" aria-label="link address"><button type="button" class="btn rt-lp-save">Save</button>' +
'<a class="rt-lp-open" target="_blank" rel="noopener" aria-label="open the link" title="Open">↗</a>' +
'<button type="button" class="rt-lp-remove" aria-label="remove the link" title="Remove the link">' + icon("i-x") + "</button>";
var menu = el("div", { class: "rt-menu", role: "listbox", "aria-label": "suggestions", "data-hook": "rich-menu" });
var tp = el("div", { class: "rt-pop rt-tpick", role: "dialog", "aria-label": "table size" });
tp.innerHTML = '<div class="rt-tp-grid"></div><div class="rt-tp-l">Columns × rows</div>';
[fmt, lp, menu, tp].forEach(function (p) { host.appendChild(p); });
var styleSel = fmt.querySelector(".rt-style"), href = fmt.querySelector(".rt-href"), langIn = fmt.querySelector(".rt-lang-in");
// The composer's attachment control, when the reader may upload:
// its file input is the Attach action, and what it stages is put in
// the text where the caret was (attach.js tells us).
var ctl = root.querySelector("[data-hook=attach-composer]");
var filePick = ctl && ctl.querySelector("[data-hook=attach-choose] input[type=file]");
if (filePick) {
fmt.querySelector('[data-cmd="attach"]').hidden = false;
filePick.addEventListener("change", function () { filePick.removeAttribute("accept"); });
}
// ---- the document --------------------------------------------------
var seq = 0, written = null, lastBlock = null;
function view(kind, src, html) {
var d = el("div", { class: kind === "prose" ? "rt-view" : "rt-card", contenteditable: "false" });
d.setAttribute(kind === "prose" ? "data-md" : "data-src", src);
if (kind !== "prose") {
d.setAttribute("data-kind", kind);
d.innerHTML = '<div class="rt-card-view"></div>';
d.firstChild.innerHTML = html;
} else d.innerHTML = html;
dropNotes(d);
orSource(d);
return d;
}
// A block renders on its own, so footnotes would list under every
// block that cites one; the definitions' own card lists them.
// A card the renderer draws as nothing (an unreferenced footnote,
// link definitions) shows its markdown, so it can still be found.
function orSource(d) {
var v = d.querySelector(".rt-card-view");
if (v && !v.textContent.trim() && !v.querySelector("img, svg, table")) {
v.innerHTML = '<pre class="rt-raw"></pre>';
v.firstChild.textContent = d.getAttribute("data-src").replace(/\n+$/, "");
}
}
function dropNotes(d) {
if (d.getAttribute("data-kind") === "source" && /^\[\^/.test(d.getAttribute("data-src") || "")) return;
Array.prototype.forEach.call(d.querySelectorAll(".footnotes"), function (f) { f.remove(); });
}
function render(src, done) {
fetch(blocksURL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "body=" + encodeURIComponent(src)
}).then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (out) { done(out.blocks || []); })
.catch(function () { done(null); });
}
function load(done) {
var mine = ++seq, src = ta.value;
// A text Rich did not write (the Markdown tab, a restored draft)
// is a version of its own.
if (hist[pos] !== src) { lastAt = 0; remember(src); }
if (src.trim() === "") { doc.innerHTML = "<p><br></p>"; written = src; tidy(); if (done) done(); return; }
host.setAttribute("aria-busy", "true");
render(src, function (blocks) {
if (mine !== seq) return;
host.removeAttribute("aria-busy");
if (!blocks) { doc.textContent = "The rich view could not load; the Markdown tab has your text."; doc.contentEditable = "false"; return; }
doc.contentEditable = "true";
doc.textContent = "";
blocks.forEach(function (b) { doc.appendChild(view(kindOf(b.source), b.source, b.html)); });
written = src;
tidy();
if (done) done();
});
}
// Rendering one block again: its markdown with the draft's link
// definitions after it, so a reference link still resolves.
function defs() {
return Array.prototype.filter.call(doc.querySelectorAll(":scope > .rt-card[data-kind=source]"), function (c) {
return /^ {0,3}\[[^\]^][^\]]*\]:/.test(c.getAttribute("data-src"));
}).map(function (c) { return c.getAttribute("data-src").trim(); }).join("\n");
}
function renderInto(target, src, inner) {
var d = defs();
render(src.replace(/\n+$/, "") + (d ? "\n\n" + d : "") + "\n", function (blocks) {
if (!blocks || !target.isConnected) return;
var html = blocks.map(function (b) { return b.html; }).join("");
if (inner) inner.innerHTML = html; else target.innerHTML = html;
dropNotes(target);
orSource(target);
});
}
// Serialising: a view or card is its source; a block in its editing
// form that nobody changed is its source too; anything else is
// written from the editing form.
function intact(n) {
var g = n._grp;
if (!g || g.nodes[0] !== n) return false;
for (var i = 0, x = n; i < g.nodes.length; i++, x = x.nextElementSibling) {
if (x !== g.nodes[i] || x.outerHTML !== g.snaps[i]) return false;
}
return true;
}
function serialize() {
var out = "", gen = false, kids = doc.children;
for (var i = 0; i < kids.length; i++) {
var n = kids[i], text;
if (n.classList.contains("rt-view")) { text = n.getAttribute("data-md"); gen = false; }
else if (n.classList.contains("rt-card")) { text = n.getAttribute("data-src"); gen = false; }
else if (intact(n)) { text = n._grp.src; i += n._grp.nodes.length - 1; gen = false; }
else {
text = mdNode(n);
if (text.replace(//g, "").trim() === "") continue;
text += "\n\n";
gen = true;
}
if (out && !/\n\n$/.test(out)) out = out.replace(/\n*$/, "\n\n");
out += text;
}
return gen ? out.replace(/\n*$/, "\n") : out;
}
function write() {
var md = serialize();
if (md === ta.value) return;
remember(md);
written = md;
ta.value = md;
ta.dispatchEvent(new Event("input", { bubbles: true }));
}
// Undo and redo. The browser's own history cannot follow a document
// whose blocks are swapped for their renderings, so Rich keeps its
// own: the markdown after each pause in typing. Undo puts a version
// back and the caret at the end of the first block it changed.
var hist = [], pos = 0, lastAt = 0;
function remember(md) {
var now = Date.now();
hist = hist.slice(0, pos + 1);
if (now - lastAt < 1000 && pos > 0 && hist.length > 1) hist[pos] = md;
else { hist.push(md); pos = hist.length - 1; }
if (hist.length > 200) { hist.shift(); pos--; }
lastAt = now;
}
function travel(step) {
var to = pos + step;
if (to < 0 || to >= hist.length) return;
var from = split(serialize()), md = hist[to], dest = split(md), at = 0;
while (at < from.length && at < dest.length && from[at] === dest[at]) at++;
pos = to;
lastAt = 0;
ta.value = md;
written = md;
ta.dispatchEvent(new Event("input", { bubbles: true }));
lastBlock = null;
load(function () {
var kids = doc.children, n = kids[Math.min(at, kids.length - 1)];
if (n && n.classList.contains("rt-view")) open(n, undefined, undefined, true);
else if (n && !n.classList.contains("rt-card")) placeIn(n);
tidy();
});
}
// The Markdown tab or a restored draft changed the text: Rich reads
// it again, now if it is showing, else when it is next shown.
ta.addEventListener("input", function () {
if (ta.value === written) return;
written = null;
if (!host.hidden) load();
});
// Stepping into a view: it becomes its editing form, the caret where
// the reader put it.
function open(v, x, y, atEnd) {
var src = v.getAttribute("data-md"), box = document.createElement("div");
box.innerHTML = renderAll(src);
var nodes = Array.prototype.slice.call(box.children);
if (!nodes.length) return null;
var g = { src: src, nodes: nodes, snaps: [], view: v };
nodes.forEach(function (n) { n._grp = g; });
v.replaceWith.apply(v, nodes);
g.snaps = nodes.map(function (n) { return n.outerHTML; });
var r = x !== undefined && document.caretRangeFromPoint ? document.caretRangeFromPoint(x, y) : null;
if (r && nodes.some(function (n) { return n.contains(r.startContainer); })) {
sel().removeAllRanges(); sel().addRange(r); doc.focus();
} else placeIn(atEnd ? nodes[nodes.length - 1] : nodes[0], !atEnd);
lastBlock = topOf(blockAt());
return nodes;
}
// Leaving a block in its editing form: unchanged, it is the view it
// was; changed, it is rendered again from what it now says.
// force settles even with the caret still inside: a press on another
// block, before the selection has moved.
function settle(n, force) {
n = n && topOf(n);
if (!n || !n.isConnected || n.nodeType !== 1 || n.classList.contains("rt-view") || n.classList.contains("rt-card")) return;
var g = n._grp, here = topOf(blockAt());
if (!force && (here === n || (g && g.nodes.indexOf(here) >= 0))) return;
if (g && g.nodes.every(function (x) { return x.isConnected; }) && intact(g.nodes[0])) {
g.nodes[0].replaceWith(g.view);
g.nodes.slice(1).forEach(function (x) { x.remove(); });
return;
}
if (n.tagName === "P" && n.textContent.replace(//g, "") === "" && !n.querySelector("img, .rt-keep")) return;
var md = mdNode(n);
if (!md.replace(//g, "").trim()) return;
var v = view("prose", md + "\n\n", "");
v.innerHTML = n.innerHTML === "" ? "" : n.outerHTML;
v.querySelectorAll("[contenteditable]").forEach(function (x) { x.removeAttribute("contenteditable"); });
n.replaceWith(v);
v.classList.add("rt-settled");
setTimeout(function () { v.classList.remove("rt-settled"); }, 650);
renderInto(v, md);
}
function watchLeave() {
var b = topOf(blockAt());
if (b === lastBlock) return;
var prev = lastBlock;
lastBlock = b;
if (prev) { settle(prev); write(); }
}
function tidy() {
var last = doc.lastElementChild;
if (!last || last.classList.contains("rt-view") || last.classList.contains("rt-card") || !/^(P|H[1-6])$/.test(last.tagName)) doc.appendChild(el("p")).innerHTML = "<br>";
var here = blockAt();
Array.prototype.forEach.call(doc.children, function (n) {
if (n.tagName === "DIV" && !n.classList.contains("rt-view") && !n.classList.contains("rt-card")) {
var p = el("p");
p.innerHTML = n.innerHTML;
n.replaceWith(p);
n = p;
}
if (n.tagName === "P") {
var empty = n.textContent.replace(//g, "") === "" && !n.querySelector("img, .rt-keep");
var on = empty && (doc.children.length === 1 || n === here);
if (on) n.setAttribute("data-hint", hint); else n.removeAttribute("data-hint");
}
});
}
function after() { tidy(); write(); placeFmt(); }
// ---- selection helpers ------------------------------------------------
function sel() { return window.getSelection(); }
function blockAt() {
var s = sel();
if (!s.rangeCount || !s.anchorNode || !doc.contains(s.anchorNode) || s.anchorNode === doc) return null;
var n = s.anchorNode;
while (n && n.parentNode !== doc && !(n.parentNode && n.parentNode.tagName === "DETAILS" && n.parentNode.parentNode === doc)) n = n.parentNode;
return n && n.nodeType === 1 ? n : null;
}
function topOf(n) { while (n && n.parentNode && n.parentNode !== doc) n = n.parentNode; return n && n.parentNode === doc ? n : null; }
function caretRect() {
var s = sel();
if (!s.rangeCount) return null;
var rects = s.getRangeAt(0).getClientRects();
if (rects.length) return rects[rects.length - 1];
var b = blockAt();
return b ? b.getBoundingClientRect() : null;
}
function placeIn(n, atStart) {
var r = document.createRange();
r.selectNodeContents(n);
r.collapse(!!atStart);
sel().removeAllRanges();
sel().addRange(r);
doc.focus();
}
function placeAfter(node) {
var r = document.createRange();
r.setStartAfter(node);
r.collapse(true);
sel().removeAllRanges();
sel().addRange(r);
}
function exec(cmd, val) { document.execCommand(cmd, false, val || null); doc.focus(); }
function insertNode(node) {
var s = sel();
if (s.rangeCount && doc.contains(s.anchorNode) && s.anchorNode !== doc) {
var r = s.getRangeAt(0);
r.deleteContents();
r.insertNode(node);
var gap = document.createTextNode(" ");
node.after(gap);
placeAfter(gap);
} else {
var p = el("p");
p.appendChild(node);
doc.insertBefore(p, doc.lastElementChild);
}
}
// ---- paragraph styles (moved by hand; formatBlock refuses inside input)
function styleOf(b) {
if (!b) return "p";
var t = b.tagName.toLowerCase();
if (t === "blockquote") return b.getAttribute("data-alert") || "blockquote";
if (t === "ul" && b.querySelector(":scope > li.task")) return "task";
// Drawn a level down: an h2 is markdown's #.
if (/^h[2-4]$/.test(t)) return "h" + (+t[1] - 1);
return /^(ul|ol)$/.test(t) ? t : "p";
}
function setStyle(v) {
var b = blockAt();
if (!b || b.tagName === "PRE" || b.tagName === "HR" || b.tagName === "SUMMARY" || b.tagName === "DETAILS") return;
var items = b.tagName === "UL" || b.tagName === "OL" || (b.tagName === "BLOCKQUOTE" && b.firstElementChild) ? Array.prototype.slice.call(b.children) : [b];
function move(from, to) {
Array.prototype.slice.call(from.childNodes).forEach(function (c) {
if (!(c.nodeType === 1 && (c.tagName === "INPUT" || c.tagName === "UL" || c.tagName === "OL"))) to.appendChild(c);
});
if (!to.textContent && !to.querySelector("img, input")) to.innerHTML = "<br>";
else if (!to.textContent && to.querySelector("input")) to.appendChild(document.createTextNode(ZW));
return to;
}
var made = [], caret = null;
if (v === "ul" || v === "ol" || v === "task") {
var list = el(v === "ol" ? "ol" : "ul");
items.forEach(function (it) {
var li = el("li");
if (v === "task") { li.className = "task"; li.appendChild(el("input", { type: "checkbox", contenteditable: "false" })); }
var old = it.querySelector && it.querySelector(":scope > input[type=checkbox]");
if (old && v === "task") li.firstChild.checked = old.checked;
move(it, li);
list.appendChild(li);
caret = li;
});
made.push(list);
} else if (v === "blockquote" || ALERTS[v]) {
var q = el("blockquote");
if (ALERTS[v]) { q.setAttribute("data-alert", v); q.setAttribute("data-label", ALERTS[v]); }
items.forEach(function (it) { var p = move(it, el("p")); q.appendChild(p); caret = p; });
made.push(q);
} else {
// Headings by their markdown level, drawn a level down.
var tag = /^h[1-6]$/.test(v) ? "h" + Math.min(+v[1] + 1, 6) : v;
items.forEach(function (it) { var n = move(it, el(tag)); made.push(n); caret = n; });
}
b.replaceWith.apply(b, made);
lastBlock = made[made.length - 1];
if (caret) placeIn(caret);
after();
}
// ---- markdown as you type ------------------------------------------------
var BLOCK_RULES = [[/^#\s$/, "h1"], [/^##\s$/, "h2"], [/^###\s$/, "h3"], [/^[-*]\s$/, "ul"], [/^1[.)]\s$/, "ol"], [/^\[\s?\]\s$/, "task"], [/^>\s$/, "blockquote"]];
var INLINE_RULES = [
[/!\[([^\]]*)\]\((https:\/\/[^)\s]+)\)$/, "img"], [/\[([^\]]+)\]\(([^)\s]+)\)$/, "a"], [/\*\*([^*\s][^*]*)\*\*$/, "strong"],
[/__([^_\s][^_]*)__$/, "strong"], [/~~([^~]+)~~$/, "s"], [/`([^`]+)`$/, "code"],
[/(?:^|[^\w*])\*([^*\s][^*]*)\*$/, "em"], [/(?:^|[^\w_])_([^_\s][^_]*)_$/, "em"]
];
function beforeCaret() {
var s = sel();
if (!s.rangeCount || !s.isCollapsed) return null;
var n = s.anchorNode;
if (!n || n.nodeType !== 3) return null;
return { node: n, off: s.anchorOffset, text: n.nodeValue.slice(0, s.anchorOffset) };
}
function onType(ev) {
if (ev.inputType !== "insertText") return;
var b = blockAt();
if (ev.data === " " && b) {
var an = sel().anchorNode, li = an && (an.nodeType === 3 ? an.parentNode : an).closest("li");
if (li && /^\[\s?\]\s$/.test(li.textContent.replace(/ /g, " ")) && !li.classList.contains("task")) {
li.textContent = "";
li.classList.add("task");
li.appendChild(el("input", { type: "checkbox", contenteditable: "false" }));
li.appendChild(document.createTextNode(ZW));
placeIn(li);
return;
}
if (b.tagName === "P") {
var head = b.textContent.replace(/ /g, " ");
for (var i = 0; i < BLOCK_RULES.length; i++) {
if (BLOCK_RULES[i][0].test(head)) { b.innerHTML = "<br>"; placeIn(b, true); setStyle(BLOCK_RULES[i][1]); return; }
}
}
}
var t = beforeCaret();
if (!t) return;
for (var k = 0; k < INLINE_RULES.length; k++) {
var m = INLINE_RULES[k][0].exec(t.text);
if (!m) continue;
var kind = INLINE_RULES[k][1], whole = kind === "img" ? m[0] : m[0].replace(/^[^*_~`\[]/, ""), start = t.off - whole.length;
var r = document.createRange();
r.setStart(t.node, start);
r.setEnd(t.node, t.off);
r.deleteContents();
var n = el(kind);
if (kind === "img") { n.alt = m[1]; n.src = m[2]; } else n.textContent = m[1];
if (kind === "a") n.setAttribute("href", m[2]);
r.insertNode(n);
var gap = document.createTextNode(ZW);
n.after(gap);
placeAfter(gap);
return;
}
}
function atEnd(n) {
var s = sel(), r = document.createRange();
r.selectNodeContents(n);
r.setStart(s.anchorNode, s.anchorOffset);
return r.toString().replace(//g, "") === "";
}
function atStart(n) {
var s = sel(), r = document.createRange();
if (!s.rangeCount || !s.isCollapsed) return false;
r.selectNodeContents(n);
r.setEnd(s.anchorNode, s.anchorOffset);
return r.toString().replace(//g, "") === "";
}
function onEnter(ev) {
var b = blockAt();
if (!b) return;
if (b.tagName === "SUMMARY") {
ev.preventDefault();
var first = b.nextElementSibling;
if (!first) { first = el("p"); first.innerHTML = "<br>"; b.after(first); }
placeIn(first, true);
return;
}
var txt = b.textContent.replace(//g, "");
if (b.tagName === "P" && /^```(\w*)$/.test(txt)) {
ev.preventDefault();
var pre = el("pre", { "data-lang": RegExp.$1 });
pre.innerHTML = "<code>" + ZW + "</code>";
b.replaceWith(pre);
lastBlock = pre;
placeIn(pre.firstChild);
after();
return;
}
if (b.tagName === "BLOCKQUOTE" || b.tagName === "UL" || b.tagName === "OL") {
var an = sel().anchorNode, line = an && (an.nodeType === 3 ? an.parentNode : an).closest("li, blockquote > p");
if (line && b.contains(line) && line.textContent.replace(//g, "").trim() === "" && !line.querySelector("ul,ol")) {
var parentList = line.parentNode;
if (line.tagName === "LI" && parentList !== b) { ev.preventDefault(); outdent(line); after(); return; }
if (line === b.lastElementChild || line === parentList.lastElementChild) {
ev.preventDefault();
line.remove();
var np = el("p");
np.innerHTML = "<br>";
if (!b.children.length) b.replaceWith(np); else b.after(np);
placeIn(np);
after();
return;
}
}
if (line && line.classList && line.classList.contains("task")) {
// A new task line is a task too.
ev.preventDefault();
var li = el("li", { class: "task" });
li.appendChild(el("input", { type: "checkbox", contenteditable: "false" }));
li.appendChild(document.createTextNode(ZW));
line.after(li);
placeIn(li);
after();
return;
}
}
if (b.tagName === "PRE") {
ev.preventDefault();
if (/\n?$/.test(b.textContent) && atEnd(b)) {
b.firstChild.textContent = b.textContent.replace(/\n?$/, "");
var p = el("p");
p.innerHTML = "<br>";
b.after(p);
placeIn(p);
after();
return;
}
document.execCommand("insertText", false, "\n");
}
}
function liAt() {
var n = sel().anchorNode;
return n && doc.contains(n) ? (n.nodeType === 3 ? n.parentNode : n).closest("li") : null;
}
function indent(li) {
var prev = li.previousElementSibling;
if (!prev) return;
var tag = li.parentNode.tagName, sub = Array.prototype.find.call(prev.children, function (c) { return c.tagName === tag; });
if (!sub) { sub = el(tag.toLowerCase()); prev.appendChild(sub); }
var r = sel().getRangeAt(0).cloneRange();
sub.appendChild(li);
sel().removeAllRanges();
sel().addRange(r);
}
function outdent(li) {
var list = li.parentNode, holder = list.parentNode;
if (holder.tagName !== "LI") return;
var r = sel().rangeCount ? sel().getRangeAt(0).cloneRange() : null;
holder.after(li);
if (!list.children.length) list.remove();
if (r) { sel().removeAllRanges(); sel().addRange(r); }
}
// ---- the formatting bar -------------------------------------------------
var saved = null;
function placeFmt() {
var s = sel(), inDoc = s.rangeCount && doc.contains(s.anchorNode) && document.activeElement === doc && !host.hidden;
if (fmt.classList.contains("linking") || fmt.contains(document.activeElement)) return;
var b = blockAt(), inCode = !!(b && b.tagName === "PRE");
fmt.classList.toggle("coding", inCode);
if (inCode) langIn.value = b.getAttribute("data-lang") || "";
if (touch) {
fmt.classList.toggle("show", !!inDoc);
fmt.classList.add("dock");
var vv = window.visualViewport;
fmt.style.bottom = (vv ? Math.max(8, innerHeight - vv.height - vv.offsetTop + 8) : 8) + "px";
} else {
if (!inDoc || (s.isCollapsed && !inCode)) { fmt.classList.remove("show"); return; }
var r = inCode ? b.getBoundingClientRect() : s.getRangeAt(0).getBoundingClientRect();
fmt.classList.add("show");
var w = fmt.offsetWidth, x = inCode ? r.right - w : Math.min(Math.max(8, r.left + r.width / 2 - w / 2), innerWidth - w - 8), y = r.top - fmt.offsetHeight - 8;
if (y < 8) y = r.bottom + 8;
fmt.style.left = Math.max(8, x) + "px";
fmt.style.top = y + "px";
}
styleSel.value = styleOf(b);
["bold", "italic", "strikeThrough"].forEach(function (c) {
var on = false;
try { on = document.queryCommandState(c); } catch (e) {}
fmt.querySelector('[data-cmd="' + c + '"]').setAttribute("aria-pressed", on ? "true" : "false");
});
}
function toggleCode() {
var s = sel();
if (!s.rangeCount) return;
var r = s.getRangeAt(0), c = r.commonAncestorContainer, inCode = (c.nodeType === 3 ? c.parentNode : c).closest("code");
if (inCode && doc.contains(inCode)) { inCode.replaceWith(document.createTextNode(inCode.textContent)); after(); return; }
if (r.collapsed) return;
var code = el("code");
code.textContent = r.toString();
r.deleteContents();
r.insertNode(code);
after();
}
function startLink() {
var s = sel();
if (!s.rangeCount || s.isCollapsed) return;
saved = s.getRangeAt(0).cloneRange();
fmt.classList.add("linking", "show");
href.value = "";
href.focus();
}
function finishLink(ok) {
fmt.classList.remove("linking");
if (saved) { sel().removeAllRanges(); sel().addRange(saved); }
if (ok && href.value.trim()) exec("createLink", href.value.trim());
saved = null;
doc.focus();
after();
}
function pickFile(accept) {
var s = sel();
saved = s.rangeCount && doc.contains(s.anchorNode) ? s.getRangeAt(0).cloneRange() : null;
if (!filePick) return;
// The control takes any file; Image asks the chooser for pictures.
if (accept) filePick.setAttribute("accept", accept); else filePick.removeAttribute("accept");
filePick.click();
}
fmt.addEventListener("mousedown", function (ev) { if (ev.target.closest("button") && !ev.target.closest(".rt-linkin")) ev.preventDefault(); });
fmt.addEventListener("click", function (ev) {
var b = ev.target.closest("button[data-cmd]");
if (!b) return;
var c = b.getAttribute("data-cmd");
if (c === "code") toggleCode();
else if (c === "link") startLink();
else if (c === "attach") pickFile();
else { exec(c); after(); }
});
styleSel.addEventListener("mousedown", function () { var s = sel(); saved = s.rangeCount ? s.getRangeAt(0).cloneRange() : null; });
styleSel.addEventListener("change", function () {
if (saved) { sel().removeAllRanges(); sel().addRange(saved); saved = null; }
setStyle(styleSel.value);
});
fmt.querySelector(".rt-link-ok").addEventListener("click", function () { finishLink(true); });
href.addEventListener("keydown", function (ev) {
if (ev.key === "Enter") { ev.preventDefault(); finishLink(true); }
if (ev.key === "Escape") finishLink(false);
});
var codeBlock = null;
langIn.addEventListener("focus", function () { var b = blockAt(); codeBlock = b && b.tagName === "PRE" ? b : codeBlock; });
langIn.addEventListener("input", function () { if (codeBlock) { codeBlock.setAttribute("data-lang", langIn.value.trim()); write(); } });
langIn.addEventListener("keydown", function (ev) {
if (ev.key === "Enter" || ev.key === "Escape") { ev.preventDefault(); if (codeBlock) placeIn(codeBlock.firstChild || codeBlock); }
});
// ---- tapping a link: change or remove it ----------------------------------
var lpHref = lp.querySelector(".rt-lp-href"), lpOpen = lp.querySelector(".rt-lp-open"), lpLink = null;
function openLinkPop(a) {
lpLink = a;
lpHref.value = a.getAttribute("href");
// Open follows only a web or same-site address: a draft may be
// somebody else's text, and a javascript: link is not a link.
var safe = /^(https?:|\/(?!\/))/i.test(a.getAttribute("href") || "");
lpOpen.hidden = !safe;
if (safe) lpOpen.href = a.href; else lpOpen.removeAttribute("href");
lp.classList.add("show");
var r = a.getBoundingClientRect(), w = lp.offsetWidth;
lp.style.left = Math.max(8, Math.min(r.left, innerWidth - w - 8)) + "px";
lp.style.top = (r.bottom + 8 + lp.offsetHeight > innerHeight ? r.top - lp.offsetHeight - 8 : r.bottom + 8) + "px";
}
function closeLinkPop() { lp.classList.remove("show"); lpLink = null; }
lp.querySelector(".rt-lp-save").addEventListener("click", function () {
if (lpLink && lpHref.value.trim()) lpLink.setAttribute("href", lpHref.value.trim());
closeLinkPop();
after();
});
lpHref.addEventListener("keydown", function (ev) {
if (ev.key === "Enter") { ev.preventDefault(); lp.querySelector(".rt-lp-save").click(); }
if (ev.key === "Escape") closeLinkPop();
});
lp.querySelector(".rt-lp-remove").addEventListener("click", function () {
if (lpLink) lpLink.replaceWith.apply(lpLink, Array.prototype.slice.call(lpLink.childNodes));
closeLinkPop();
after();
});
// ---- one menu, two uses: "/" inserts, # ! @ [[ link quarry things ----------
var mItems = [], mIdx = 0, mState = null;
function emptyHere(b) { if (b && b.tagName === "P") { b.innerHTML = "<br>"; placeIn(b); } }
var INSERT = [
["Heading 1", "#", function () { setStyle("h1"); }], ["Heading 2", "##", function () { setStyle("h2"); }],
["Heading 3", "###", function () { setStyle("h3"); }],
["Bulleted list", "-", function () { setStyle("ul"); }], ["Numbered list", "1.", function () { setStyle("ol"); }],
["Task list", "[ ]", function () { setStyle("task"); }], ["Quote", ">", function () { setStyle("blockquote"); }],
["Note", "[!NOTE]", function () { setStyle("note"); }], ["Warning", "[!WARNING]", function () { setStyle("warning"); }],
["Code block", "```", function () {
var b = blockAt(), pre = el("pre", { "data-lang": "" });
pre.innerHTML = "<code>" + ZW + "</code>";
b.replaceWith(pre);
lastBlock = pre;
placeIn(pre.firstChild);
}],
["Table", "| |", function () { openTablePick(); }],
["Diagram", "pikchr", function () { insertCard("diagram", '```pikchr\nbox "one" fit; arrow; box "two" fit\n```'); }],
["Poll", "ballot", function () { insertCard("poll", "```poll\nquestion: Your question?\n- option one\n- option two\n```"); }],
["Fold", "details", function () { insertFold(); }],
["Divider", "---", function () {
var b = blockAt(), hr = el("hr"), p = el("p");
p.innerHTML = "<br>";
b.replaceWith(hr);
hr.after(p);
placeIn(p);
}]
];
if (filePick) {
INSERT.push(["Image", "picture", function () { pickFile("image/*"); }]);
INSERT.push(["Attach a file", "any file", function () { pickFile(); }]);
}
function insertCard(kind, src) {
var b = blockAt(), c = view(kind, src + "\n\n", ""), p = el("p");
p.innerHTML = "<br>";
if (b) b.replaceWith(c); else doc.insertBefore(c, doc.lastElementChild);
c.after(p);
placeIn(p);
lastBlock = p;
renderInto(c, src, c.querySelector(".rt-card-view"));
openCard(c);
}
function insertFold() {
var b = blockAt(), box = el("div");
box.innerHTML = foldHTML("<details><summary>Summary</summary>\n\nWhat it hides.\n\n</details>");
var f = box.firstChild;
if (b) b.replaceWith(f); else doc.insertBefore(f, doc.lastElementChild);
lastBlock = f;
var sm = f.querySelector("summary"), r = document.createRange();
r.selectNodeContents(sm);
sel().removeAllRanges();
sel().addRange(r);
doc.focus();
}
// "/ Table": tap out the size, and an empty table of that size opens.
var tpGrid = tp.querySelector(".rt-tp-grid"), tpL = tp.querySelector(".rt-tp-l"), tpAt = null;
function markTP(c, r) {
Array.prototype.forEach.call(tpGrid.children, function (b, i) { b.classList.toggle("on", i % 6 < c && Math.floor(i / 6) < r); });
tpL.textContent = c + " × " + r;
}
for (var ti = 0; ti < 36; ti++) (function (ti) {
var c = ti % 6 + 1, r = Math.floor(ti / 6) + 1;
var b = el("button", { type: "button", "aria-label": c + " columns, " + r + " rows" });
b.addEventListener("pointerenter", function () { markTP(c, r); });
b.addEventListener("focus", function () { markTP(c, r); });
b.addEventListener("mousedown", function (ev) { ev.preventDefault(); });
b.addEventListener("click", function () { makeTable(c, r); });
tpGrid.appendChild(b);
})(ti);
function openTablePick() {
tpAt = blockAt();
var r = caretRect();
markTP(0, 0);
tpL.textContent = "Columns × rows";
tp.classList.add("show");
var x = r ? r.left : 16, y = r ? r.bottom + 6 : 80;
if (y + tp.offsetHeight > innerHeight - 8 && r) y = Math.max(8, r.top - tp.offsetHeight - 6);
tp.style.left = Math.max(8, Math.min(x, innerWidth - tp.offsetWidth - 8)) + "px";
tp.style.top = y + "px";
}
function closeTablePick() { tp.classList.remove("show"); }
function makeTable(c, r) {
closeTablePick();
if (tpAt && doc.contains(tpAt)) placeIn(tpAt);
var hd = [], al = [], empty = [], body = [], i;
for (i = 0; i < c; i++) { hd.push("Column " + (i + 1)); al.push("---"); empty.push(" "); }
for (i = 0; i < r; i++) body.push("| " + empty.join(" | ") + " |");
insertCard("table", ["| " + hd.join(" | ") + " |", "| " + al.join(" | ") + " |"].concat(body).join("\n"));
after();
}
function showMenu(rows, head) {
menu.innerHTML = head ? '<div class="rt-menu-h">' + esc(head) + "</div>" : "";
mItems = rows;
mIdx = 0;
if (!rows.length) menu.insertAdjacentHTML("beforeend", '<div class="rt-none">' + (mState && mState.busy ? "Searching…" : "No match") + "</div>");
rows.forEach(function (row, i) {
var bt = el("button", { type: "button", role: "option" });
bt.innerHTML = row.html;
bt.addEventListener("mousedown", function (ev) { ev.preventDefault(); choose(i); });
menu.appendChild(bt);
});
var r = caretRect();
if (!r) return;
menu.classList.add("show");
var x = Math.min(r.left, innerWidth - menu.offsetWidth - 8), y = r.bottom + 6;
if (y + menu.offsetHeight > (window.visualViewport ? visualViewport.height : innerHeight) - (touch ? 60 : 8)) y = Math.max(8, r.top - menu.offsetHeight - 6);
menu.style.left = Math.max(8, x) + "px";
menu.style.top = y + "px";
markMenu();
}
function markMenu() { Array.prototype.forEach.call(menu.querySelectorAll("button"), function (b, i) { b.classList.toggle("sel", i === mIdx); }); }
function closeMenu() { menu.classList.remove("show"); mState = null; }
function choose(i) {
var row = mItems[i];
if (!row) return;
var st = mState;
closeMenu();
row.go(st);
after();
}
function openSlash() {
mState = { kind: "/" };
showMenu(INSERT.map(function (it) {
return { html: esc(it[0]) + "<small>" + esc(it[1]) + "</small>", go: function () { emptyHere(blockAt()); it[2](); } };
}), "Insert");
}
// The pickers: after #, !, @ or [[, quarry is asked what that could
// name here, and the choice goes in as the reference, drawn as it
// will read.
var KINDS = { "#": ["ticket", "Tickets"], "!": ["landing", "Landings"], "@": ["person", "People"], "[[": ["wiki", "Wiki pages"] };
var askTimer = null, askSeq = 0;
function refMenu() {
var t = beforeCaret();
if (!t) { closeMenu(); return; }
var st = mState, q = t.text.slice(st.start + st.trig.length);
if (st.node !== t.node || t.off < st.start + st.trig.length || /[\s\]]/.test(q) || q.length > 40) { closeMenu(); return; }
st.q = q;
clearTimeout(askTimer);
askTimer = setTimeout(function () {
var mine = ++askSeq;
fetch(suggestURL + "?k=" + KINDS[st.trig][0] + "&q=" + encodeURIComponent(q), { headers: { Accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : { picks: [] }; })
.catch(function () { return { picks: [] }; })
.then(function (out) {
if (mine !== askSeq || mState !== st) return;
st.busy = false;
showMenu((out.picks || []).map(function (x) {
return { html: '<span class="k">' + esc(x.tok.replace(/^\[\[|\]\]$/g, "")) + "</span>" + esc(x.text || ""), go: function (s) {
var r = document.createRange();
r.setStart(s.node, s.start);
r.setEnd(s.node, Math.min(s.node.nodeValue.length, s.start + s.trig.length + s.q.length));
r.deleteContents();
var box = el("span");
box.innerHTML = refHTML(x.tok);
var chip = box.firstChild;
r.insertNode(chip);
var sp = document.createTextNode(" ");
chip.after(sp);
placeAfter(sp);
} };
}), KINDS[st.trig][1]);
});
}, 120);
}
function maybeOpenRef(ev) {
var t = beforeCaret();
if (!t || ev.inputType !== "insertText" || !suggestURL) return false;
var trig = /\[\[$/.test(t.text) ? "[[" : /(^|[\s(])[#!@]$/.test(t.text) ? t.text.slice(-1) : null;
if (!trig) return false;
mState = { kind: "ref", trig: trig, node: t.node, start: t.off - trig.length, busy: true };
showMenu([], KINDS[trig][1]);
refMenu();
return true;
}
// ---- cards ----------------------------------------------------------------
function openCard(c) {
if (c.classList.contains("open")) return;
c.classList.add("open");
var kind = c.getAttribute("data-kind"), box = el("div", { class: "rt-card-src" }), foot = el("div", { class: "rt-card-foot" });
var viewEl = c.querySelector(".rt-card-view"), timer = null;
function update(src) {
c.setAttribute("data-src", src + "\n\n");
write();
clearTimeout(timer);
timer = setTimeout(function () { renderInto(c, src, viewEl); }, 250);
}
var first = null, cur = c.getAttribute("data-src");
if (kind === "poll") {
var d = pollOf(cur);
var q = el("input", { class: "rt-in", "aria-label": "question", placeholder: "Question" });
q.value = d.q;
var list = el("div", { class: "rt-opts" }), add = el("button", { type: "button", class: "btn" }, "+ Option");
var closes = el("input", { type: "date", class: "rt-in", "aria-label": "closes" });
closes.value = /^\d{4}-\d\d-\d\d$/.test(d.closes) ? d.closes : "";
var drawOpts = function () {
list.textContent = "";
d.opts.forEach(function (o, i) {
var row = el("div", { class: "rt-row" }), inp = el("input", { class: "rt-in", "aria-label": "option " + (i + 1) });
var x = el("button", { type: "button", class: "rt-x", "aria-label": "remove option " + (i + 1) }, "×");
inp.value = o;
inp.addEventListener("input", function () { d.opts[i] = inp.value; update(pollSrc(d)); });
inp.addEventListener("keydown", function (ev) {
if (ev.key === "Enter") { ev.preventDefault(); d.opts.splice(i + 1, 0, ""); drawOpts(); update(pollSrc(d)); list.querySelectorAll("input")[i + 1].focus(); }
});
x.addEventListener("click", function () { if (d.opts.length > 1) { d.opts.splice(i, 1); drawOpts(); update(pollSrc(d)); } });
row.appendChild(inp);
row.appendChild(x);
list.appendChild(row);
});
};
drawOpts();
q.addEventListener("input", function () { d.q = q.value; update(pollSrc(d)); });
closes.addEventListener("input", function () { d.closes = closes.value; update(pollSrc(d)); });
add.addEventListener("click", function () { d.opts.push(""); drawOpts(); update(pollSrc(d)); list.lastElementChild.querySelector("input").focus(); });
var cl = el("label", { class: "rt-l" }, "Closes ");
cl.appendChild(closes);
var bar = el("div", { class: "rt-bar" });
bar.appendChild(add);
bar.appendChild(cl);
box.appendChild(q);
box.appendChild(list);
box.appendChild(bar);
foot.appendChild(el("span", {}, "Readers vote once it is posted"));
first = q;
} else if (kind === "table") {
var t = tableOf(cur), grid = el("div", { class: "rt-grid" });
var drawGrid = function () {
grid.textContent = "";
var n = Math.max.apply(null, t.rows.map(function (r) { return r.length; }));
t.rows.forEach(function (r) { while (r.length < n) r.push(""); });
var tbl = el("table", { class: "rt-table" }), hx = el("tr", { class: "rt-colx" });
for (var j = 0; j < n; j++) (function (j) {
var td = el("td"), x = el("button", { type: "button", class: "rt-x", "aria-label": "remove column " + (j + 1) }, "×");
x.addEventListener("click", function () {
if (n > 1) { t.rows.forEach(function (r) { r.splice(j, 1); }); t.align.splice(j, 1); drawGrid(); update(tableSrc(t)); }
});
td.appendChild(x);
hx.appendChild(td);
})(j);
hx.appendChild(el("td"));
tbl.appendChild(hx);
t.rows.forEach(function (r, i) {
var tr = el("tr");
r.forEach(function (cell, j) {
var td = el(i ? "td" : "th"), inp = el("input", { class: "rt-cell", "aria-label": (i ? "row " + i : "header") + ", column " + (j + 1) });
inp.value = cell.replace(/\\\|/g, "|");
inp.addEventListener("input", function () { t.rows[i][j] = inp.value.replace(/\|/g, "\\|"); update(tableSrc(t)); });
if (i === t.rows.length - 1 && j === r.length - 1) {
inp.addEventListener("keydown", function (ev) {
if (ev.key === "Tab" && !ev.shiftKey) {
ev.preventDefault();
t.rows.push(t.rows[0].map(function () { return ""; }));
drawGrid();
update(tableSrc(t));
var ins = grid.querySelectorAll("input");
ins[ins.length - r.length].focus();
}
});
}
td.appendChild(inp);
tr.appendChild(td);
});
var tx = el("td");
if (i) {
var x = el("button", { type: "button", class: "rt-x", "aria-label": "remove row " + i }, "×");
x.addEventListener("click", function () { t.rows.splice(i, 1); drawGrid(); update(tableSrc(t)); });
tx.appendChild(x);
}
tr.appendChild(tx);
tbl.appendChild(tr);
});
grid.appendChild(tbl);
};
drawGrid();
var ar = el("button", { type: "button", class: "btn" }, "+ Row"), ac = el("button", { type: "button", class: "btn" }, "+ Column");
ar.addEventListener("click", function () { t.rows.push(t.rows[0].map(function () { return ""; })); drawGrid(); update(tableSrc(t)); });
ac.addEventListener("click", function () { t.rows.forEach(function (r, i) { r.push(i ? "" : "Column"); }); drawGrid(); update(tableSrc(t)); });
var tb = el("div", { class: "rt-bar" });
tb.appendChild(ar);
tb.appendChild(ac);
box.appendChild(grid);
box.appendChild(tb);
foot.appendChild(el("span", {}, "Tab moves between cells; Tab in the last cell adds a row"));
first = grid.querySelector("input");
} else {
var src = el("textarea", { class: "rt-srcbox", "aria-label": "this block's markdown", spellcheck: "false" });
src.value = cur.replace(/\n+$/, "");
box.appendChild(src);
var size = function () { src.style.height = "auto"; src.style.height = src.scrollHeight + 2 + "px"; };
foot.appendChild(el("span", {}, kind === "diagram" ? "pikchr, drawn as you type" : "Markdown, drawn as you type"));
if (kind === "diagram") {
var studio = el("a", { class: "btn", target: "_blank", rel: "noopener", href: STUDIO, "data-hook": "rich-studio" }, "Edit in pikchr-studio ↗");
var relink = function () { studioLink(fenceBody(src.value), function (u) { studio.href = u; }); };
relink();
foot.appendChild(studio);
src.addEventListener("input", relink);
}
src.addEventListener("input", function () { size(); update(src.value); });
setTimeout(size, 0);
first = src;
}
var done = el("button", { type: "button", class: "btn-primary rt-done", "data-hook": "rich-done" }, "Done");
done.addEventListener("click", function () { closeCard(c); });
foot.appendChild(done);
box.appendChild(foot);
c.insertBefore(box, viewEl);
box.addEventListener("keydown", function (ev) { if (ev.key === "Escape") { ev.preventDefault(); closeCard(c); } });
setTimeout(function () { if (first) first.focus(); }, 0);
}
function closeCard(c) {
c.classList.remove("open");
var s = c.querySelector(".rt-card-src");
if (s) s.remove();
// An emptied card is no block at all.
if (!c.getAttribute("data-src").trim()) c.remove();
write();
}
// ---- paste and drop ---------------------------------------------------------
var KEEP = { P: 1, H2: 1, H3: 1, H4: 1, UL: 1, OL: 1, LI: 1, BLOCKQUOTE: 1, PRE: 1, CODE: 1, STRONG: 1, B: 1, EM: 1, I: 1, S: 1, DEL: 1, A: 1, BR: 1 };
function clean(node) {
var out = document.createDocumentFragment();
node.childNodes.forEach(function (n) {
if (n.nodeType === 3) { out.appendChild(document.createTextNode(n.nodeValue)); return; }
if (n.nodeType !== 1 || /^(SCRIPT|STYLE|META|LINK|TITLE|IMG)$/.test(n.tagName)) return;
var t = /^(DIV|SECTION|ARTICLE)$/.test(n.tagName) ? "P" : /^H[1-6]$/.test(n.tagName) ? "H" + Math.min(+n.tagName[1] + 1, 4) : n.tagName;
if (!KEEP[t]) { out.appendChild(clean(n)); return; }
var e = el(t);
if (t === "A" && /^https?:/.test(n.getAttribute("href") || "")) e.setAttribute("href", n.getAttribute("href"));
e.appendChild(clean(n));
out.appendChild(e);
});
return out;
}
doc.addEventListener("paste", function (ev) {
var cd = ev.clipboardData;
if (!cd) return;
// Files are attach.js's: it stages them and tells us their names.
if (cd.files && cd.files.length) { if (!ctl) ev.preventDefault(); return; }
var text = cd.getData("text/plain"), html = cd.getData("text/html"), s = sel();
ev.preventDefault();
if (/^https?:\/\/\S+$/.test(text.trim()) && s.rangeCount && !s.isCollapsed) { exec("createLink", text.trim()); after(); return; }
if (html) {
var box = el("div"), tmp = el("div");
box.innerHTML = html;
tmp.appendChild(clean(box));
document.execCommand("insertHTML", false, tmp.innerHTML);
} else document.execCommand("insertText", false, text);
after();
});
doc.addEventListener("drop", function (ev) {
if (!ev.dataTransfer || !ev.dataTransfer.files || !ev.dataTransfer.files.length) return;
if (document.caretRangeFromPoint) {
var r = document.caretRangeFromPoint(ev.clientX, ev.clientY);
if (r && doc.contains(r.startContainer)) { sel().removeAllRanges(); sel().addRange(r); }
}
});
// A file staged by the attachment control goes in where the caret
// was: an image as itself, anything else as a link to it.
root.addEventListener("attach-staged", function (ev) {
if (host.hidden) return;
var d = ev.detail || {}, ref = "attach:" + encodeURI(d.name).replace(/[()]/g, function (c) { return "%" + c.charCodeAt(0).toString(16).toUpperCase(); });
if (saved && doc.contains(saved.startContainer)) { sel().removeAllRanges(); sel().addRange(saved); saved = null; }
var n;
if (d.image && d.url) {
staged[ref] = d.url;
n = el("img", { alt: d.name.replace(/\.[^.]+$/, ""), src: d.url, "data-upload": ref });
} else {
n = el("a", { href: ref });
n.textContent = d.name;
}
insertNode(n);
after();
});
// ---- wiring -------------------------------------------------------------------
doc.addEventListener("mousedown", function (ev) {
var v = ev.target.closest(".rt-view");
if (v && v.parentNode === doc) {
ev.preventDefault();
var x = ev.clientX, y = ev.clientY;
settle(lastBlock, true);
if (v.isConnected) open(v, x, y);
after();
return;
}
var c = ev.target.closest(".rt-card");
if (c && !ev.target.closest(".rt-card-src")) { ev.preventDefault(); openCard(c); }
});
doc.addEventListener("click", function (ev) {
if (ev.target.closest(".rt-card, .rt-view")) { if (!ev.target.closest(".rt-card-src")) ev.preventDefault(); return; }
if (ev.target.closest("summary") && doc.contains(ev.target)) { ev.preventDefault(); return; }
var box = ev.target.closest("input[type=checkbox]");
if (box && doc.contains(box)) { setTimeout(function () { box.toggleAttribute("checked", box.checked); after(); }, 0); return; }
var a = ev.target.closest("a");
if (a && doc.contains(a)) { ev.preventDefault(); if (!a.hasAttribute("data-tok")) openLinkPop(a); }
});
// Undo from the Edit menu or a gesture arrives as beforeinput.
doc.addEventListener("beforeinput", function (ev) {
if (ev.inputType === "historyUndo" || ev.inputType === "historyRedo") {
ev.preventDefault();
write();
travel(ev.inputType === "historyUndo" ? -1 : 1);
}
});
doc.addEventListener("input", function (ev) {
onType(ev);
var b = blockAt();
if (ev.inputType === "insertText" && ev.data === "/" && b && b.tagName === "P" && b.textContent.replace(//g, "") === "/") openSlash();
else if (mState && mState.kind === "ref") refMenu();
else if (!maybeOpenRef(ev) && mState) closeMenu();
after();
});
// The caret beside a view or a card: Backspace and Delete open the
// view instead of deleting it whole, and step over a card.
function neighbour(back) {
var b = blockAt();
if (!b || b.parentNode !== doc) return null;
if (back ? !atStart(b) : !atEnd(b)) return null;
var n = back ? b.previousElementSibling : b.nextElementSibling;
return n && (n.classList.contains("rt-view") || n.classList.contains("rt-card")) ? n : null;
}
doc.addEventListener("keydown", function (ev) {
var mod = ev.metaKey || ev.ctrlKey;
if (menu.classList.contains("show")) {
if (ev.key === "ArrowDown") { ev.preventDefault(); mIdx = (mIdx + 1) % Math.max(1, mItems.length); markMenu(); return; }
if (ev.key === "ArrowUp") { ev.preventDefault(); mIdx = (mIdx + mItems.length - 1) % Math.max(1, mItems.length); markMenu(); return; }
if ((ev.key === "Enter" || ev.key === "Tab") && mItems.length) { ev.preventDefault(); choose(mIdx); return; }
if (ev.key === "Escape") { closeMenu(); return; }
}
if (mod && ev.key.toLowerCase() === "b") { ev.preventDefault(); exec("bold"); after(); return; }
if (mod && ev.key.toLowerCase() === "i") { ev.preventDefault(); exec("italic"); after(); return; }
if (mod && ev.key.toLowerCase() === "k") { ev.preventDefault(); startLink(); return; }
if (mod && ev.key.toLowerCase() === "z") { ev.preventDefault(); write(); travel(ev.shiftKey ? 1 : -1); return; }
if (mod && ev.key.toLowerCase() === "y") { ev.preventDefault(); write(); travel(1); return; }
if (ev.key === "Backspace" || ev.key === "Delete" || ev.key === "ArrowUp" || ev.key === "ArrowDown") {
var back = ev.key === "Backspace" || ev.key === "ArrowUp", nb = neighbour(back);
if (nb) {
ev.preventDefault();
// The block the caret leaves settles once the caret is out.
if (nb.classList.contains("rt-view")) { var from = topOf(blockAt()); open(nb, undefined, undefined, back); lastBlock = from; watchLeave(); after(); }
else if (ev.key[0] === "A") { var past = back ? nb.previousElementSibling : nb.nextElementSibling; if (past && !past.classList.contains("rt-view") && !past.classList.contains("rt-card")) placeIn(past, !back); }
return;
}
}
if (ev.key === "Tab") {
var li = liAt();
if (li) { ev.preventDefault(); if (ev.shiftKey) outdent(li); else indent(li); after(); }
return;
}
if (ev.key === "Enter" && ev.shiftKey) { ev.preventDefault(); document.execCommand("insertLineBreak"); return; }
if (ev.key === "Enter") onEnter(ev);
});
document.addEventListener("selectionchange", function () {
if (host.hidden || fmt.contains(document.activeElement)) return;
var s = sel();
// The caret parked beside a view (arrowing past one) opens it.
if (s.rangeCount && s.anchorNode === doc) {
var v = doc.childNodes[s.anchorOffset] || doc.childNodes[s.anchorOffset - 1];
if (v && v.classList && v.classList.contains("rt-view")) { open(v); after(); return; }
}
watchLeave();
placeFmt();
});
doc.addEventListener("blur", function () {
setTimeout(function () {
if (fmt.contains(document.activeElement) || lp.contains(document.activeElement)) return;
if (!fmt.classList.contains("linking")) fmt.classList.remove("show");
if (lastBlock && document.activeElement !== doc) {
var prev = lastBlock;
lastBlock = null;
settle(prev, true);
write();
}
}, 150);
});
window.addEventListener("resize", placeFmt);
if (window.visualViewport) { visualViewport.addEventListener("resize", placeFmt); visualViewport.addEventListener("scroll", placeFmt); }
document.addEventListener("mousedown", function (ev) {
if (!menu.contains(ev.target)) closeMenu();
if (!lp.contains(ev.target) && !(ev.target.closest && ev.target.closest(".rt-doc a"))) closeLinkPop();
if (tp.classList.contains("show") && !tp.contains(ev.target) && !menu.contains(ev.target)) closeTablePick();
});
document.addEventListener("keydown", function (ev) { if (ev.key === "Escape" && tp.classList.contains("show")) closeTablePick(); });
function hide() { fmt.classList.remove("show"); closeMenu(); closeLinkPop(); closeTablePick(); }
return {
// Shown: read the textarea again unless it is what Rich wrote.
show: function () { if (written === null || written !== ta.value) load(); },
hide: hide
};
}
document.querySelectorAll(".f-composer").forEach(function (root) {
var ta = root.querySelector("textarea[data-slash]");
var tabs = root.querySelector(".f-tabs");
var split = root.querySelector(".f-split");
var richPane = root.querySelector(".f-rich");
if (!ta) return;
// 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.
if (tabs && split && richPane) {
var rich = richEditor(ta, richPane, root);
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.show();
else {
rich.hide();
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;
});
});
})();