// 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 = "\u200b";
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>'; }
// The bar's glyphs: Octicons, the set quarry's sprite is drawn from,
// inline so the composer needs nothing from the page.
var OC = {
bold: "<svg class=\"ic\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M4 2h4.5a3.501 3.501 0 0 1 2.852 5.53A3.499 3.499 0 0 1 9.5 14H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Zm1 7v3h4.5a1.5 1.5 0 0 0 0-3Zm3.5-2a1.5 1.5 0 0 0 0-3H5v3Z\"/></svg>",
italic: "<svg class=\"ic\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M6 2.75A.75.75 0 0 1 6.75 2h6.5a.75.75 0 0 1 0 1.5h-2.505l-3.858 9H9.25a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.505l3.858-9H6.75A.75.75 0 0 1 6 2.75Z\"/></svg>",
strike: "<svg class=\"ic\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M11.055 8.5c.524.536.815 1.257.811 2.007a3.133 3.133 0 0 1-1.12 2.408C9.948 13.597 8.748 14 7.096 14c-1.706 0-3.104-.607-3.902-1.377a.751.751 0 0 1 1.042-1.079c.48.463 1.487.956 2.86.956 1.422 0 2.232-.346 2.676-.726.435-.372.594-.839.594-1.267 0-.472-.208-.857-.647-1.197-.448-.346-1.116-.623-1.951-.81H1.75a.75.75 0 0 1 0-1.5h12.5a.75.75 0 0 1 0 1.5ZM7.581 3.25c-2.036 0-2.778 1.082-2.778 1.786 0 .055.002.107.006.157a.75.75 0 0 1-1.496.114 3.506 3.506 0 0 1-.01-.271c0-1.832 1.75-3.286 4.278-3.286 1.418 0 2.721.58 3.514 1.093a.75.75 0 1 1-.814 1.26c-.64-.414-1.662-.853-2.7-.853Z\"/></svg>",
code: "<svg class=\"ic\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z\"/></svg>",
link: "<svg class=\"ic\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z\"/></svg>",
ul: "<svg class=\"ic\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z\"/></svg>",
ol: "<svg class=\"ic\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M5 3.25a.75.75 0 0 1 .75-.75h8.5a.75.75 0 0 1 0 1.5h-8.5A.75.75 0 0 1 5 3.25Zm0 5a.75.75 0 0 1 .75-.75h8.5a.75.75 0 0 1 0 1.5h-8.5A.75.75 0 0 1 5 8.25Zm0 5a.75.75 0 0 1 .75-.75h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1-.75-.75ZM.924 10.32a.5.5 0 0 1-.851-.525l.001-.001.001-.002.002-.004.007-.011c.097-.144.215-.273.348-.384.228-.19.588-.392 1.068-.392.468 0 .858.181 1.126.484.259.294.377.673.377 1.038 0 .987-.686 1.495-1.156 1.845l-.047.035c-.303.225-.522.4-.654.597h1.357a.5.5 0 0 1 0 1H.5a.5.5 0 0 1-.5-.5c0-1.005.692-1.52 1.167-1.875l.035-.025c.531-.396.8-.625.8-1.078a.57.57 0 0 0-.128-.376C1.806 10.068 1.695 10 1.5 10a.658.658 0 0 0-.429.163.835.835 0 0 0-.144.153ZM2.003 2.5V6h.503a.5.5 0 0 1 0 1H.5a.5.5 0 0 1 0-1h.503V3.308l-.28.14a.5.5 0 0 1-.446-.895l1.003-.5a.5.5 0 0 1 .723.447Z\"/></svg>",
task: "<svg class=\"ic\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M2 2h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Zm4.655 8.595a.75.75 0 0 1 0 1.06L4.03 14.28a.75.75 0 0 1-1.06 0l-1.5-1.5a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l.97.97 2.095-2.095a.75.75 0 0 1 1.06 0ZM9.75 2.5h5.5a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5Zm0 5h5.5a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5Zm0 5h5.5a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5Zm-7.25-9v3h3v-3Z\"/></svg>",
quote: "<svg class=\"ic\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M1.75 2.5h10.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5Zm4 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2.5 7.75v6a.75.75 0 0 1-1.5 0v-6a.75.75 0 0 1 1.5 0Z\"/></svg>",
pre: "<svg class=\"ic\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25Zm7.47 3.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L10.69 8 9.22 6.53a.75.75 0 0 1 0-1.06ZM6.78 6.53 5.31 8l1.47 1.47a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z\"/></svg>",
clip: "<svg class=\"ic\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M12.212 3.02a1.753 1.753 0 0 0-2.478.003l-5.83 5.83a3.007 3.007 0 0 0-.88 2.127c0 .795.315 1.551.88 2.116.567.567 1.333.89 2.126.89.79 0 1.548-.321 2.116-.89l5.48-5.48a.75.75 0 0 1 1.061 1.06l-5.48 5.48a4.492 4.492 0 0 1-3.177 1.33c-1.2 0-2.345-.487-3.187-1.33a4.483 4.483 0 0 1-1.32-3.177c0-1.195.475-2.341 1.32-3.186l5.83-5.83a3.25 3.25 0 0 1 5.553 2.297c0 .863-.343 1.691-.953 2.301L7.439 12.39c-.375.377-.884.59-1.416.593a1.998 1.998 0 0 1-1.412-.593 1.992 1.992 0 0 1 0-2.828l5.48-5.48a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-5.48 5.48a.492.492 0 0 0 0 .707.499.499 0 0 0 .352.154.51.51 0 0 0 .356-.154l5.833-5.827a1.755 1.755 0 0 0 0-2.481Z\"/></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(/\u200b/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(/\u200b/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(/\u200b/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(/\u200b/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(/=+$/, ""));
});
}
// opts.chat makes it chat's box: Enter sends (root is the form),
// the bar docks above the text behind an Aa button (opts.aa), "/"
// inserts only what a chat line holds, and opts.onUpEmpty and
// opts.onEscape are chat's ↑ and Esc.
function richEditor(ta, host, root, opts) {
opts = opts || {};
var chat = !!opts.chat;
var blocksURL = host.getAttribute("data-blocks"), suggestURL = host.getAttribute("data-suggest");
var hint = chat ? ta.getAttribute("placeholder") || "Message" : "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"]];
// Chat's bar has buttons for the blocks, so its picker is the level.
if (chat) styles = styles.slice(0, 4);
fmt.innerHTML = '<div class="rt-tools"><span class="rt-pick"><select class="rt-style" aria-label="paragraph style">' +
styles.map(function (s) { return '<option value="' + s[0] + '">' + s[1] + "</option>"; }).join("") + "</select>" +
icon("i-caret-down") + "</span>" +
'<span class="rt-sep"></span>' +
'<button type="button" data-cmd="bold" aria-label="bold" title="Bold (⌘B)">' + OC.bold + "</button>" +
'<button type="button" data-cmd="italic" aria-label="italic" title="Italic (⌘I)">' + OC.italic + "</button>" +
'<button type="button" data-cmd="strikeThrough" aria-label="strikethrough" title="Strikethrough">' + OC.strike + "</button>" +
'<button type="button" data-cmd="code" aria-label="code" title="Code">' + OC.code + "</button>" +
'<button type="button" data-cmd="link" aria-label="link" title="Link (⌘K)">' + OC.link + "</button>" +
(chat ? '<span class="rt-sep"></span>' +
'<button type="button" data-cmd="ul" aria-label="bulleted list" title="Bulleted list">' + OC.ul + "</button>" +
'<button type="button" data-cmd="ol" aria-label="numbered list" title="Numbered list">' + OC.ol + "</button>" +
'<button type="button" data-cmd="task" aria-label="task list" title="Task list">' + OC.task + "</button>" +
'<button type="button" data-cmd="blockquote" aria-label="quote" title="Quote">' + OC.quote + "</button>" +
'<button type="button" data-cmd="pre" aria-label="code block" title="Code block">' + OC.pre + "</button>" : "") +
'<button type="button" data-cmd="attach" aria-label="attach a file" title="Attach a file" hidden>' + OC.clip + "</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");
// Chat's Aa: the bar docked above the text, open until closed, and
// remembered in this browser.
var docked = false;
function dock(on) {
docked = on;
fmt.classList.toggle("docked", on);
fmt.classList.toggle("show", on);
if (on) { host.insertBefore(fmt, doc); fmt.style.left = fmt.style.top = fmt.style.bottom = ""; }
else host.appendChild(fmt);
if (opts.aa) opts.aa.setAttribute("aria-pressed", on ? "true" : "false");
try { localStorage.setItem("quarry.chat.aa", on ? "1" : ""); } catch (e) {}
}
if (chat && opts.aa) {
opts.aa.hidden = false;
opts.aa.addEventListener("mousedown", function (ev) { ev.preventDefault(); });
opts.aa.addEventListener("click", function () { dock(!docked); doc.focus(); placeFmt(); });
var was = "";
try { was = localStorage.getItem("quarry.chat.aa") || ""; } catch (e) {}
if (was) dock(true);
}
// 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"); });
}
// Chat's files (opts.upload) go to the bucket the moment they are in
// the box: a line is sent with Enter, and there is no section to
// stage them in. Each upload's ticket rides with the form as a
// hidden attach field, and a send waits for any still on its way.
// attach.js does the sending; it loads after this script, so it is
// looked for when a file arrives.
var up = chat && opts.upload ? opts.upload : null;
var pending = 0, sendLater = false, upNames = {};
if (up) {
filePick = up.input;
filePick.addEventListener("change", function () {
var files = Array.prototype.slice.call(filePick.files || []);
filePick.value = "";
filePick.removeAttribute("accept");
sendFiles(files);
});
}
function upWords(msg) {
if (!up.error) return;
up.error.textContent = msg || "";
up.error.hidden = !msg;
}
// The same name twice in one line would be one file (a message
// names each of its files once), so the second is name-2.
function upName(name) {
var dot = name.lastIndexOf("."), stem = dot > 0 ? name.slice(0, dot) : name, ext = dot > 0 ? name.slice(dot) : "";
for (var n = 2; upNames[name]; n++) name = stem + "-" + n + ext;
upNames[name] = true;
return name;
}
function sendFiles(files) {
if (!up || !files.length) return;
if (!window.quarryAttach) { upWords("Files cannot be sent from this browser."); return; }
upWords("");
files.forEach(function (file) {
var name = upName(window.quarryAttach.pastedName(file)), image = /^image\//.test(file.type);
var url = image && window.URL && URL.createObjectURL ? URL.createObjectURL(file) : "";
root.dispatchEvent(new CustomEvent("attach-staged", { detail: { name: name, image: image, url: url } }));
var node = doc.querySelector('[data-upload="' + refOf(name) + '"], a[href="' + refOf(name) + '"]');
if (node) node.setAttribute("data-uploading", "");
pending++;
window.quarryAttach.send(up.repo, "", "chat", file, name, null)
.then(function (b) {
if (node) node.removeAttribute("data-uploading");
root.appendChild(el("input", { type: "hidden", name: "attach", value: b.ticket, "data-ref": refOf(name) }));
}, function (e) {
if (node && node.isConnected) { node.remove(); after(); }
delete upNames[name];
upWords(name + ": " + (e.message || e));
})
.then(function () {
pending--;
if (!pending && sendLater) { sendLater = false; send(); }
});
});
}
if (up) {
// Before the form's own handler (capture, on the document): a
// send while a file is still on its way waits for it, and a file
// taken out of the line before sending is not sent.
document.addEventListener("submit", function (ev) {
if (ev.target !== root) return;
if (pending) { ev.preventDefault(); ev.stopImmediatePropagation(); sendLater = true; return; }
root.querySelectorAll("input[name=attach][data-ref]").forEach(function (i) {
if (ta.value.indexOf(i.getAttribute("data-ref")) < 0) i.remove();
});
}, true);
}
// ---- 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(/\u200b/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(/\u200b/g, "") === "" && !n.querySelector("img, .rt-keep")) return;
var md = mdNode(n);
if (!md.replace(/\u200b/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;
// A post keeps a line to type on after its last block; a chat line
// is its blocks, and the caret goes into the last one instead.
if (!last || (!chat && (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(/\u200b/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) };
}
// A reference typed out in full becomes its chip once it ends, as a
// picked one does: the space or stop typed after it closes it. Not
// in code, where it is text.
function chipTyped(t) {
var here = t.node.parentNode;
if (here && here.closest && here.closest("code, pre, a")) return false;
var before = t.text.slice(0, -1);
var m = /(^|[\s(])((?:[\w.-]+:)?[#!][1-9]\d{0,8}|@[A-Za-z0-9](?:[\w.-]{0,62}[A-Za-z0-9])?|\[\[[^\]\n]+\]\])$/.exec(before);
if (!m) return false;
var r = document.createRange();
r.setStart(t.node, before.length - m[2].length);
r.setEnd(t.node, before.length);
r.deleteContents();
var box = el("span");
box.innerHTML = refHTML(m[2]);
var chip = box.firstChild;
r.insertNode(chip);
var next = chip.nextSibling, r2 = document.createRange();
if (next && next.nodeType === 3 && next.nodeValue.length) r2.setStart(next, 1); else r2.setStartAfter(chip);
r2.collapse(true);
sel().removeAllRanges();
sel().addRange(r2);
return true;
}
function onType(ev) {
if (ev.inputType !== "insertText") return;
if (/^[\s.,;:!?)]$/.test(ev.data)) {
var t0 = beforeCaret();
if (t0 && chipTyped(t0)) { if (mState && mState.kind === "ref") closeMenu(); 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(/\u200b/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(/\u200b/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(/\u200b/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(/\u200b/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();
var code = b.querySelector("code") || b, text = code.textContent.replace(/\u200b/g, "");
// Enter on the empty last line leaves the block.
if (atEnd(b) && (text === "" || /\n$/.test(text))) {
code.textContent = text.replace(/\n$/, "") || ZW;
var p = el("p");
p.innerHTML = "<br>";
b.after(p);
placeIn(p);
after();
return;
}
// A newline, with a zero-width mark after one at the end so the
// empty last line shows and the caret has somewhere to stand.
var s = sel(), r = s.getRangeAt(0), nl = document.createTextNode(atEnd(b) ? "\n" + ZW : "\n");
r.deleteContents();
r.insertNode(nl);
var r2 = document.createRange();
r2.setStart(nl, 1);
r2.collapse(true);
s.removeAllRanges();
s.addRange(r2);
after();
}
}
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 (docked) { syncFmt(b); return; }
// Chat on a phone formats through its Aa bar, never a floating one.
if (chat && touch) { fmt.classList.remove("show"); return; }
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";
}
syncFmt(b);
}
function syncFmt(b) {
var v = styleOf(b);
styleSel.value = Array.prototype.some.call(styleSel.options, function (o) { return o.value === v; }) ? v : "p";
["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 if (c === "pre") codeBlock();
else if (c === "ul" || c === "ol" || c === "task" || c === "blockquote") setStyle(c);
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 () { codeBlock(); }],
["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);
}]
];
// A chat line holds headings, lists, a quote and code; the rest is
// the forum's.
if (chat) INSERT = INSERT.filter(function (it) { return /^(Heading|Bulleted|Numbered|Task|Quote|Code)/.test(it[0]); });
if (filePick) {
INSERT.push(["Image", "picture", function () { pickFile("image/*"); }]);
INSERT.push(["Attach a file", "any file", function () { pickFile(); }]);
}
function codeBlock() {
var b = blockAt(), pre = el("pre", { "data-lang": "" });
pre.innerHTML = "<code>" + ZW + "</code>";
if (b && b.tagName === "P" && !b.textContent.replace(/\u200b/g, "")) b.replaceWith(pre);
else if (b) b.after(pre); else doc.insertBefore(pre, doc.lastElementChild);
lastBlock = pre;
placeIn(pre.firstChild);
after();
}
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 (up) { ev.preventDefault(); sendFiles(Array.prototype.slice.call(cd.files)); return; }
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); }
}
if (up) { ev.preventDefault(); sendFiles(Array.prototype.slice.call(ev.dataTransfer.files)); }
});
if (up) doc.addEventListener("dragover", function (ev) {
if (ev.dataTransfer && Array.prototype.indexOf.call(ev.dataTransfer.types, "Files") >= 0) ev.preventDefault();
});
function refOf(name) {
return "attach:" + encodeURI(name).replace(/[()]/g, function (c) { return "%" + c.charCodeAt(0).toString(16).toUpperCase(); });
}
// 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 = refOf(d.name);
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(/\u200b/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 (chat) {
var cb = blockAt(), inBlock = !!(cb && /^(PRE|UL|OL|BLOCKQUOTE|DETAILS|SUMMARY)$/.test(cb.tagName));
if (ev.key === "Escape" && opts.onEscape) { ev.preventDefault(); opts.onEscape(); return; }
if (ev.key === "ArrowUp" && opts.onUpEmpty && !doc.textContent.replace(/\u200b/g, "").trim()) { ev.preventDefault(); opts.onUpEmpty(); return; }
// Enter sends; ⇧Enter is a new line. Inside a block Enter keeps
// its block meaning and Enter on its empty line leaves it.
if (ev.key === "Enter" && !inBlock) {
ev.preventDefault();
if (ev.shiftKey) { document.execCommand("insertParagraph"); after(); return; }
send();
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(); }
// Chat sends the form; the server empties the field when the line is
// in, and Rich follows the field: whatever it holds that Rich did
// not write, it reads again.
function send() {
lastBlock = null;
write();
if (!ta.value.trim()) return;
if (root.requestSubmit) root.requestSubmit(); else root.submit();
}
if (chat) setInterval(function () {
if (host.hidden || ta.value === written) return;
written = ta.value;
load();
// Emptied: the line was sent (the server empties the field), or
// its writer cleared it. Either way its files are spent.
if (up && !ta.value && !pending) {
root.querySelectorAll("input[name=attach][data-ref]").forEach(function (i) { i.remove(); });
upNames = {};
upWords("");
}
}, 300);
return {
// Shown: read the textarea again unless it is what Rich wrote.
show: function () { if (written === null || written !== ta.value) load(); },
hide: hide,
focus: function () {
var last = doc.lastElementChild;
if (last && last.classList.contains("rt-view")) open(last, undefined, undefined, true);
else if (last) placeIn(last);
after();
}
};
}
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);
}
});
// Chat: the channel's box is Rich, with Enter to send and the Aa bar,
// and so is the box an edit opens under one of your messages. The
// textarea stays the field the form posts, and the box without
// script.
function chatRich(ta, host, form, opts) {
form.classList.add("chat-rich-on");
host.hidden = false;
var rich = richEditor(ta, host, form, opts);
rich.show();
return rich;
}
// editable says a message's edit control is still inside its window,
// by the deadline the server wrote on it.
function editable(d) {
var until = Number(d.getAttribute("data-edit-until"));
return !until || until > Date.now();
}
document.querySelectorAll(".chat-rich").forEach(function (host) {
var form = host.closest("form"), ta = form && form.querySelector("textarea.composer-in");
if (!ta) return;
var log = document.getElementById("chatlog");
// The paperclip, when the repository takes files: it opens the
// chooser, and paste and drop into the box send the same way.
var clip = form.querySelector("[data-hook=chat-attach]"), pick = form.querySelector("[data-hook=chat-file]");
if (clip && pick) {
clip.hidden = false;
// Pressing it keeps the caret in the box, where the file goes.
clip.addEventListener("mousedown", function (ev) { ev.preventDefault(); });
clip.addEventListener("click", function () { pick.click(); });
}
chatRich(ta, host, form, {
chat: true,
aa: form.querySelector(".chat-aa"),
upload: clip && pick ? { repo: clip.getAttribute("data-repo"), input: pick, error: form.querySelector("[data-hook=chat-file-error]") } : null,
// ↑ in an empty box edits your last message, while it may still be.
onUpEmpty: function () {
var mine = log ? log.querySelectorAll("details.chatedit") : [];
for (var i = mine.length - 1; i >= 0; i--) {
if (editable(mine[i])) { mine[i].open = true; return; }
}
}
});
// The server draws the pencil only inside the edit window; this
// takes it away when the window closes on an open page. One left
// open mid-edit stays, and its save is answered in the channel.
setInterval(function () {
document.querySelectorAll("details.chatedit").forEach(function (d) {
if (!d.open && !editable(d)) d.remove();
});
}, 10000);
document.addEventListener("toggle", function (ev) {
var d = ev.target;
if (!d.matches || !d.matches("details.chatedit")) return;
if (!d.open) return;
if (!editable(d)) { d.remove(); return; }
var eta = d.querySelector("textarea"), eform = d.querySelector("form");
if (!eta || !eform) return;
var back = eform.querySelector("input[name=back]");
if (back) back.value = location.pathname + location.search;
if (d._rich) { d._rich.show(); d._rich.focus(); return; }
var eh = document.createElement("div");
eh.className = "f-rich chat-rich-edit";
eh.setAttribute("data-blocks", host.getAttribute("data-blocks"));
eh.setAttribute("data-suggest", host.getAttribute("data-suggest"));
eta.after(eh);
d._rich = chatRich(eta, eh, eform, { chat: true, onEscape: function () { d.open = false; } });
setTimeout(function () { d._rich.focus(); }, 300);
}, true);
});
// 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;
});
});
})();