gitoriaLog in with ident

components

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmain3bc905c5components #15 (mission 003) 2/2: docs (README Look, STATUS, LOG), reportmremain/components/md-editor/md-editor.hl

43.1 KB

  1. // md-editor.hl — THE MARKDOWN WYSIWYG EDITOR, all Hybriel (port of worldapi-components/md-editor.js): headings look like
  2. // headings, **bold** is bold, lists are lists — while you type; the value stays plain Markdown. `MdEditor { name = "note" value = "**hi**" }`.
  3. // The formatted surface is a contenteditable element whose children are built in the browser (createElement / text nodes, never
  4. // innerHTML of user text) — the framework never re-renders it: nothing bound to it changes after the first render. Hybriel has no
  5. // mount event, so a 100 ms CSS animation on the surface fires `animationiteration` once the page is live, and that hands the
  6. // editor its first content (the mount hook turns the animation off again). The value is written to the hidden field
  7. // `<input name>` (posts in a form) and announced with a bubbling DOM `input` event on it (`e.target.value` = the Markdown).
  8. // Subset (what the tickets' markdown.hl renders): headings 1-6, paragraphs (single line breaks kept), - / 1. lists (one level),
  9. // fenced code blocks, `code`, links (http(s), mailto, /path, #anchor), *em*, **strong**.
  10. String name = 'markdown'
  11. String value = ''
  12. String placeholder = 'Write…'
  13. Number rows = 6
  14. Hybrid linkOk = ['http://', 'https://', 'mailto:', '/', '#']
  15. static NL = "
  16. "
  17. static BS = 'x\y'.slice(1, 2)
  18. static ZW = "​"
  19. static NB = " "
  20. // ---- small text helpers ---------------------------------------------------------------------------------------------
  21. isDigit(c) { k = c.charCodeAt(0) return k >= 48 && k <= 57 }
  22. isAlnum(c) { k = c.charCodeAt(0) return (k >= 48 && k <= 57) || (k >= 65 && k <= 90) || (k >= 97 && k <= 122) || k > 127 }
  23. isSpace(c) { return c == ' ' || c == NB || c == NL || c == ZW }
  24. repeat(c, n) { let s = '' let i = 0 while (i < n) { s = s + c i = i + 1 } return s }
  25. // trim spaces (also the non-breaking space the browser types) and the zero-width space
  26. clean(s) { return s.replaceAll(NB, ' ').replaceAll(ZW, '') }
  27. trimSp(s) {
  28. let a = 0
  29. let b = s.length
  30. while (a < b && (s.slice(a, a + 1) == ' ' || s.slice(a, a + 1) == NB || s.slice(a, a + 1) == ZW)) { a = a + 1 }
  31. while (b > a && (s.slice(b - 1, b) == ' ' || s.slice(b - 1, b) == NB || s.slice(b - 1, b) == ZW)) { b = b - 1 }
  32. return s.slice(a, b)
  33. }
  34. safeHref(u) {
  35. t = trimSp(u)
  36. if (t == '' ) { return '' }
  37. for (p of linkOk) { if (t.startsWith(p)) { return t } }
  38. return ''
  39. }
  40. // an image address: http(s) or a path on this site; nothing else (no data:, no javascript:)
  41. safeSrc(u) {
  42. t = trimSp(u)
  43. if (t.startsWith('https://') || t.startsWith('http://') || (t.startsWith('/') && !t.startsWith('//'))) { return t }
  44. return ''
  45. }
  46. // the end of a bare URL that starts at i: up to a space, minus trailing punctuation
  47. urlEnd(s, i) {
  48. let j = i
  49. while (j < s.length && !isSpace(s.slice(j, j + 1)) && s.slice(j, j + 1) != '<') { j = j + 1 }
  50. while (j > i && ('.,;:!?)]}' + "'" + '"').indexOf(s.slice(j - 1, j)) >= 0) { j = j - 1 }
  51. return j
  52. }
  53. // ---- Markdown → DOM ---------------------------------------------------------------------------------------------------
  54. el(tag, text) {
  55. e = document.createElement(tag)
  56. if (text != null) { e.textContent = text }
  57. return e
  58. }
  59. // the closing run of c (n times) after position i, whose content is not empty and does not end in a space; -1 when none
  60. closer(s, i, c, n) {
  61. mark = repeat(c, n)
  62. let j = s.indexOf(mark, i)
  63. while (j >= 0) {
  64. let after = s.slice(j + n, j + n + 1)
  65. let before = s.slice(j - 1, j)
  66. let ok = j > i && !isSpace(before) && before != BS && (after != c)
  67. if (ok && c == '_' && after != '' && isAlnum(after)) { ok = false }
  68. if (ok) { return j }
  69. j = s.indexOf(mark, j + 1)
  70. }
  71. return -1
  72. }
  73. // appends the inline Markdown `s` to `host`
  74. inline(host, s) {
  75. let buf = ''
  76. let i = 0
  77. n = s.length
  78. while (i < n) {
  79. let c = s.slice(i, i + 1)
  80. let done = false
  81. if (c == BS && i + 1 < n && '\*_`[]<>#-.!()+~|'.indexOf(s.slice(i + 1, i + 2)) >= 0) {
  82. buf = buf + s.slice(i + 1, i + 2)
  83. i = i + 2
  84. done = true
  85. }
  86. if (!done && c == '`') {
  87. let run = 1
  88. while (s.slice(i + run, i + run + 1) == '`') { run = run + 1 }
  89. let mark = repeat('`', run)
  90. let j = s.indexOf(mark, i + run)
  91. if (j > i + run) {
  92. if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }
  93. let inner = s.slice(i + run, j)
  94. if (inner.length > 2 && inner.startsWith(' ') && inner.endsWith(' ')) { inner = inner.slice(1, inner.length - 1) }
  95. host.appendChild(el('code', inner))
  96. i = j + run
  97. done = true
  98. }
  99. }
  100. if (!done && c == '!' && s.slice(i + 1, i + 2) == '[') {
  101. let j = s.indexOf('](', i + 2)
  102. let k = j >= 0 ? s.indexOf(')', j + 2) : -1
  103. if (j > i + 1 && k > j + 2) {
  104. let src = safeSrc(s.slice(j + 2, k))
  105. if (src != '') {
  106. if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }
  107. let img = el('img', null)
  108. img.setAttribute('src', src)
  109. img.setAttribute('alt', s.slice(i + 2, j))
  110. host.appendChild(img)
  111. i = k + 1
  112. done = true
  113. }
  114. }
  115. }
  116. if (!done && c == '[') {
  117. let j = s.indexOf('](', i + 1)
  118. let k = j >= 0 ? s.indexOf(')', j + 2) : -1
  119. if (j > i + 1 && k > j + 2) {
  120. let href = safeHref(s.slice(j + 2, k))
  121. if (href != '') {
  122. if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }
  123. let a = el('a', null)
  124. a.setAttribute('href', href)
  125. inline(a, s.slice(i + 1, j))
  126. host.appendChild(a)
  127. i = k + 1
  128. done = true
  129. }
  130. }
  131. }
  132. if (!done && c == '<' && (s.slice(i + 1, i + 8) == 'http://' || s.slice(i + 1, i + 9) == 'https://')) {
  133. let j = s.indexOf('>', i)
  134. if (j > 0 && s.slice(i, j).indexOf(' ') < 0) {
  135. if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }
  136. let a = el('a', s.slice(i + 1, j))
  137. a.setAttribute('href', s.slice(i + 1, j))
  138. host.appendChild(a)
  139. i = j + 1
  140. done = true
  141. }
  142. }
  143. if (!done && c == 'h' && (s.slice(i, i + 7) == 'http://' || s.slice(i, i + 8) == 'https://') && (i == 0 || !isAlnum(s.slice(i - 1, i)))) {
  144. let j = urlEnd(s, i)
  145. if (j > i + 8) {
  146. if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }
  147. let a = el('a', s.slice(i, j))
  148. a.setAttribute('href', s.slice(i, j))
  149. host.appendChild(a)
  150. i = j
  151. done = true
  152. }
  153. }
  154. if (!done && (c == '*' || c == '_') && !(c == '_' && i > 0 && isAlnum(s.slice(i - 1, i)))) {
  155. let run = 1
  156. while (run < 3 && s.slice(i + run, i + run + 1) == c) { run = run + 1 }
  157. let first = s.slice(i + run, i + run + 1)
  158. if (first != '' && !isSpace(first) && first != c) {
  159. let j = closer(s, i + run, c, run)
  160. if (j > 0) {
  161. if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }
  162. let node = null
  163. let innerNode = null
  164. if (run == 1) { node = el('em', null) innerNode = node }
  165. if (run == 2) { node = el('strong', null) innerNode = node }
  166. if (run == 3) { node = el('strong', null) innerNode = el('em', null) node.appendChild(innerNode) }
  167. inline(innerNode, s.slice(i + run, j))
  168. host.appendChild(node)
  169. i = j + run
  170. done = true
  171. }
  172. }
  173. }
  174. if (!done) { buf = buf + c i = i + 1 }
  175. }
  176. if (buf != '') { host.appendChild(document.createTextNode(buf)) }
  177. }
  178. // a line like `## title`: its level (0 = not a heading)
  179. headingLevel(line) {
  180. let k = 0
  181. while (k < line.length && k < 7 && line.slice(k, k + 1) == '#') { k = k + 1 }
  182. if (k >= 1 && k <= 6 && (line.length == k || line.slice(k, k + 1) == ' ')) { return k }
  183. return 0
  184. }
  185. // a list line: 0 = none, 1 = bullet, 2 = numbered; the item text is cut off by itemText
  186. listKind(line) {
  187. t = line.trim()
  188. if ((t.startsWith('- ') || t.startsWith('* ') || t.startsWith('+ '))) { return 1 }
  189. let k = 0
  190. while (k < t.length && isDigit(t.slice(k, k + 1))) { k = k + 1 }
  191. if (k > 0 && k < 10 && (t.slice(k, k + 2) == '. ' || t.slice(k, k + 2) == ') ')) { return 2 }
  192. return 0
  193. }
  194. itemText(line) {
  195. t = line.trim()
  196. let k = 0
  197. while (k < t.length && isDigit(t.slice(k, k + 1))) { k = k + 1 }
  198. return t.slice(k + 2).trim()
  199. }
  200. fenceOf(line) {
  201. t = line.trim()
  202. if (t.startsWith('```')) { return '`' }
  203. if (t.startsWith('~~~')) { return '~' }
  204. return ''
  205. }
  206. startsBlock(line) { return headingLevel(line) > 0 || listKind(line) > 0 || fenceOf(line) != '' }
  207. // Markdown → the block elements in a container
  208. parseInto(box, md) {
  209. let lines = md.replaceAll(BS + 'r', '').split(NL)
  210. let i = 0
  211. while (i < lines.length) {
  212. let line = lines[i]
  213. let fence = fenceOf(line)
  214. if (fence != '') {
  215. let run = 0
  216. let t = line.trim()
  217. while (t.slice(run, run + 1) == fence) { run = run + 1 }
  218. let body = []
  219. i = i + 1
  220. while (i < lines.length && !(lines[i].trim().startsWith(repeat(fence, run)) && lines[i].trim().replaceAll(fence, '') == '')) { body.push(lines[i]) i = i + 1 }
  221. i = i + 1
  222. box.appendChild(el('pre', body.join(NL)))
  223. } else if (line.trim() == '') {
  224. i = i + 1
  225. } else if (headingLevel(line) > 0) {
  226. let lv = headingLevel(line)
  227. let text = line.trim().slice(lv).trim()
  228. while (text.endsWith('#') && text.length > 0) { text = text.slice(0, text.length - 1) }
  229. let h = el('h' + lv, null)
  230. inline(h, text.trim())
  231. box.appendChild(h)
  232. i = i + 1
  233. } else if (listKind(line) > 0) {
  234. let kind = listKind(line)
  235. let list = el(kind == 1 ? 'ul' : 'ol', null)
  236. let last = null
  237. while (i < lines.length && lines[i].trim() != '' && (listKind(lines[i]) == kind || (listKind(lines[i]) == 0 && !startsBlock(lines[i]) && last != null))) {
  238. if (listKind(lines[i]) == kind) {
  239. last = el('li', null)
  240. inline(last, itemText(lines[i]))
  241. list.appendChild(last)
  242. } else {
  243. last.appendChild(el('br', null))
  244. inline(last, lines[i].trim())
  245. }
  246. i = i + 1
  247. }
  248. box.appendChild(list)
  249. } else {
  250. let p = el('p', null)
  251. let first = true
  252. while (i < lines.length && lines[i].trim() != '' && (first || !startsBlock(lines[i]))) {
  253. if (!first) { p.appendChild(el('br', null)) }
  254. inline(p, lines[i].trim())
  255. first = false
  256. i = i + 1
  257. }
  258. box.appendChild(p)
  259. }
  260. }
  261. if (box.childNodes.length == 0) { let p = el('p', null) p.appendChild(el('br', null)) box.appendChild(p) }
  262. }
  263. // ---- DOM → Markdown ---------------------------------------------------------------------------------------------------
  264. // a text node's characters, with the ones Markdown would read as formatting written as an escape
  265. escapeText(s, first) {
  266. let out = ''
  267. let i = 0
  268. while (i < s.length) {
  269. let c = s.slice(i, i + 1)
  270. let prev = i > 0 ? s.slice(i - 1, i) : ' '
  271. let next = i + 1 < s.length ? s.slice(i + 1, i + 2) : ' '
  272. if (c == BS || c == '*' || c == '`' || c == '[' || c == ']' || c == '<') { out = out + BS + c }
  273. else if (c == '_' && (!isAlnum(prev) || !isAlnum(next))) { out = out + BS + c }
  274. else { out = out + c }
  275. i = i + 1
  276. }
  277. return out
  278. }
  279. // the inline Markdown of an element's children
  280. inlineMd(node) {
  281. let out = ''
  282. kids = node.childNodes
  283. let i = 0
  284. while (i < kids.length) {
  285. let k = kids[i]
  286. if (k.nodeType == 3) {
  287. out = out + escapeText(clean(k.data), false)
  288. } else if (k.nodeType == 1) {
  289. let tag = k.nodeName.toLowerCase()
  290. if (tag == 'br') { out = out + NL }
  291. else if (tag == 'code') {
  292. let t = clean(k.textContent)
  293. if (t != '') {
  294. let run = 1
  295. while (t.indexOf(repeat('`', run)) >= 0) { run = run + 1 }
  296. let pad = t.startsWith('`') || t.endsWith('`') ? ' ' : ''
  297. out = out + repeat('`', run) + pad + t + pad + repeat('`', run)
  298. }
  299. }
  300. else if (tag == 'strong' || tag == 'b' || tag == 'em' || tag == 'i') {
  301. let mark = (tag == 'strong' || tag == 'b') ? '**' : '*'
  302. let t = inlineMd(k)
  303. let core = trimSp(t)
  304. if (core != '') {
  305. let lead = t.slice(0, t.indexOf(core))
  306. let tail = t.slice(t.indexOf(core) + core.length)
  307. out = out + lead + mark + core + mark + tail
  308. } else { out = out + t }
  309. }
  310. else if (tag == 'img') {
  311. let src = safeSrc(k.getAttribute('src') != null ? k.getAttribute('src') : '')
  312. if (src != '') { out = out + '![' + (k.getAttribute('alt') != null ? k.getAttribute('alt') : '').replaceAll('[', '').replaceAll(']', '') + '](' + src.replaceAll(')', '%29').replaceAll(' ', '%20') + ')' }
  313. }
  314. else if (tag == 'a') {
  315. let t = inlineMd(k)
  316. let href = safeHref(k.getAttribute('href') != null ? k.getAttribute('href') : '')
  317. if (href == '' || trimSp(t) == '') { out = out + t }
  318. else if (trimSp(k.textContent) == href && (href.startsWith('http://') || href.startsWith('https://'))) { out = out + href }
  319. else { out = out + '[' + t + '](' + href.replaceAll(')', '%29').replaceAll(' ', '%20') + ')' }
  320. }
  321. else if (tag == 'div' || tag == 'p') { out = out + inlineMd(k) }
  322. else { out = out + inlineMd(k) }
  323. }
  324. i = i + 1
  325. }
  326. return out
  327. }
  328. // the text of a paragraph's or item's lines: trailing / leading breaks dropped, each line trimmed
  329. lines(md) {
  330. parts = md.split(NL)
  331. out = []
  332. for (p of parts) { out.push(trimSp(p)) }
  333. while (out.length > 0 && out[out.length - 1] == '') { out.pop() }
  334. while (out.length > 0 && out[0] == '') { out.shift() }
  335. return out
  336. }
  337. // same for a line starting with digits + "." (the backslash goes before the dot)
  338. guardNumber(line) {
  339. let k = 0
  340. while (k < line.length && isDigit(line.slice(k, k + 1))) { k = k + 1 }
  341. if (k > 0 && (line.slice(k, k + 2) == '. ' || line.slice(k, k + 2) == ') ')) { return line.slice(0, k) + BS + line.slice(k) }
  342. return line
  343. }
  344. paragraphMd(node) {
  345. ls = lines(inlineMd(node))
  346. if (ls.length == 0) { return '' }
  347. out = []
  348. for (l of ls) {
  349. if (headingLevel(l) > 0 || fenceOf(l) != '' || l.startsWith('>') || listKind(l) == 1) { out.push(BS + l) } else { out.push(guardNumber(l)) }
  350. }
  351. return out.join(NL)
  352. }
  353. // the Markdown blocks of a container's children
  354. blocksMd(box) {
  355. out = []
  356. kids = box.childNodes
  357. let i = 0
  358. while (i < kids.length) {
  359. let k = kids[i]
  360. if (k.nodeType == 3) {
  361. let t = paragraphMd(k.parentNode == box ? wrapText(k) : k)
  362. if (t != '') { out.push(t) }
  363. } else if (k.nodeType == 1) {
  364. let tag = k.nodeName.toLowerCase()
  365. if (tag.length == 2 && tag.startsWith('h') && isDigit(tag.slice(1, 2)) && tag != 'hr') {
  366. let t = lines(inlineMd(k)).join(' ')
  367. if (t != '') { out.push(repeat('#', toNumber(tag.slice(1, 2))) + ' ' + t) }
  368. } else if (tag == 'ul' || tag == 'ol') {
  369. let items = []
  370. let n = 1
  371. let li = k.childNodes
  372. let j = 0
  373. while (j < li.length) {
  374. if (li[j].nodeType == 1) {
  375. let ls = lines(inlineMd(li[j]))
  376. if (ls.length > 0) {
  377. items.push((tag == 'ul' ? '- ' : n + '. ') + ls.join(NL + ' '))
  378. n = n + 1
  379. }
  380. }
  381. j = j + 1
  382. }
  383. if (items.length > 0) { out.push(items.join(NL)) }
  384. } else if (tag == 'pre') {
  385. let body = preText(k)
  386. let run = 3
  387. while (body.indexOf(repeat('`', run)) >= 0) { run = run + 1 }
  388. if (body != '') { out.push(repeat('`', run) + NL + body + NL + repeat('`', run)) }
  389. } else if ((tag == 'div' || tag == 'p') && hasBlockChild(k)) {
  390. for (x of blocksMd(k)) { out.push(x) }
  391. } else if (tag == 'br') {
  392. } else {
  393. let t = paragraphMd(k)
  394. if (t != '') { out.push(t) }
  395. }
  396. }
  397. i = i + 1
  398. }
  399. return out
  400. }
  401. wrapText(t) { p = el('p', null) p.appendChild(document.createTextNode(t.data)) return p }
  402. hasBlockChild(node) {
  403. kids = node.childNodes
  404. let i = 0
  405. while (i < kids.length) {
  406. 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 }
  407. i = i + 1
  408. }
  409. return false
  410. }
  411. // the text of a code block (a <br> is a line break)
  412. preText(node) {
  413. let out = ''
  414. kids = node.childNodes
  415. let i = 0
  416. while (i < kids.length) {
  417. let k = kids[i]
  418. if (k.nodeType == 3) { out = out + k.data.replaceAll(ZW, '').replaceAll(NB, ' ') }
  419. else if (k.nodeName.toLowerCase() == 'br') { out = out + NL }
  420. else { out = out + preText(k) }
  421. i = i + 1
  422. }
  423. // a trailing line feed is the browser's, not the writer's
  424. while (out.endsWith(NL)) { out = out.slice(0, out.length - 1) }
  425. return out
  426. }
  427. toMarkdown(box) {
  428. return blocksMd(box).join(NL + NL)
  429. }
  430. // ---- the editor ---------------------------------------------------------------------------------------------------------
  431. rootOf(node) { return node.closest('md-editor') }
  432. surfaceOf(root) { return root.querySelector('article') }
  433. fieldOf(root) { return root.querySelector('input.value') }
  434. sourceOf(root) { return root.querySelector('textarea') }
  435. // the block (a direct child of the surface) that holds a node
  436. blockOf(root, node) {
  437. s = surfaceOf(root)
  438. let n = node
  439. while (n != null && n.parentNode != s) { n = n.parentNode }
  440. return n
  441. }
  442. inSurface(root) {
  443. sel = document.getSelection()
  444. return sel.rangeCount > 0 && sel.anchorNode != null && surfaceOf(root).contains(sel.anchorNode)
  445. }
  446. inTag(root, tags) {
  447. sel = document.getSelection()
  448. let n = sel.anchorNode
  449. s = surfaceOf(root)
  450. while (n != null && n != s) {
  451. if (n.nodeType == 1 && tags.indexOf(n.nodeName.toLowerCase()) >= 0) { return true }
  452. n = n.parentNode
  453. }
  454. return false
  455. }
  456. // 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.
  457. sync(root) {
  458. s = surfaceOf(root)
  459. f = fieldOf(root)
  460. let md = toMarkdown(s)
  461. if (root.mdOrig != null && md == root.mdCanon) { md = root.mdOrig }
  462. root.classList.toggle('empty', md == '' && s.querySelector('pre') == null)
  463. if (md != f.value) {
  464. f.value = md
  465. f.dispatchEvent(new Event('input', { bubbles = true }))
  466. }
  467. }
  468. // a new value (from outside, the source view or a paste-all): shown formatted
  469. load(root, md) {
  470. s = surfaceOf(root)
  471. s.textContent = ''
  472. parseInto(s, md)
  473. root.mdOrig = md
  474. root.mdCanon = toMarkdown(s)
  475. root.classList.toggle('empty', root.mdCanon == '')
  476. }
  477. // the toolbar shows what the caret is in
  478. updateBar(root) {
  479. if (!root.isConnected || !inSurface(root)) { return null }
  480. mark = (tool, active) => { b = root.querySelector('button.' + tool) if (b != null) { b.classList.toggle('on', active) } }
  481. mark('bold', document.queryCommandState('bold'))
  482. mark('italic', document.queryCommandState('italic'))
  483. mark('code', inTag(root, ['code']))
  484. mark('ul', inTag(root, ['ul']))
  485. mark('ol', inTag(root, ['ol']))
  486. mark('pre', inTag(root, ['pre']))
  487. mark('link', inTag(root, ['a']))
  488. h = root.querySelector('button.heading')
  489. let level = ''
  490. tags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']
  491. for (t of tags) { if (inTag(root, [t])) { level = t.toUpperCase() } }
  492. if (h != null) { h.textContent = level != '' ? level : 'H' h.classList.toggle('on', level != '') }
  493. }
  494. // bold / italic / heading / lists / code block: the browser's own editing commands, so undo works
  495. codeToggle(root) {
  496. sel = document.getSelection()
  497. r = sel.getRangeAt(0)
  498. let n = sel.anchorNode
  499. let inCode = null
  500. while (n != null && n != surfaceOf(root)) { if (n.nodeType == 1 && n.nodeName.toLowerCase() == 'code') { inCode = n } n = n.parentNode }
  501. if (inCode != null) {
  502. document.execCommand('insertHTML', false, escHtml(inCode.textContent, ''))
  503. return null
  504. }
  505. if (r.collapsed) { return null }
  506. document.execCommand('insertHTML', false, escHtml(r.toString(), 'code') + ZW)
  507. }
  508. // text as escaped html (built by the browser from a text node), optionally inside a tag
  509. escHtml(text, tag) {
  510. box = document.createElement('div')
  511. e = tag != '' ? el(tag, text) : document.createTextNode(text)
  512. box.appendChild(e)
  513. return box.innerHTML
  514. }
  515. headingCycle(root) {
  516. let cur = 0
  517. tags = ['h1', 'h2', 'h3']
  518. let i = 0
  519. while (i < 3) { if (inTag(root, [tags[i]])) { cur = i + 1 } i = i + 1 }
  520. document.execCommand('formatBlock', false, cur == 3 ? 'p' : tags[cur])
  521. }
  522. blockToggle(root, tag, cmd) {
  523. if (inTag(root, [tag])) {
  524. if (tag == 'pre') { document.execCommand('formatBlock', false, 'p') } else { document.execCommand(cmd) }
  525. } else if (tag == 'pre') { document.execCommand('formatBlock', false, 'pre') } else { document.execCommand(cmd) }
  526. }
  527. linkOpen(root) {
  528. sel = document.getSelection()
  529. if (!inSurface(root)) { surfaceOf(root).focus() }
  530. root.mdRange = sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null
  531. f = root.querySelector('link-form input')
  532. let n = sel.anchorNode
  533. let href = ''
  534. while (n != null && n != surfaceOf(root)) { if (n.nodeType == 1 && n.nodeName.toLowerCase() == 'a') { href = n.getAttribute('href') } n = n.parentNode }
  535. f.value = href
  536. root.classList.add('linking')
  537. f.focus()
  538. }
  539. linkClose(root) {
  540. root.classList.remove('linking')
  541. surfaceOf(root).focus()
  542. sel = document.getSelection()
  543. if (root.mdRange != null) { sel.removeAllRanges() sel.addRange(root.mdRange) }
  544. }
  545. linkApply(root, remove) {
  546. f = root.querySelector('link-form input')
  547. href = remove ? '' : safeHref(f.value)
  548. linkClose(root)
  549. if (remove || f.value.trim() == '') { document.execCommand('unlink') }
  550. else if (href != '') {
  551. sel = document.getSelection()
  552. if (sel.isCollapsed) { document.execCommand('insertHTML', false, escLink(href)) } else { document.execCommand('createLink', false, href) }
  553. }
  554. sync(root)
  555. }
  556. escLink(href) {
  557. box = document.createElement('div')
  558. a = el('a', href)
  559. a.setAttribute('href', href)
  560. box.appendChild(a)
  561. return box.innerHTML
  562. }
  563. // the parsed Markdown as html the browser can insert (built from elements, so every text is escaped)
  564. mdHtml(md) {
  565. box = document.createElement('div')
  566. parseInto(box, md)
  567. // a single paragraph is inserted inline: no extra block
  568. if (box.childNodes.length == 1 && box.firstChild.nodeName.toLowerCase() == 'p') { return box.firstChild.innerHTML }
  569. return box.innerHTML
  570. }
  571. // typing rules at the caret: "# " "- " "1. " make a block; the typed marker is consumed (only the formatting stays). They work in a
  572. // 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)
  573. blockRule(root, e) {
  574. sel = document.getSelection()
  575. if (!sel.isCollapsed || !inSurface(root)) { return false }
  576. let b = blockOf(root, sel.anchorNode)
  577. if (b == null) { return false }
  578. let tag = b.nodeName.toLowerCase()
  579. let inner = sel.anchorNode
  580. while (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 }
  581. if (inner != null && inner != surfaceOf(root) && inner.nodeName.toLowerCase() != 'li') { b = inner tag = inner.nodeName.toLowerCase() }
  582. let item = sel.anchorNode
  583. while (item != null && item != surfaceOf(root) && item.nodeName.toLowerCase() != 'li') { item = item.parentNode }
  584. if (item == surfaceOf(root)) { item = null }
  585. let box = b
  586. if (item != null) {
  587. box = item
  588. tag = item.parentNode.nodeName.toLowerCase()
  589. } else if (tag != 'p' && tag != 'div' && !(tag.length == 2 && tag.startsWith('h'))) { return false }
  590. r = sel.getRangeAt(0)
  591. pre = document.createRange()
  592. pre.selectNodeContents(box)
  593. pre.setEnd(r.endContainer, r.endOffset)
  594. before = clean(pre.toString())
  595. if (before != clean(box.textContent)) { return false }
  596. let cmd = ''
  597. let arg = ''
  598. if (item == null && before.length >= 1 && before.length <= 6 && before.replaceAll('#', '') == '') { cmd = 'formatBlock' arg = 'h' + before.length }
  599. if (before == '-' || before == '*' || before == '+') { cmd = 'insertUnorderedList' }
  600. if (before.length >= 2 && before.endsWith('.') && before.slice(0, before.length - 1).length < 9) {
  601. let digits = true
  602. let k = 0
  603. while (k < before.length - 1) { if (!isDigit(before.slice(k, k + 1))) { digits = false } k = k + 1 }
  604. if (digits) { cmd = 'insertOrderedList' }
  605. }
  606. if (cmd == '') { return false }
  607. e.preventDefault()
  608. sel.selectAllChildren(box)
  609. document.execCommand('delete')
  610. // a list marker typed in a list of that kind is only consumed
  611. if (item != null && ((cmd == 'insertUnorderedList' && tag == 'ul') || (cmd == 'insertOrderedList' && tag == 'ol'))) { return true }
  612. if (item == null && tag != 'p' && tag != 'div' && cmd != 'formatBlock') { document.execCommand('formatBlock', false, 'p') }
  613. if (arg != '') { document.execCommand(cmd, false, arg) } else { document.execCommand(cmd) }
  614. return true
  615. }
  616. // a closing * _ ` typed: find the opener before the caret in the same text node
  617. inlineRule(root, e) {
  618. sel = document.getSelection()
  619. node = sel.anchorNode
  620. if (!sel.isCollapsed || node == null || node.nodeType != 3 || !surfaceOf(root).contains(node)) { return null }
  621. if (inTag(root, ['code', 'pre'])) { return null }
  622. off = sel.anchorOffset
  623. t = node.data.slice(0, off)
  624. c = e.data
  625. let tag = ''
  626. let start = -1
  627. let ends = ''
  628. if (c == '`') {
  629. let j = t.slice(0, off - 1).lastIndexOf('`')
  630. if (j >= 0 && off - 1 - j - 1 >= 1) { start = j tag = 'code' ends = t.slice(j + 1, off - 1) }
  631. }
  632. if (c == '*' && t.length >= 5 && t.endsWith('**')) {
  633. let j = t.slice(0, off - 2).lastIndexOf('**')
  634. let inner = j >= 0 ? t.slice(j + 2, off - 2) : ''
  635. if (j >= 0 && inner != '' && !isSpace(inner.slice(0, 1)) && !isSpace(inner.slice(inner.length - 1))) { start = j tag = 'strong' ends = inner }
  636. }
  637. if ((c == '*' || c == '_') && tag == '' && t.length >= 3 && !t.endsWith(c + c)) {
  638. let j = t.slice(0, off - 1).lastIndexOf(c)
  639. let inner = j >= 0 ? t.slice(j + 1, off - 1) : ''
  640. okStart = j >= 0 && (j == 0 || !isAlnum(t.slice(j - 1, j)) || c == '*') && t.slice(j - 1, j) != c && t.slice(j + 1, j + 2) != c
  641. if (okStart && inner != '' && !isSpace(inner.slice(0, 1)) && !isSpace(inner.slice(inner.length - 1))) { start = j tag = 'em' ends = inner }
  642. }
  643. if (tag == '') { return null }
  644. r = document.createRange()
  645. r.setStart(node, start)
  646. r.setEnd(node, off)
  647. sel.removeAllRanges()
  648. sel.addRange(r)
  649. document.execCommand('insertHTML', false, escHtml(ends, tag) + ZW)
  650. // Chrome pins the colour on the new element (`<strong style="color: …">`) when it equals the text around it (the black/white base
  651. // look, #15); the surface never carries styles of its own
  652. for (k of surfaceOf(root).querySelectorAll('[style]')) { k.removeAttribute('style') }
  653. }
  654. // ---- 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)
  655. Style {
  656. '@keyframes md-editor-mount' { 'from' { opacity = '.999' } to { opacity = '1' } }
  657. 'md-editor' { position = 'relative' display = 'block' border = '0' borderRadius = '.5rem' background = 'var(--color-background-sunken, #f2f2f2)' color = 'var(--color-text, #000)' }
  658. 'md-editor:focus-within' { outline = '2px solid var(--color-accent, #555)' outlineOffset = '-1px' }
  659. 'md-editor nav' { display = 'flex' flexWrap = 'wrap' gap = '.25rem' padding = '.35rem' borderBottom = '0' background = 'var(--color-surface, #ddd)' borderRadius = '.5rem .5rem 0 0' }
  660. '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' }
  661. 'md-editor nav button:hover' { background = 'var(--color-background, #fff)' }
  662. 'md-editor nav button.on' { color = 'var(--color-accent-text, #fff)' background = 'var(--color-accent, #000)' }
  663. 'md-editor nav button.italic' { fontStyle = 'italic' }
  664. 'md-editor nav button.code' { fontFamily = 'ui-monospace, monospace' }
  665. 'md-editor nav button:disabled' { opacity = '.4' cursor = 'default' }
  666. 'md-editor.source nav button' { opacity = '.4' pointerEvents = 'none' }
  667. 'md-editor nav input.pick' { display = 'none' }
  668. 'md-editor ul.files' { display = 'none' margin = '0' padding = '.35rem .8rem' listStyle = 'none' borderBottom = '0' fontSize = '.85rem' }
  669. 'md-editor ul.files:not(:empty)' { display = 'block' }
  670. 'md-editor ul.files li' { display = 'flex' alignItems = 'center' gap = '.6rem' }
  671. 'md-editor ul.files li span' { flex = '0 1 12rem' overflow = 'hidden' textOverflow = 'ellipsis' whiteSpace = 'nowrap' }
  672. 'md-editor ul.files progress' { flex = '1' accentColor = 'var(--color-accent, #000)' }
  673. 'md-editor ul.files li.error' { color = 'var(--color-danger, #000)' }
  674. 'md-editor link-form' { display = 'none' gap = '.35rem' padding = '.35rem' borderBottom = '0' background = 'var(--color-surface, #ddd)' }
  675. 'md-editor.linking link-form' { display = 'flex' }
  676. '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' }
  677. 'md-editor link-form button' { font = 'inherit' padding = '.3rem .7rem' color = 'var(--color-text, #000)' background = 'transparent' border = '0' borderRadius = '.35rem' cursor = 'pointer' }
  678. 'md-editor article' { display = 'block' minHeight = '8rem' padding = '.6rem .8rem' outline = 'none' overflowWrap = 'anywhere' animation = 'md-editor-mount 100ms infinite' }
  679. 'md-editor.source article' { display = 'none' }
  680. 'md-editor .placeholder' { display = 'none' position = 'absolute' left = '.8rem' marginTop = '.6rem' pointerEvents = 'none' color = 'var(--color-text-muted, #555)' }
  681. 'md-editor.empty:not(.source) .placeholder' { display = 'block' }
  682. 'md-editor article > :first-child' { marginTop = '0' }
  683. 'md-editor article > :last-child' { marginBottom = '0' }
  684. '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)' }
  685. 'md-editor article h1' { fontSize = '1.6rem' }
  686. 'md-editor article h2' { fontSize = '1.35rem' }
  687. 'md-editor article h3' { fontSize = '1.15rem' }
  688. 'md-editor article h4, md-editor article h5, md-editor article h6' { fontSize = '1rem' }
  689. 'md-editor article p' { margin = '0 0 .7rem' }
  690. 'md-editor article ul, md-editor article ol' { margin = '0 0 .7rem' paddingLeft = '1.5rem' }
  691. 'md-editor article img' { display = 'block' maxWidth = '100%' height = 'auto' margin = '.3rem 0' borderRadius = '.3rem' }
  692. 'md-editor article strong' { color = 'var(--color-text-strong, #000)' }
  693. 'md-editor article a' { color = 'var(--color-accent, #000)' textDecoration = 'underline' }
  694. 'md-editor article code' { padding = '0 .25rem' fontFamily = 'ui-monospace, monospace' fontSize = '.9em' background = 'var(--color-surface, #ddd)' borderRadius = '.25rem' }
  695. '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' }
  696. 'md-editor footer' { display = 'flex' justifyContent = 'flex-end' padding = '.3rem .5rem' borderTop = '0' }
  697. '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' }
  698. 'md-editor footer button:hover' { color = 'var(--color-text, #000)' }
  699. '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' }
  700. 'md-editor.source textarea' { display = 'block' }
  701. }
  702. // The hidden input `name` carries the value in a form; the article is the surface; the textarea is the source view (a value shown
  703. // only while it is on). The rendered parts never change after the first render, so the framework leaves the hand-built content alone.
  704. View {
  705. mdEditor {
  706. nav {
  707. button { type = "button" class = "bold" title = "Bold (Ctrl+B)" "B" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'bold') } }
  708. button { type = "button" class = "italic" title = "Italic (Ctrl+I)" "I" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'italic') } }
  709. button { type = "button" class = "code" title = "Code (Ctrl+E)" "<>" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'code') } }
  710. button { type = "button" class = "link" title = "Link (Ctrl+K)" "Link" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'link') } }
  711. button { type = "button" class = "heading" title = "Heading: paragraph, H1, H2, H3" "H" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'heading') } }
  712. button { type = "button" class = "ul" title = "Bulleted list" "• List" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'ul') } }
  713. button { type = "button" class = "ol" title = "Numbered list" "1. List" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'ol') } }
  714. button { type = "button" class = "pre" title = "Code block" "Block" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'pre') } }
  715. 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) } }
  716. input { type = "file" class = "pick" multiple = "multiple" on change(e) { emit mdPicked(e) } }
  717. button { type = "button" class = "undo" title = "Undo" "↶" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'undo') } }
  718. button { type = "button" class = "redo" title = "Redo" "↷" on mousedown(e) { e.preventDefault() } on click(e) { emit mdTool(e, 'redo') } }
  719. }
  720. linkForm {
  721. input { type = "text" placeholder = "https://…" autocomplete = "off" on keydown(e) { emit mdLinkKey(e) } }
  722. button { type = "button" "Apply" on click(e) { emit mdLinkSubmit(e) } }
  723. button { type = "button" title = "Remove the link" "Remove" on click(e) { emit mdLinkRemove(e) } }
  724. }
  725. ul { class = "files" 'aria-live' = "polite" }
  726. span { class = "placeholder" placeholder }
  727. 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) } }
  728. textarea { rows = rows spellcheck = "false" on input(e) { emit mdSourceInput(e) } }
  729. input { class = "value" type = "hidden" name = name value = value }
  730. footer { button { type = "button" class = "source-switch" on click(e) { emit mdSource(e) } "Markdown source" } }
  731. }
  732. }
  733. on mdMount(e) {
  734. s = e.target
  735. s.style.animationName = 'none'
  736. root = rootOf(s)
  737. document.execCommand('defaultParagraphSeparator', false, 'p')
  738. document.execCommand('styleWithCSS', false, false)
  739. load(root, fieldOf(root).value)
  740. sourceOf(root).value = fieldOf(root).value
  741. document.addEventListener('selectionchange', () => { updateBar(root) })
  742. }
  743. on mdInput(e) {
  744. e.stopPropagation()
  745. root = rootOf(e.target)
  746. s = surfaceOf(root)
  747. // an emptied surface gets its paragraph back
  748. if (s.childNodes.length == 0) {
  749. np = document.createElement('p')
  750. np.appendChild(document.createElement('br'))
  751. s.appendChild(np)
  752. document.getSelection().collapse(np, 0)
  753. }
  754. if (e.inputType == 'insertText' && (e.data == '*' || e.data == '_' || e.data == '`')) { inlineRule(root, e) }
  755. sync(root)
  756. updateBar(root)
  757. }
  758. on mdBefore(e) {
  759. if (e.inputType == 'insertText' && e.data == ' ') {
  760. root = rootOf(e.target)
  761. blockRule(root, e)
  762. }
  763. }
  764. on mdKey(e) {
  765. root = rootOf(e.target)
  766. mod = e.ctrlKey || e.metaKey
  767. if (mod && !e.shiftKey && !e.altKey && (e.key == 'k' || e.key == 'K')) { e.preventDefault() linkOpen(root) return null }
  768. if (mod && !e.shiftKey && !e.altKey && (e.key == 'e' || e.key == 'E')) { e.preventDefault() codeToggle(root) sync(root) return null }
  769. if (mod && e.shiftKey && (e.key == '8' || e.key == '*')) { e.preventDefault() blockToggle(root, 'ul', 'insertUnorderedList') sync(root) return null }
  770. if (mod && e.shiftKey && (e.key == '7' || e.key == '&')) { e.preventDefault() blockToggle(root, 'ol', 'insertOrderedList') sync(root) return null }
  771. if (mod && e.altKey && e.code == 'KeyC') { e.preventDefault() blockToggle(root, 'pre', '') sync(root) return null }
  772. if (mod && e.altKey && e.code == 'Digit0') { e.preventDefault() document.execCommand('formatBlock', false, 'p') sync(root) return null }
  773. 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 }
  774. // Ctrl+Enter sends the form the editor sits in
  775. if (mod && e.key == 'Enter') {
  776. e.preventDefault()
  777. form = root.closest('form')
  778. if (form != null) { form.requestSubmit() }
  779. return null
  780. }
  781. // Backspace at the very start of a heading / list item / code block turns it into a paragraph
  782. if (e.key == 'Backspace' && document.getSelection().isCollapsed && inSurface(root)) {
  783. let sel = document.getSelection()
  784. let b = blockOf(root, sel.anchorNode)
  785. if (b != null && 'h1,h2,h3,h4,h5,h6,ul,ol,pre'.split(',').indexOf(b.nodeName.toLowerCase()) >= 0) {
  786. r = sel.getRangeAt(0)
  787. pre = document.createRange()
  788. pre.selectNodeContents(b)
  789. pre.setEnd(r.endContainer, r.endOffset)
  790. if (pre.toString() == '') {
  791. e.preventDefault()
  792. tag = b.nodeName.toLowerCase()
  793. if (tag == 'ul') { document.execCommand('insertUnorderedList') }
  794. else if (tag == 'ol') { document.execCommand('insertOrderedList') }
  795. else { document.execCommand('formatBlock', false, 'p') }
  796. sync(root)
  797. return null
  798. }
  799. }
  800. }
  801. // ``` at the start of a line + Enter opens a code block
  802. if (e.key == 'Enter' && !mod && !e.shiftKey && !inTag(root, ['pre'])) {
  803. let sel = document.getSelection()
  804. let b = sel.anchorNode != null ? blockOf(root, sel.anchorNode) : null
  805. if (b != null && b.nodeName.toLowerCase() == 'p' && sel.isCollapsed && (clean(b.textContent).startsWith('```') || clean(b.textContent).startsWith('~~~')) && clean(b.textContent).replaceAll('`', '').replaceAll('~', '') == '') {
  806. e.preventDefault()
  807. sel.selectAllChildren(b)
  808. document.execCommand('delete')
  809. document.execCommand('formatBlock', false, 'pre')
  810. sync(root)
  811. }
  812. }
  813. }
  814. on mdTool(e, tool) {
  815. root = rootOf(e.target)
  816. s = surfaceOf(root)
  817. if (!inSurface(root)) { s.focus() }
  818. if (tool == 'bold') { document.execCommand('bold') }
  819. if (tool == 'italic') { document.execCommand('italic') }
  820. if (tool == 'code') { codeToggle(root) }
  821. if (tool == 'link') { linkOpen(root) return null }
  822. if (tool == 'heading') { headingCycle(root) }
  823. if (tool == 'ul') { blockToggle(root, 'ul', 'insertUnorderedList') }
  824. if (tool == 'ol') { blockToggle(root, 'ol', 'insertOrderedList') }
  825. if (tool == 'pre') { blockToggle(root, 'pre', '') }
  826. if (tool == 'undo') { document.execCommand('undo') }
  827. if (tool == 'redo') { document.execCommand('redo') }
  828. sync(root)
  829. updateBar(root)
  830. }
  831. on mdLinkSubmit(e) { linkApply(rootOf(e.target), false) }
  832. on mdLinkRemove(e) { linkApply(rootOf(e.target), true) }
  833. on mdLinkKey(e) {
  834. if (e.key == 'Escape') { e.preventDefault() linkClose(rootOf(e.target)) }
  835. if (e.key == 'Enter') { e.preventDefault() linkApply(rootOf(e.target), false) }
  836. }
  837. // pasted / dropped text is read as Markdown (rich HTML is not taken: only its plain text)
  838. insertMarkdown(root, text) {
  839. if (inTag(root, ['pre'])) { document.execCommand('insertText', false, text) return null }
  840. document.execCommand('insertHTML', false, mdHtml(text))
  841. }
  842. on mdPaste(e) {
  843. e.preventDefault()
  844. root = rootOf(e.target)
  845. if (e.clipboardData.files.length > 0) { attach(root, e.clipboardData.files, null) return null }
  846. insertMarkdown(root, e.clipboardData.getData('text/plain'))
  847. sync(root)
  848. }
  849. on mdDrop(e) {
  850. e.preventDefault()
  851. root = rootOf(e.target)
  852. if (e.dataTransfer.files.length > 0) {
  853. at = document.caretRangeFromPoint(e.clientX, e.clientY)
  854. attach(root, e.dataTransfer.files, at)
  855. return null
  856. }
  857. text = e.dataTransfer.getData('text/plain')
  858. if (text != '') { insertMarkdown(root, text) sync(root) }
  859. }
  860. // ---- files and images: dropped, pasted or chosen files go to the app through `emit server mdUpload(files)` (the framework sends them
  861. // over HTTP with progress); the app stores them and answers the addresses; each lands in the text as ![name](address) (an image) or
  862. // [name](address). Progress comes back as `uploadProgress` and is shown under the toolbar.
  863. fileRow(root, p) {
  864. list = root.querySelector('ul.files')
  865. let li = list.children[p.index]
  866. if (li == null) {
  867. li = document.createElement('li')
  868. li.appendChild(el('span', p.name))
  869. li.appendChild(document.createElement('progress'))
  870. list.appendChild(li)
  871. }
  872. bar = li.querySelector('progress')
  873. bar.max = p.size
  874. bar.value = p.sent
  875. }
  876. on client uploadProgress(p) {
  877. for (root of document.querySelectorAll('md-editor.uploading')) { fileRow(root, p) }
  878. }
  879. attachMd(names, kinds, urls) {
  880. out = []
  881. let i = 0
  882. while (i < urls.length) {
  883. let label = names[i].replaceAll('[', '').replaceAll(']', '')
  884. let u = urls[i].replaceAll(')', '%29').replaceAll(' ', '%20')
  885. if (urls[i] != '') { out.push((kinds[i] ? '!' : '') + '[' + label + '](' + u + ')') }
  886. i = i + 1
  887. }
  888. return out.join(NL + NL)
  889. }
  890. attach(root, files, at) {
  891. s = surfaceOf(root)
  892. sel = document.getSelection()
  893. let range = at
  894. if (range == null && inSurface(root)) { range = sel.getRangeAt(0).cloneRange() }
  895. list = []
  896. names = []
  897. kinds = []
  898. let k = 0
  899. while (k < files.length) { list.push(files[k]) names.push(files[k].name) kinds.push(files[k].type.startsWith('image/')) k = k + 1 }
  900. box = root.querySelector('ul.files')
  901. box.textContent = ''
  902. root.classList.add('uploading')
  903. root.classList.remove('failed')
  904. urls = emit server mdUpload(list)
  905. root.classList.remove('uploading')
  906. if (urls == null || urls.length != list.length) {
  907. root.classList.add('failed')
  908. box.textContent = ''
  909. li = el('li', 'The upload failed.')
  910. li.className = 'error'
  911. box.appendChild(li)
  912. return null
  913. }
  914. box.textContent = ''
  915. s.focus()
  916. if (range != null && s.contains(range.startContainer)) { sel.removeAllRanges() sel.addRange(range) }
  917. else { sel.selectAllChildren(s) sel.collapseToEnd() }
  918. md = attachMd(names, kinds, urls)
  919. if (md == '') { return null }
  920. // 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
  921. // item's text: it is put on its own after the list, as elements (an editing command would join it to the item)
  922. let top = null
  923. if (sel.anchorNode == s) { top = s.childNodes[sel.anchorOffset - 1 >= 0 ? sel.anchorOffset - 1 : 0] }
  924. else if (sel.anchorNode != null && s.contains(sel.anchorNode)) { top = blockOf(root, sel.anchorNode) }
  925. if (top != null && (top.nodeName == 'UL' || top.nodeName == 'OL')) {
  926. tmp = document.createElement('div')
  927. parseInto(tmp, md)
  928. ref = top.nextSibling
  929. while (tmp.firstChild != null) { s.insertBefore(tmp.firstChild, ref) }
  930. sel.selectAllChildren(s)
  931. sel.collapseToEnd()
  932. sync(root)
  933. return null
  934. }
  935. insertMarkdown(root, md)
  936. sync(root)
  937. }
  938. on mdPick(e) {
  939. root = rootOf(e.target)
  940. root.querySelector('input.pick').click()
  941. }
  942. on mdPicked(e) {
  943. root = rootOf(e.target)
  944. input = e.target
  945. if (input.files.length > 0) { attach(root, input.files, null) }
  946. input.value = ''
  947. }
  948. // Ctrl / Cmd + click follows a link
  949. on mdClick(e) {
  950. a = e.target.closest('a')
  951. if ((e.ctrlKey || e.metaKey) && a != null && safeHref(a.getAttribute('href')) != '') { window.open(a.getAttribute('href'), '_blank', 'noopener') }
  952. }
  953. on mdBlur(e) { sync(rootOf(e.target)) }
  954. // the Markdown source view: the whole field as a plain textarea, and back
  955. on mdSource(e) {
  956. root = rootOf(e.target)
  957. ta = sourceOf(root)
  958. b = root.querySelector('button.source-switch')
  959. if (root.classList.contains('source')) {
  960. md = ta.value
  961. load(root, md)
  962. root.classList.remove('source')
  963. b.textContent = 'Markdown source'
  964. sync(root)
  965. surfaceOf(root).focus()
  966. } else {
  967. sync(root)
  968. ta.value = fieldOf(root).value
  969. root.classList.add('source')
  970. b.textContent = 'Visual editor'
  971. ta.focus()
  972. }
  973. }
  974. on mdSourceInput(e) {
  975. root = rootOf(e.target)
  976. f = fieldOf(root)
  977. f.value = e.target.value
  978. f.dispatchEvent(new Event('input', { bubbles = true }))
  979. }

Branches

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