components
All repositories: gitoria
43.1 KB
// md-editor.hl — THE MARKDOWN WYSIWYG EDITOR, all Hybriel (port of worldapi-components/md-editor.js): headings look like// headings, **bold** is bold, lists are lists — while you type; the value stays plain Markdown. `MdEditor { name = "note" value = "**hi**" }`.// The formatted surface is a contenteditable element whose children are built in the browser (createElement / text nodes, never// innerHTML of user text) — the framework never re-renders it: nothing bound to it changes after the first render. Hybriel has no// mount event, so a 100 ms CSS animation on the surface fires `animationiteration` once the page is live, and that hands the// editor its first content (the mount hook turns the animation off again). The value is written to the hidden field// `<input name>` (posts in a form) and announced with a bubbling DOM `input` event on it (`e.target.value` = the Markdown).// Subset (what the tickets' markdown.hl renders): headings 1-6, paragraphs (single line breaks kept), - / 1. lists (one level),// fenced code blocks, `code`, links (http(s), mailto, /path, #anchor), *em*, **strong**.String name = 'markdown'String value = ''String placeholder = 'Write…'Number rows = 6Hybrid linkOk = ['http://', 'https://', 'mailto:', '/', '#']static NL = ""static BS = 'x\y'.slice(1, 2)static ZW = ""static NB = " "// ---- small text helpers ---------------------------------------------------------------------------------------------isDigit(c) { k = c.charCodeAt(0) return k >= 48 && k <= 57 }isAlnum(c) { k = c.charCodeAt(0) return (k >= 48 && k <= 57) || (k >= 65 && k <= 90) || (k >= 97 && k <= 122) || k > 127 }isSpace(c) { return c == ' ' || c == NB || c == NL || c == ZW }repeat(c, n) { let s = '' let i = 0 while (i < n) { s = s + c i = i + 1 } return s }// trim spaces (also the non-breaking space the browser types) and the zero-width spaceclean(s) { return s.replaceAll(NB, ' ').replaceAll(ZW, '') }trimSp(s) {let a = 0let b = s.lengthwhile (a < b && (s.slice(a, a + 1) == ' ' || s.slice(a, a + 1) == NB || s.slice(a, a + 1) == ZW)) { a = a + 1 }while (b > a && (s.slice(b - 1, b) == ' ' || s.slice(b - 1, b) == NB || s.slice(b - 1, b) == ZW)) { b = b - 1 }return s.slice(a, b)}safeHref(u) {t = trimSp(u)if (t == '' ) { return '' }for (p of linkOk) { if (t.startsWith(p)) { return t } }return ''}// an image address: http(s) or a path on this site; nothing else (no data:, no javascript:)safeSrc(u) {t = trimSp(u)if (t.startsWith('https://') || t.startsWith('http://') || (t.startsWith('/') && !t.startsWith('//'))) { return t }return ''}// the end of a bare URL that starts at i: up to a space, minus trailing punctuationurlEnd(s, i) {let j = iwhile (j < s.length && !isSpace(s.slice(j, j + 1)) && s.slice(j, j + 1) != '<') { j = j + 1 }while (j > i && ('.,;:!?)]}' + "'" + '"').indexOf(s.slice(j - 1, j)) >= 0) { j = j - 1 }return j}// ---- Markdown → DOM ---------------------------------------------------------------------------------------------------el(tag, text) {e = document.createElement(tag)if (text != null) { e.textContent = text }return e}// the closing run of c (n times) after position i, whose content is not empty and does not end in a space; -1 when nonecloser(s, i, c, n) {mark = repeat(c, n)let j = s.indexOf(mark, i)while (j >= 0) {let after = s.slice(j + n, j + n + 1)let before = s.slice(j - 1, j)let ok = j > i && !isSpace(before) && before != BS && (after != c)if (ok && c == '_' && after != '' && isAlnum(after)) { ok = false }if (ok) { return j }j = s.indexOf(mark, j + 1)}return -1}// appends the inline Markdown `s` to `host`inline(host, s) {let buf = ''let i = 0n = s.lengthwhile (i < n) {let c = s.slice(i, i + 1)let done = falseif (c == BS && i + 1 < n && '\*_`[]<>#-.!()+~|'.indexOf(s.slice(i + 1, i + 2)) >= 0) {buf = buf + s.slice(i + 1, i + 2)i = i + 2done = true}if (!done && c == '`') {let run = 1while (s.slice(i + run, i + run + 1) == '`') { run = run + 1 }let mark = repeat('`', run)let j = s.indexOf(mark, i + run)if (j > i + run) {if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }let inner = s.slice(i + run, j)if (inner.length > 2 && inner.startsWith(' ') && inner.endsWith(' ')) { inner = inner.slice(1, inner.length - 1) }host.appendChild(el('code', inner))i = j + rundone = true}}if (!done && c == '!' && s.slice(i + 1, i + 2) == '[') {let j = s.indexOf('](', i + 2)let k = j >= 0 ? s.indexOf(')', j + 2) : -1if (j > i + 1 && k > j + 2) {let src = safeSrc(s.slice(j + 2, k))if (src != '') {if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }let img = el('img', null)img.setAttribute('src', src)img.setAttribute('alt', s.slice(i + 2, j))host.appendChild(img)i = k + 1done = true}}}if (!done && c == '[') {let j = s.indexOf('](', i + 1)let k = j >= 0 ? s.indexOf(')', j + 2) : -1if (j > i + 1 && k > j + 2) {let href = safeHref(s.slice(j + 2, k))if (href != '') {if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }let a = el('a', null)a.setAttribute('href', href)inline(a, s.slice(i + 1, j))host.appendChild(a)i = k + 1done = true}}}if (!done && c == '<' && (s.slice(i + 1, i + 8) == 'http://' || s.slice(i + 1, i + 9) == 'https://')) {let j = s.indexOf('>', i)if (j > 0 && s.slice(i, j).indexOf(' ') < 0) {if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }let a = el('a', s.slice(i + 1, j))a.setAttribute('href', s.slice(i + 1, j))host.appendChild(a)i = j + 1done = true}}if (!done && c == 'h' && (s.slice(i, i + 7) == 'http://' || s.slice(i, i + 8) == 'https://') && (i == 0 || !isAlnum(s.slice(i - 1, i)))) {let j = urlEnd(s, i)if (j > i + 8) {if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }let a = el('a', s.slice(i, j))a.setAttribute('href', s.slice(i, j))host.appendChild(a)i = jdone = true}}if (!done && (c == '*' || c == '_') && !(c == '_' && i > 0 && isAlnum(s.slice(i - 1, i)))) {let run = 1while (run < 3 && s.slice(i + run, i + run + 1) == c) { run = run + 1 }let first = s.slice(i + run, i + run + 1)if (first != '' && !isSpace(first) && first != c) {let j = closer(s, i + run, c, run)if (j > 0) {if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }let node = nulllet innerNode = nullif (run == 1) { node = el('em', null) innerNode = node }if (run == 2) { node = el('strong', null) innerNode = node }if (run == 3) { node = el('strong', null) innerNode = el('em', null) node.appendChild(innerNode) }inline(innerNode, s.slice(i + run, j))host.appendChild(node)i = j + rundone = true}}}if (!done) { buf = buf + c i = i + 1 }}if (buf != '') { host.appendChild(document.createTextNode(buf)) }}// a line like `## title`: its level (0 = not a heading)headingLevel(line) {let k = 0while (k < line.length && k < 7 && line.slice(k, k + 1) == '#') { k = k + 1 }if (k >= 1 && k <= 6 && (line.length == k || line.slice(k, k + 1) == ' ')) { return k }return 0}// a list line: 0 = none, 1 = bullet, 2 = numbered; the item text is cut off by itemTextlistKind(line) {t = line.trim()if ((t.startsWith('- ') || t.startsWith('* ') || t.startsWith('+ '))) { return 1 }let k = 0while (k < t.length && isDigit(t.slice(k, k + 1))) { k = k + 1 }if (k > 0 && k < 10 && (t.slice(k, k + 2) == '. ' || t.slice(k, k + 2) == ') ')) { return 2 }return 0}itemText(line) {t = line.trim()let k = 0while (k < t.length && isDigit(t.slice(k, k + 1))) { k = k + 1 }return t.slice(k + 2).trim()}fenceOf(line) {t = line.trim()if (t.startsWith('```')) { return '`' }if (t.startsWith('~~~')) { return '~' }return ''}startsBlock(line) { return headingLevel(line) > 0 || listKind(line) > 0 || fenceOf(line) != '' }// Markdown → the block elements in a containerparseInto(box, md) {let lines = md.replaceAll(BS + 'r', '').split(NL)let i = 0while (i < lines.length) {let line = lines[i]let fence = fenceOf(line)if (fence != '') {let run = 0let t = line.trim()while (t.slice(run, run + 1) == fence) { run = run + 1 }let body = []i = i + 1while (i < lines.length && !(lines[i].trim().startsWith(repeat(fence, run)) && lines[i].trim().replaceAll(fence, '') == '')) { body.push(lines[i]) i = i + 1 }i = i + 1box.appendChild(el('pre', body.join(NL)))} else if (line.trim() == '') {i = i + 1} else if (headingLevel(line) > 0) {let lv = headingLevel(line)let text = line.trim().slice(lv).trim()while (text.endsWith('#') && text.length > 0) { text = text.slice(0, text.length - 1) }let h = el('h' + lv, null)inline(h, text.trim())box.appendChild(h)i = i + 1} else if (listKind(line) > 0) {let kind = listKind(line)let list = el(kind == 1 ? 'ul' : 'ol', null)let last = nullwhile (i < lines.length && lines[i].trim() != '' && (listKind(lines[i]) == kind || (listKind(lines[i]) == 0 && !startsBlock(lines[i]) && last != null))) {if (listKind(lines[i]) == kind) {last = el('li', null)inline(last, itemText(lines[i]))list.appendChild(last)} else {last.appendChild(el('br', null))inline(last, lines[i].trim())}i = i + 1}box.appendChild(list)} else {let p = el('p', null)let first = truewhile (i < lines.length && lines[i].trim() != '' && (first || !startsBlock(lines[i]))) {if (!first) { p.appendChild(el('br', null)) }inline(p, lines[i].trim())first = falsei = i + 1}box.appendChild(p)}}if (box.childNodes.length == 0) { let p = el('p', null) p.appendChild(el('br', null)) box.appendChild(p) }}// ---- DOM → Markdown ---------------------------------------------------------------------------------------------------// a text node's characters, with the ones Markdown would read as formatting written as an escapeescapeText(s, first) {let out = ''let i = 0while (i < s.length) {let c = s.slice(i, i + 1)let prev = i > 0 ? s.slice(i - 1, i) : ' 'let next = i + 1 < s.length ? s.slice(i + 1, i + 2) : ' 'if (c == BS || c == '*' || c == '`' || c == '[' || c == ']' || c == '<') { out = out + BS + c }else if (c == '_' && (!isAlnum(prev) || !isAlnum(next))) { out = out + BS + c }else { out = out + c }i = i + 1}return out}// the inline Markdown of an element's childreninlineMd(node) {let out = ''kids = node.childNodeslet i = 0while (i < kids.length) {let k = kids[i]if (k.nodeType == 3) {out = out + escapeText(clean(k.data), false)} else if (k.nodeType == 1) {let tag = k.nodeName.toLowerCase()if (tag == 'br') { out = out + NL }else if (tag == 'code') {let t = clean(k.textContent)if (t != '') {let run = 1while (t.indexOf(repeat('`', run)) >= 0) { run = run + 1 }let pad = t.startsWith('`') || t.endsWith('`') ? ' ' : ''out = out + repeat('`', run) + pad + t + pad + repeat('`', run)}}else if (tag == 'strong' || tag == 'b' || tag == 'em' || tag == 'i') {let mark = (tag == 'strong' || tag == 'b') ? '**' : '*'let t = inlineMd(k)let core = trimSp(t)if (core != '') {let lead = t.slice(0, t.indexOf(core))let tail = t.slice(t.indexOf(core) + core.length)out = out + lead + mark + core + mark + tail} else { out = out + t }}else if (tag == 'img') {let src = safeSrc(k.getAttribute('src') != null ? k.getAttribute('src') : '')if (src != '') { out = out + '![' + (k.getAttribute('alt') != null ? k.getAttribute('alt') : '').replaceAll('[', '').replaceAll(']', '') + '](' + src.replaceAll(')', '%29').replaceAll(' ', '%20') + ')' }}else if (tag == 'a') {let t = inlineMd(k)let href = safeHref(k.getAttribute('href') != null ? k.getAttribute('href') : '')if (href == '' || trimSp(t) == '') { out = out + t }else if (trimSp(k.textContent) == href && (href.startsWith('http://') || href.startsWith('https://'))) { out = out + href }else { out = out + '[' + t + '](' + href.replaceAll(')', '%29').replaceAll(' ', '%20') + ')' }}else if (tag == 'div' || tag == 'p') { out = out + inlineMd(k) }else { out = out + inlineMd(k) }}i = i + 1}return out}// the text of a paragraph's or item's lines: trailing / leading breaks dropped, each line trimmedlines(md) {parts = md.split(NL)out = []for (p of parts) { out.push(trimSp(p)) }while (out.length > 0 && out[out.length - 1] == '') { out.pop() }while (out.length > 0 && out[0] == '') { out.shift() }return out}// same for a line starting with digits + "." (the backslash goes before the dot)guardNumber(line) {let k = 0while (k < line.length && isDigit(line.slice(k, k + 1))) { k = k + 1 }if (k > 0 && (line.slice(k, k + 2) == '. ' || line.slice(k, k + 2) == ') ')) { return line.slice(0, k) + BS + line.slice(k) }return line}paragraphMd(node) {ls = lines(inlineMd(node))if (ls.length == 0) { return '' }out = []for (l of ls) {if (headingLevel(l) > 0 || fenceOf(l) != '' || l.startsWith('>') || listKind(l) == 1) { out.push(BS + l) } else { out.push(guardNumber(l)) }}return out.join(NL)}// the Markdown blocks of a container's childrenblocksMd(box) {out = []kids = box.childNodeslet i = 0while (i < kids.length) {let k = kids[i]if (k.nodeType == 3) {let t = paragraphMd(k.parentNode == box ? wrapText(k) : k)if (t != '') { out.push(t) }} else if (k.nodeType == 1) {let tag = k.nodeName.toLowerCase()if (tag.length == 2 && tag.startsWith('h') && isDigit(tag.slice(1, 2)) && tag != 'hr') {let t = lines(inlineMd(k)).join(' ')if (t != '') { out.push(repeat('#', toNumber(tag.slice(1, 2))) + ' ' + t) }} else if (tag == 'ul' || tag == 'ol') {let items = []let n = 1let li = k.childNodeslet j = 0while (j < li.length) {if (li[j].nodeType == 1) {let ls = lines(inlineMd(li[j]))if (ls.length > 0) {items.push((tag == 'ul' ? '- ' : n + '. ') + ls.join(NL + ' '))n = n + 1}}j = j + 1}if (items.length > 0) { out.push(items.join(NL)) }} else if (tag == 'pre') {let body = preText(k)let run = 3while (body.indexOf(repeat('`', run)) >= 0) { run = run + 1 }if (body != '') { out.push(repeat('`', run) + NL + body + NL + repeat('`', run)) }} else if ((tag == 'div' || tag == 'p') && hasBlockChild(k)) {for (x of blocksMd(k)) { out.push(x) }} else if (tag == 'br') {} else {let t = paragraphMd(k)if (t != '') { out.push(t) }}}i = i + 1}return out}wrapText(t) { p = el('p', null) p.appendChild(document.createTextNode(t.data)) return p }hasBlockChild(node) {kids = node.childNodeslet i = 0while (i < kids.length) {if (kids[i].nodeType == 1 && 'p,div,ul,ol,pre,h1,h2,h3,h4,h5,h6'.split(',').indexOf(kids[i].nodeName.toLowerCase()) >= 0) { return true }i = i + 1}return false}// the text of a code block (a <br> is a line break)preText(node) {let out = ''kids = node.childNodeslet i = 0while (i < kids.length) {let k = kids[i]if (k.nodeType == 3) { out = out + k.data.replaceAll(ZW, '').replaceAll(NB, ' ') }else if (k.nodeName.toLowerCase() == 'br') { out = out + NL }else { out = out + preText(k) }i = i + 1}// a trailing line feed is the browser's, not the writer'swhile (out.endsWith(NL)) { out = out.slice(0, out.length - 1) }return out}toMarkdown(box) {return blocksMd(box).join(NL + NL)}// ---- the editor ---------------------------------------------------------------------------------------------------------rootOf(node) { return node.closest('md-editor') }surfaceOf(root) { return root.querySelector('article') }fieldOf(root) { return root.querySelector('input.value') }sourceOf(root) { return root.querySelector('textarea') }// the block (a direct child of the surface) that holds a nodeblockOf(root, node) {s = surfaceOf(root)let n = nodewhile (n != null && n.parentNode != s) { n = n.parentNode }return n}inSurface(root) {sel = document.getSelection()return sel.rangeCount > 0 && sel.anchorNode != null && surfaceOf(root).contains(sel.anchorNode)}inTag(root, tags) {sel = document.getSelection()let n = sel.anchorNodes = surfaceOf(root)while (n != null && n != s) {if (n.nodeType == 1 && tags.indexOf(n.nodeName.toLowerCase()) >= 0) { return true }n = n.parentNode}return false}// the Markdown of the surface is the value: written to the field and announced when it changed. `orig` keeps an untouched value byte for byte.sync(root) {s = surfaceOf(root)f = fieldOf(root)let md = toMarkdown(s)if (root.mdOrig != null && md == root.mdCanon) { md = root.mdOrig }root.classList.toggle('empty', md == '' && s.querySelector('pre') == null)if (md != f.value) {f.value = mdf.dispatchEvent(new Event('input', { bubbles = true }))}}// a new value (from outside, the source view or a paste-all): shown formattedload(root, md) {s = surfaceOf(root)s.textContent = ''parseInto(s, md)root.mdOrig = mdroot.mdCanon = toMarkdown(s)root.classList.toggle('empty', root.mdCanon == '')}// the toolbar shows what the caret is inupdateBar(root) {if (!root.isConnected || !inSurface(root)) { return null }mark = (tool, active) => { b = root.querySelector('button.' + tool) if (b != null) { b.classList.toggle('on', active) } }mark('bold', document.queryCommandState('bold'))mark('italic', document.queryCommandState('italic'))mark('code', inTag(root, ['code']))mark('ul', inTag(root, ['ul']))mark('ol', inTag(root, ['ol']))mark('pre', inTag(root, ['pre']))mark('link', inTag(root, ['a']))h = root.querySelector('button.heading')let level = ''tags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']for (t of tags) { if (inTag(root, [t])) { level = t.toUpperCase() } }if (h != null) { h.textContent = level != '' ? level : 'H' h.classList.toggle('on', level != '') }}// bold / italic / heading / lists / code block: the browser's own editing commands, so undo workscodeToggle(root) {sel = document.getSelection()r = sel.getRangeAt(0)let n = sel.anchorNodelet inCode = nullwhile (n != null && n != surfaceOf(root)) { if (n.nodeType == 1 && n.nodeName.toLowerCase() == 'code') { inCode = n } n = n.parentNode }if (inCode != null) {document.execCommand('insertHTML', false, escHtml(inCode.textContent, ''))return null}if (r.collapsed) { return null }document.execCommand('insertHTML', false, escHtml(r.toString(), 'code') + ZW)}// text as escaped html (built by the browser from a text node), optionally inside a tagescHtml(text, tag) {box = document.createElement('div')e = tag != '' ? el(tag, text) : document.createTextNode(text)box.appendChild(e)return box.innerHTML}headingCycle(root) {let cur = 0tags = ['h1', 'h2', 'h3']let i = 0while (i < 3) { if (inTag(root, [tags[i]])) { cur = i + 1 } i = i + 1 }document.execCommand('formatBlock', false, cur == 3 ? 'p' : tags[cur])}blockToggle(root, tag, cmd) {if (inTag(root, [tag])) {if (tag == 'pre') { document.execCommand('formatBlock', false, 'p') } else { document.execCommand(cmd) }} else if (tag == 'pre') { document.execCommand('formatBlock', false, 'pre') } else { document.execCommand(cmd) }}linkOpen(root) {sel = document.getSelection()if (!inSurface(root)) { surfaceOf(root).focus() }root.mdRange = sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : nullf = root.querySelector('link-form input')let n = sel.anchorNodelet href = ''while (n != null && n != surfaceOf(root)) { if (n.nodeType == 1 && n.nodeName.toLowerCase() == 'a') { href = n.getAttribute('href') } n = n.parentNode }f.value = hrefroot.classList.add('linking')f.focus()}linkClose(root) {root.classList.remove('linking')surfaceOf(root).focus()sel = document.getSelection()if (root.mdRange != null) { sel.removeAllRanges() sel.addRange(root.mdRange) }}linkApply(root, remove) {f = root.querySelector('link-form input')href = remove ? '' : safeHref(f.value)linkClose(root)if (remove || f.value.trim() == '') { document.execCommand('unlink') }else if (href != '') {sel = document.getSelection()if (sel.isCollapsed) { document.execCommand('insertHTML', false, escLink(href)) } else { document.execCommand('createLink', false, href) }}sync(root)}escLink(href) {box = document.createElement('div')a = el('a', href)a.setAttribute('href', href)box.appendChild(a)return box.innerHTML}// the parsed Markdown as html the browser can insert (built from elements, so every text is escaped)mdHtml(md) {box = document.createElement('div')parseInto(box, md)// a single paragraph is inserted inline: no extra blockif (box.childNodes.length == 1 && box.firstChild.nodeName.toLowerCase() == 'p') { return box.firstChild.innerHTML }return box.innerHTML}// typing rules at the caret: "# " "- " "1. " make a block; the typed marker is consumed (only the formatting stays). They work in a// paragraph, in a heading (an emptied heading keeps its level) and in a list item (a marker of the other list kind switches the list)blockRule(root, e) {sel = document.getSelection()if (!sel.isCollapsed || !inSurface(root)) { return false }let b = blockOf(root, sel.anchorNode)if (b == null) { return false }let tag = b.nodeName.toLowerCase()let inner = sel.anchorNodewhile (inner != null && inner != surfaceOf(root) && !(inner.nodeType == 1 && ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li'].indexOf(inner.nodeName.toLowerCase()) >= 0)) { inner = inner.parentNode }if (inner != null && inner != surfaceOf(root) && inner.nodeName.toLowerCase() != 'li') { b = inner tag = inner.nodeName.toLowerCase() }let item = sel.anchorNodewhile (item != null && item != surfaceOf(root) && item.nodeName.toLowerCase() != 'li') { item = item.parentNode }if (item == surfaceOf(root)) { item = null }let box = bif (item != null) {box = itemtag = item.parentNode.nodeName.toLowerCase()} else if (tag != 'p' && tag != 'div' && !(tag.length == 2 && tag.startsWith('h'))) { return false }r = sel.getRangeAt(0)pre = document.createRange()pre.selectNodeContents(box)pre.setEnd(r.endContainer, r.endOffset)before = clean(pre.toString())if (before != clean(box.textContent)) { return false }let cmd = ''let arg = ''if (item == null && before.length >= 1 && before.length <= 6 && before.replaceAll('#', '') == '') { cmd = 'formatBlock' arg = 'h' + before.length }if (before == '-' || before == '*' || before == '+') { cmd = 'insertUnorderedList' }if (before.length >= 2 && before.endsWith('.') && before.slice(0, before.length - 1).length < 9) {let digits = truelet k = 0while (k < before.length - 1) { if (!isDigit(before.slice(k, k + 1))) { digits = false } k = k + 1 }if (digits) { cmd = 'insertOrderedList' }}if (cmd == '') { return false }e.preventDefault()sel.selectAllChildren(box)document.execCommand('delete')// a list marker typed in a list of that kind is only consumedif (item != null && ((cmd == 'insertUnorderedList' && tag == 'ul') || (cmd == 'insertOrderedList' && tag == 'ol'))) { return true }if (item == null && tag != 'p' && tag != 'div' && cmd != 'formatBlock') { document.execCommand('formatBlock', false, 'p') }if (arg != '') { document.execCommand(cmd, false, arg) } else { document.execCommand(cmd) }return true}// a closing * _ ` typed: find the opener before the caret in the same text nodeinlineRule(root, e) {sel = document.getSelection()node = sel.anchorNodeif (!sel.isCollapsed || node == null || node.nodeType != 3 || !surfaceOf(root).contains(node)) { return null }if (inTag(root, ['code', 'pre'])) { return null }off = sel.anchorOffsett = node.data.slice(0, off)c = e.datalet tag = ''let start = -1let ends = ''if (c == '`') {let j = t.slice(0, off - 1).lastIndexOf('`')if (j >= 0 && off - 1 - j - 1 >= 1) { start = j tag = 'code' ends = t.slice(j + 1, off - 1) }}if (c == '*' && t.length >= 5 && t.endsWith('**')) {let j = t.slice(0, off - 2).lastIndexOf('**')let inner = j >= 0 ? t.slice(j + 2, off - 2) : ''if (j >= 0 && inner != '' && !isSpace(inner.slice(0, 1)) && !isSpace(inner.slice(inner.length - 1))) { start = j tag = 'strong' ends = inner }}if ((c == '*' || c == '_') && tag == '' && t.length >= 3 && !t.endsWith(c + c)) {let j = t.slice(0, off - 1).lastIndexOf(c)let inner = j >= 0 ? t.slice(j + 1, off - 1) : ''okStart = j >= 0 && (j == 0 || !isAlnum(t.slice(j - 1, j)) || c == '*') && t.slice(j - 1, j) != c && t.slice(j + 1, j + 2) != cif (okStart && inner != '' && !isSpace(inner.slice(0, 1)) && !isSpace(inner.slice(inner.length - 1))) { start = j tag = 'em' ends = inner }}if (tag == '') { return null }r = document.createRange()r.setStart(node, start)r.setEnd(node, off)sel.removeAllRanges()sel.addRange(r)document.execCommand('insertHTML', false, escHtml(ends, tag) + ZW)// Chrome pins the colour on the new element (`<strong style="color: …">`) when it equals the text around it (the black/white base// look, #15); the surface never carries styles of its ownfor (k of surfaceOf(root).querySelectorAll('[style]')) { k.removeAttribute('style') }}// ---- look: the component's own Style; the base look is black, white and the greys #555 #999 #ddd #f2f2f2 (shared/base.hl names them; README "Look"); every colour is a semantic token with its base value as fallback, `var(--color-…, #000)`, so an app that declares the tokens (layouts.worldapi.org's theme) restyles it. No frames: a border only where it means something (focus ring, drop zone)Style {'@keyframes md-editor-mount' { 'from' { opacity = '.999' } to { opacity = '1' } }'md-editor' { position = 'relative' display = 'block' border = '0' borderRadius = '.5rem' background = 'var(--color-background-sunken, #f2f2f2)' color = 'var(--color-text, #000)' }'md-editor:focus-within' { outline = '2px solid var(--color-accent, #555)' outlineOffset = '-1px' }'md-editor nav' { display = 'flex' flexWrap = 'wrap' gap = '.25rem' padding = '.35rem' borderBottom = '0' background = 'var(--color-surface, #ddd)' borderRadius = '.5rem .5rem 0 0' }'md-editor nav button' { font = 'inherit' fontSize = '.9rem' fontWeight = '600' minWidth = '2rem' padding = '.25rem .5rem' color = 'var(--color-text, #000)' background = 'transparent' border = '1px solid transparent' borderRadius = '.35rem' cursor = 'pointer' }'md-editor nav button:hover' { background = 'var(--color-background, #fff)' }'md-editor nav button.on' { color = 'var(--color-accent-text, #fff)' background = 'var(--color-accent, #000)' }'md-editor nav button.italic' { fontStyle = 'italic' }'md-editor nav button.code' { fontFamily = 'ui-monospace, monospace' }'md-editor nav button:disabled' { opacity = '.4' cursor = 'default' }'md-editor.source nav button' { opacity = '.4' pointerEvents = 'none' }'md-editor nav input.pick' { display = 'none' }'md-editor ul.files' { display = 'none' margin = '0' padding = '.35rem .8rem' listStyle = 'none' borderBottom = '0' fontSize = '.85rem' }'md-editor ul.files:not(:empty)' { display = 'block' }'md-editor ul.files li' { display = 'flex' alignItems = 'center' gap = '.6rem' }'md-editor ul.files li span' { flex = '0 1 12rem' overflow = 'hidden' textOverflow = 'ellipsis' whiteSpace = 'nowrap' }'md-editor ul.files progress' { flex = '1' accentColor = 'var(--color-accent, #000)' }'md-editor ul.files li.error' { color = 'var(--color-danger, #000)' }'md-editor link-form' { display = 'none' gap = '.35rem' padding = '.35rem' borderBottom = '0' background = 'var(--color-surface, #ddd)' }'md-editor.linking link-form' { display = 'flex' }'md-editor link-form input' { flex = '1' minWidth = '0' font = 'inherit' padding = '.3rem .5rem' color = 'var(--color-text, #000)' background = 'var(--color-background, #fff)' border = '0' borderRadius = '.35rem' }'md-editor link-form button' { font = 'inherit' padding = '.3rem .7rem' color = 'var(--color-text, #000)' background = 'transparent' border = '0' borderRadius = '.35rem' cursor = 'pointer' }'md-editor article' { display = 'block' minHeight = '8rem' padding = '.6rem .8rem' outline = 'none' overflowWrap = 'anywhere' animation = 'md-editor-mount 100ms infinite' }'md-editor.source article' { display = 'none' }'md-editor .placeholder' { display = 'none' position = 'absolute' left = '.8rem' marginTop = '.6rem' pointerEvents = 'none' color = 'var(--color-text-muted, #555)' }'md-editor.empty:not(.source) .placeholder' { display = 'block' }'md-editor article > :first-child' { marginTop = '0' }'md-editor article > :last-child' { marginBottom = '0' }'md-editor article h1, md-editor article h2, md-editor article h3, md-editor article h4, md-editor article h5, md-editor article h6' { margin = '1rem 0 .5rem' lineHeight = '1.25' color = 'var(--color-text-strong, #000)' }'md-editor article h1' { fontSize = '1.6rem' }'md-editor article h2' { fontSize = '1.35rem' }'md-editor article h3' { fontSize = '1.15rem' }'md-editor article h4, md-editor article h5, md-editor article h6' { fontSize = '1rem' }'md-editor article p' { margin = '0 0 .7rem' }'md-editor article ul, md-editor article ol' { margin = '0 0 .7rem' paddingLeft = '1.5rem' }'md-editor article img' { display = 'block' maxWidth = '100%' height = 'auto' margin = '.3rem 0' borderRadius = '.3rem' }'md-editor article strong' { color = 'var(--color-text-strong, #000)' }'md-editor article a' { color = 'var(--color-accent, #000)' textDecoration = 'underline' }'md-editor article code' { padding = '0 .25rem' fontFamily = 'ui-monospace, monospace' fontSize = '.9em' background = 'var(--color-surface, #ddd)' borderRadius = '.25rem' }'md-editor article pre' { margin = '0 0 .7rem' padding = '.5rem .7rem' fontFamily = 'ui-monospace, monospace' fontSize = '.9rem' whiteSpace = 'pre-wrap' background = 'var(--color-surface, #ddd)' border = '0' borderRadius = '.4rem' }'md-editor footer' { display = 'flex' justifyContent = 'flex-end' padding = '.3rem .5rem' borderTop = '0' }'md-editor footer button' { font = 'inherit' fontSize = '.85rem' padding = '.2rem .6rem' color = 'var(--color-text-muted, #555)' background = 'transparent' border = '0' borderRadius = '.35rem' cursor = 'pointer' }'md-editor footer button:hover' { color = 'var(--color-text, #000)' }'md-editor textarea' { display = 'none' boxSizing = 'border-box' width = '100%' minHeight = '8rem' padding = '.6rem .8rem' font = '.9rem/1.5 ui-monospace, monospace' color = 'var(--color-text, #000)' background = 'transparent' border = '0' outline = 'none' resize = 'vertical' }'md-editor.source textarea' { display = 'block' }}// The hidden input `name` carries the value in a form; the article is the surface; the textarea is the source view (a value shown// only while it is on). The rendered parts never change after the first render, so the framework leaves the hand-built content alone.View {mdEditor {nav {button { type = "button" class = "bold" title = "Bold (Ctrl+B)" "B" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'bold') } }button { type = "button" class = "italic" title = "Italic (Ctrl+I)" "I" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'italic') } }button { type = "button" class = "code" title = "Code (Ctrl+E)" "<>" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'code') } }button { type = "button" class = "link" title = "Link (Ctrl+K)" "Link" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'link') } }button { type = "button" class = "heading" title = "Heading: paragraph, H1, H2, H3" "H" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'heading') } }button { type = "button" class = "ul" title = "Bulleted list" "• List" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'ul') } }button { type = "button" class = "ol" title = "Numbered list" "1. List" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'ol') } }button { type = "button" class = "pre" title = "Code block" "Block" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'pre') } }button { type = "button" class = "file" title = "Add files or images (or drop / paste them)" "File" on mousedown(e) { e.preventDefault() } on click(e) { emit mdPick(e) } }input { type = "file" class = "pick" multiple = "multiple" on change(e) { emit mdPicked(e) } }button { type = "button" class = "undo" title = "Undo" "↶" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'undo') } }button { type = "button" class = "redo" title = "Redo" "↷" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'redo') } }}linkForm {input { type = "text" placeholder = "https://…" autocomplete = "off" on keydown(e) { emit mdLinkKey(e) } }button { type = "button" "Apply" on click(e) { emit mdLinkSubmit(e) } }button { type = "button" title = "Remove the link" "Remove" on click(e) { emit mdLinkRemove(e) } }}ul { class = "files" 'aria-live' = "polite" }span { class = "placeholder" placeholder }article { contenteditable = "true" role = "textbox" tabindex = "0" on animationiteration(e) { emit mdMount(e) } on input(e) { emit mdInput(e) } on beforeinput(e) { emit mdBefore(e) } on keydown(e) { emit mdKey(e) } on paste(e) { emit mdPaste(e) } on drop(e) { emit mdDrop(e) } on click(e) { emit mdClick(e) } on blur(e) { emit mdBlur(e) } }textarea { rows = rows spellcheck = "false" on input(e) { emit mdSourceInput(e) } }input { class = "value" type = "hidden" name = name value = value }footer { button { type = "button" class = "source-switch" on click(e) { emit mdSource(e) } "Markdown source" } }}}on mdMount(e) {s = e.targets.style.animationName = 'none'root = rootOf(s)document.execCommand('defaultParagraphSeparator', false, 'p')document.execCommand('styleWithCSS', false, false)load(root, fieldOf(root).value)sourceOf(root).value = fieldOf(root).valuedocument.addEventListener('selectionchange', () => { updateBar(root) })}on mdInput(e) {e.stopPropagation()root = rootOf(e.target)s = surfaceOf(root)// an emptied surface gets its paragraph backif (s.childNodes.length == 0) {np = document.createElement('p')np.appendChild(document.createElement('br'))s.appendChild(np)document.getSelection().collapse(np, 0)}if (e.inputType == 'insertText' && (e.data == '*' || e.data == '_' || e.data == '`')) { inlineRule(root, e) }sync(root)updateBar(root)}on mdBefore(e) {if (e.inputType == 'insertText' && e.data == ' ') {root = rootOf(e.target)blockRule(root, e)}}on mdKey(e) {root = rootOf(e.target)mod = e.ctrlKey || e.metaKeyif (mod && !e.shiftKey && !e.altKey && (e.key == 'k' || e.key == 'K')) { e.preventDefault() linkOpen(root) return null }if (mod && !e.shiftKey && !e.altKey && (e.key == 'e' || e.key == 'E')) { e.preventDefault() codeToggle(root) sync(root) return null }if (mod && e.shiftKey && (e.key == '8' || e.key == '*')) { e.preventDefault() blockToggle(root, 'ul', 'insertUnorderedList') sync(root) return null }if (mod && e.shiftKey && (e.key == '7' || e.key == '&')) { e.preventDefault() blockToggle(root, 'ol', 'insertOrderedList') sync(root) return null }if (mod && e.altKey && e.code == 'KeyC') { e.preventDefault() blockToggle(root, 'pre', '') sync(root) return null }if (mod && e.altKey && e.code == 'Digit0') { e.preventDefault() document.execCommand('formatBlock', false, 'p') sync(root) return null }if (mod && e.altKey && (e.code == 'Digit1' || e.code == 'Digit2' || e.code == 'Digit3')) { e.preventDefault() document.execCommand('formatBlock', false, 'h' + e.code.slice(5, 6)) sync(root) return null }// Ctrl+Enter sends the form the editor sits inif (mod && e.key == 'Enter') {e.preventDefault()form = root.closest('form')if (form != null) { form.requestSubmit() }return null}// Backspace at the very start of a heading / list item / code block turns it into a paragraphif (e.key == 'Backspace' && document.getSelection().isCollapsed && inSurface(root)) {let sel = document.getSelection()let b = blockOf(root, sel.anchorNode)if (b != null && 'h1,h2,h3,h4,h5,h6,ul,ol,pre'.split(',').indexOf(b.nodeName.toLowerCase()) >= 0) {r = sel.getRangeAt(0)pre = document.createRange()pre.selectNodeContents(b)pre.setEnd(r.endContainer, r.endOffset)if (pre.toString() == '') {e.preventDefault()tag = b.nodeName.toLowerCase()if (tag == 'ul') { document.execCommand('insertUnorderedList') }else if (tag == 'ol') { document.execCommand('insertOrderedList') }else { document.execCommand('formatBlock', false, 'p') }sync(root)return null}}}// ``` at the start of a line + Enter opens a code blockif (e.key == 'Enter' && !mod && !e.shiftKey && !inTag(root, ['pre'])) {let sel = document.getSelection()let b = sel.anchorNode != null ? blockOf(root, sel.anchorNode) : nullif (b != null && b.nodeName.toLowerCase() == 'p' && sel.isCollapsed && (clean(b.textContent).startsWith('```') || clean(b.textContent).startsWith('~~~')) && clean(b.textContent).replaceAll('`', '').replaceAll('~', '') == '') {e.preventDefault()sel.selectAllChildren(b)document.execCommand('delete')document.execCommand('formatBlock', false, 'pre')sync(root)}}}on mdTool(e, tool) {root = rootOf(e.target)s = surfaceOf(root)if (!inSurface(root)) { s.focus() }if (tool == 'bold') { document.execCommand('bold') }if (tool == 'italic') { document.execCommand('italic') }if (tool == 'code') { codeToggle(root) }if (tool == 'link') { linkOpen(root) return null }if (tool == 'heading') { headingCycle(root) }if (tool == 'ul') { blockToggle(root, 'ul', 'insertUnorderedList') }if (tool == 'ol') { blockToggle(root, 'ol', 'insertOrderedList') }if (tool == 'pre') { blockToggle(root, 'pre', '') }if (tool == 'undo') { document.execCommand('undo') }if (tool == 'redo') { document.execCommand('redo') }sync(root)updateBar(root)}on mdLinkSubmit(e) { linkApply(rootOf(e.target), false) }on mdLinkRemove(e) { linkApply(rootOf(e.target), true) }on mdLinkKey(e) {if (e.key == 'Escape') { e.preventDefault() linkClose(rootOf(e.target)) }if (e.key == 'Enter') { e.preventDefault() linkApply(rootOf(e.target), false) }}// pasted / dropped text is read as Markdown (rich HTML is not taken: only its plain text)insertMarkdown(root, text) {if (inTag(root, ['pre'])) { document.execCommand('insertText', false, text) return null }document.execCommand('insertHTML', false, mdHtml(text))}on mdPaste(e) {e.preventDefault()root = rootOf(e.target)if (e.clipboardData.files.length > 0) { attach(root, e.clipboardData.files, null) return null }insertMarkdown(root, e.clipboardData.getData('text/plain'))sync(root)}on mdDrop(e) {e.preventDefault()root = rootOf(e.target)if (e.dataTransfer.files.length > 0) {at = document.caretRangeFromPoint(e.clientX, e.clientY)attach(root, e.dataTransfer.files, at)return null}text = e.dataTransfer.getData('text/plain')if (text != '') { insertMarkdown(root, text) sync(root) }}// ---- files and images: dropped, pasted or chosen files go to the app through `emit server mdUpload(files)` (the framework sends them// over HTTP with progress); the app stores them and answers the addresses; each lands in the text as  (an image) or// [name](address). Progress comes back as `uploadProgress` and is shown under the toolbar.fileRow(root, p) {list = root.querySelector('ul.files')let li = list.children[p.index]if (li == null) {li = document.createElement('li')li.appendChild(el('span', p.name))li.appendChild(document.createElement('progress'))list.appendChild(li)}bar = li.querySelector('progress')bar.max = p.sizebar.value = p.sent}on client uploadProgress(p) {for (root of document.querySelectorAll('md-editor.uploading')) { fileRow(root, p) }}attachMd(names, kinds, urls) {out = []let i = 0while (i < urls.length) {let label = names[i].replaceAll('[', '').replaceAll(']', '')let u = urls[i].replaceAll(')', '%29').replaceAll(' ', '%20')if (urls[i] != '') { out.push((kinds[i] ? '!' : '') + '[' + label + '](' + u + ')') }i = i + 1}return out.join(NL + NL)}attach(root, files, at) {s = surfaceOf(root)sel = document.getSelection()let range = atif (range == null && inSurface(root)) { range = sel.getRangeAt(0).cloneRange() }list = []names = []kinds = []let k = 0while (k < files.length) { list.push(files[k]) names.push(files[k].name) kinds.push(files[k].type.startsWith('image/')) k = k + 1 }box = root.querySelector('ul.files')box.textContent = ''root.classList.add('uploading')root.classList.remove('failed')urls = emit server mdUpload(list)root.classList.remove('uploading')if (urls == null || urls.length != list.length) {root.classList.add('failed')box.textContent = ''li = el('li', 'The upload failed.')li.className = 'error'box.appendChild(li)return null}box.textContent = ''s.focus()if (range != null && s.contains(range.startContainer)) { sel.removeAllRanges() sel.addRange(range) }else { sel.selectAllChildren(s) sel.collapseToEnd() }md = attachMd(names, kinds, urls)if (md == '') { return null }// inside a list item (or at the end of the surface, where the caret sits in the last item) the block would be glued to the// item's text: it is put on its own after the list, as elements (an editing command would join it to the item)let top = nullif (sel.anchorNode == s) { top = s.childNodes[sel.anchorOffset - 1 >= 0 ? sel.anchorOffset - 1 : 0] }else if (sel.anchorNode != null && s.contains(sel.anchorNode)) { top = blockOf(root, sel.anchorNode) }if (top != null && (top.nodeName == 'UL' || top.nodeName == 'OL')) {tmp = document.createElement('div')parseInto(tmp, md)ref = top.nextSiblingwhile (tmp.firstChild != null) { s.insertBefore(tmp.firstChild, ref) }sel.selectAllChildren(s)sel.collapseToEnd()sync(root)return null}insertMarkdown(root, md)sync(root)}on mdPick(e) {root = rootOf(e.target)root.querySelector('input.pick').click()}on mdPicked(e) {root = rootOf(e.target)input = e.targetif (input.files.length > 0) { attach(root, input.files, null) }input.value = ''}// Ctrl / Cmd + click follows a linkon mdClick(e) {a = e.target.closest('a')if ((e.ctrlKey || e.metaKey) && a != null && safeHref(a.getAttribute('href')) != '') { window.open(a.getAttribute('href'), '_blank', 'noopener') }}on mdBlur(e) { sync(rootOf(e.target)) }// the Markdown source view: the whole field as a plain textarea, and backon mdSource(e) {root = rootOf(e.target)ta = sourceOf(root)b = root.querySelector('button.source-switch')if (root.classList.contains('source')) {md = ta.valueload(root, md)root.classList.remove('source')b.textContent = 'Markdown source'sync(root)surfaceOf(root).focus()} else {sync(root)ta.value = fieldOf(root).valueroot.classList.add('source')b.textContent = 'Visual editor'ta.focus()}}on mdSourceInput(e) {root = rootOf(e.target)f = fieldOf(root)f.value = e.target.valuef.dispatchEvent(new Event('input', { bubbles = true }))}
Branches
- mainmain branch
Latest commits
- 3bc905c5components #15 (mission 003) 2/2: docs (README Look, STATUS, LOG), reportmre
- 5a98d77ecomponents #15 (mission 003) 1/2: black/white base look — greys for nuances, no WorldAPI fallbacks, no decorative frames; site + icon black/white; gate 230/0mre
- 3f93d716components code order (mission 002) 4/4: docs, tests/letcount.py, tests/same-output*, reportmre
- e747cfa0components code order (mission 002) 3/4: let only where reassigned (257 dropped, 0 never-reassigned left); gate 220/0, layouts gate 73/0, same outputmre
- 172914a3components code order (mission 002) 2/4: lib/uploads.hl (demo upload storage), thin demo handlers, project.hl map; gate 220/0, same outputmre
- d29f35b1components code order (mission 002) 1/4: styles.hl -> components/styles.hl; gate 220/0, same outputmre
- 09945750components: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, http1 773de63e); gate 220/0mre
- ede4d19acomponents: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 220/0mre
- 2bb9a935components: Hybriel master 8efba065 (re-vendor round 069: #126 memory, #48 lambda copy)mre
- e4f01768antcolony#40: mission references point to the moved missionsmre
- d32b44e2antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 0fb80b57components: Hybriel master ff51cf46 (re-vendor round)mre
- 57408b6ecomponents#14: installable app (manifest, service worker, offline index), own iconmre
- 317bd09fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- becbd59bcomponents#13: modal dialog (<dialog> based, focus wrap, backdrop/Esc/x close with reason, scroll lock, footer buttons)mre
- 367dd19adeploy.sh: never send .git or .gitignore to Byrodinmre
- 4b4dabe5State of 2026-09-27, before the move to gitoriamre