gitoriaLog in with ident

components

All repositories: gitoria

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

16.1 KB

  1. // code-editor.hl — THE CODE EDITOR, all Hybriel (port of custom-ide's CodeJar editor): the code is highlighted while you type, line
  2. // numbers follow, Tab / Shift+Tab indent and outdent, Enter keeps the indent, brackets and quotes close themselves, Ctrl+Z / Ctrl+Shift+Z
  3. // undo and redo. `CodeEditor { name = "source" value = "let x = 1" language = "hybriel" }` — the value is the plain text.
  4. // Like CodeJar: ONE contenteditable="plaintext-only" element holds the code as text nodes and <span class="tok-…"> tokens; after every
  5. // edit the text is read back, painted again and the caret put back by its text offset. The framework never re-renders the surface
  6. // (nothing bound to it changes after the first render). Hybriel has no mount event, so a 100 ms CSS animation on the surface fires
  7. // `animationiteration` once the page is live; that hands the editor its first content. The value is written to the hidden field
  8. // `<input name>` (posts in a form) and announced with a bubbling DOM `input` event on it (`e.target.value` = the text).
  9. // Languages: hybriel, js (javascript), plain. The undo history is the editor's own (snapshots of the text and the caret), as in CodeJar.
  10. String name = 'code'
  11. String value = ''
  12. String language = 'hybriel'
  13. Number spaces = 0
  14. static NL = "
  15. "
  16. static TAB = " "
  17. static BS = 'x\y'.slice(1, 2)
  18. static hybrielWords = ' import from static on server emit View Style parent if else for while return let new true false null class extends this in of self mode try catch throw break continue String Number Boolean Hybrid Text '
  19. static jsWords = ' import export from default const let var function return if else for while do switch case break continue new this class extends super typeof instanceof in of try catch finally throw async await yield true false null undefined void delete static get set '
  20. // ---- small text helpers ---------------------------------------------------------------------------------------------
  21. isDigit(c) { k = c.charCodeAt(0) return k >= 48 && k <= 57 }
  22. isUpper(c) { k = c.charCodeAt(0) return k >= 65 && k <= 90 }
  23. isWordStart(c) { k = c.charCodeAt(0) return (k >= 65 && k <= 90) || (k >= 97 && k <= 122) || k == 95 || k == 36 || k > 127 }
  24. isWordChar(c) { return isWordStart(c) || isDigit(c) }
  25. wordsOf(lang) {
  26. if (lang == 'hybriel') { return hybrielWords }
  27. if (lang == 'js' || lang == 'javascript') { return jsWords }
  28. return ''
  29. }
  30. lineEnd(s, i) {
  31. j = s.indexOf(NL, i)
  32. return j < 0 ? s.length : j
  33. }
  34. // the end (exclusive) of the string that opens at i with the quote q: a backslash masks the next character; only a template
  35. // string (`) runs over lines
  36. stringEnd(s, i, q) {
  37. let j = i + 1
  38. n = s.length
  39. while (j < n) {
  40. let c = s.slice(j, j + 1)
  41. if (c == BS) { j = j + 2 }
  42. else if (c == q) { return j + 1 }
  43. else if (c == NL && q != '`') { return j }
  44. else { j = j + 1 }
  45. }
  46. return n
  47. }
  48. // the leading blanks of a line
  49. leadingBlanks(line) {
  50. let k = 0
  51. while (k < line.length && (line.slice(k, k + 1) == ' ' || line.slice(k, k + 1) == TAB)) { k = k + 1 }
  52. return line.slice(0, k)
  53. }
  54. repeat(c, n) { let s = '' let i = 0 while (i < n) { s = s + c i = i + 1 } return s }
  55. // ---- highlighting: the text → text nodes and <span class="tok-…"> in a host ---------------------------------------------
  56. span(host, cls, text) {
  57. e = document.createElement('span')
  58. e.className = 'tok-' + cls
  59. e.textContent = text
  60. host.appendChild(e)
  61. }
  62. paint(host, s, lang) {
  63. words = wordsOf(lang)
  64. let buf = ''
  65. let i = 0
  66. n = s.length
  67. while (i < n) {
  68. let c = s.slice(i, i + 1)
  69. let two = s.slice(i, i + 2)
  70. let end = -1
  71. let cls = ''
  72. if (lang != 'plain') {
  73. if (two == '//') { end = lineEnd(s, i) cls = 'comment' }
  74. else if (two == '/*') { let j = s.indexOf('*/', i + 2) end = j < 0 ? n : j + 2 cls = 'comment' }
  75. else if (c == "'" || c == '"' || c == '`') { end = stringEnd(s, i, c) cls = 'string' }
  76. else if (isDigit(c) && (i == 0 || !isWordChar(s.slice(i - 1, i)))) {
  77. let j = i + 1
  78. while (j < n && (isWordChar(s.slice(j, j + 1)) || (s.slice(j, j + 1) == '.' && isDigit(s.slice(j + 1, j + 2))))) { j = j + 1 }
  79. end = j
  80. cls = 'number'
  81. }
  82. else if (isWordStart(c)) {
  83. let j = i + 1
  84. while (j < n && isWordChar(s.slice(j, j + 1))) { j = j + 1 }
  85. let w = s.slice(i, j)
  86. let prev = i > 0 ? s.slice(i - 1, i) : ''
  87. if (words.indexOf(' ' + w + ' ') >= 0 && prev != '.') { end = j cls = 'keyword' }
  88. else if (s.slice(j, j + 1) == '(') { end = j cls = 'function' }
  89. else if (isUpper(c) && w.length > 1) { end = j cls = 'type' }
  90. else { buf = buf + w i = j end = -2 }
  91. }
  92. }
  93. if (end > i) {
  94. if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }
  95. span(host, cls, s.slice(i, end))
  96. i = end
  97. } else if (end == -1) { buf = buf + c i = i + 1 }
  98. }
  99. // one more line feed at the very end: the browser shows the caret of a last empty line only when a line feed follows it
  100. host.appendChild(document.createTextNode(buf + NL))
  101. }
  102. // the code in the surface, without that last line feed
  103. textOf(s) {
  104. t = s.textContent
  105. return t.endsWith(NL) ? t.slice(0, t.length - 1) : t
  106. }
  107. // ---- the caret as text offsets (the DOM is rebuilt by paint, so the caret is saved as numbers) -----------------------------
  108. rootOf(node) { return node.closest('code-editor') }
  109. surfaceOf(root) { return root.querySelector('article') }
  110. gutterOf(root) { return root.querySelector('code-gutter') }
  111. fieldOf(root) { return root.querySelector('input.value') }
  112. langOf(root) { return root.querySelector('input.language').value }
  113. unitOf(root) {
  114. n = toNumber(root.querySelector('input.spaces').value)
  115. return n > 0 ? repeat(' ', n) : TAB
  116. }
  117. // null when the caret is not in the surface
  118. caretOf(s) {
  119. sel = document.getSelection()
  120. if (sel.rangeCount == 0 || sel.anchorNode == null || !s.contains(sel.anchorNode) || !s.contains(sel.focusNode)) { return null }
  121. r = sel.getRangeAt(0)
  122. a = document.createRange()
  123. a.selectNodeContents(s)
  124. a.setEnd(r.startContainer, r.startOffset)
  125. b = document.createRange()
  126. b.selectNodeContents(s)
  127. b.setEnd(r.endContainer, r.endOffset)
  128. return { start = a.toString().length end = b.toString().length }
  129. }
  130. placeCaret(s, start, end) {
  131. w = document.createTreeWalker(s, 4)
  132. let pos = 0
  133. let sn = null
  134. let so = 0
  135. let en = null
  136. let eo = 0
  137. let node = w.nextNode()
  138. while (node != null) {
  139. let len = node.data.length
  140. if (sn == null && start <= pos + len) { sn = node so = start - pos }
  141. if (en == null && end <= pos + len) { en = node eo = end - pos }
  142. pos = pos + len
  143. node = w.nextNode()
  144. }
  145. if (sn == null) { sn = s so = s.childNodes.length }
  146. if (en == null) { en = s eo = s.childNodes.length }
  147. document.getSelection().setBaseAndExtent(sn, so, en, eo)
  148. }
  149. // ---- the editor ---------------------------------------------------------------------------------------------------------
  150. // line numbers: rewritten only when the number of lines changed
  151. gutter(root, text) {
  152. n = text.split(NL).length
  153. if (root.codeLines == n) { return null }
  154. root.codeLines = n
  155. let out = ''
  156. let i = 1
  157. while (i <= n) { out = out + (i > 1 ? NL : '') + i i = i + 1 }
  158. gutterOf(root).textContent = out
  159. }
  160. // the text is the value: written to the field and announced when it changed
  161. sync(root, text) {
  162. f = fieldOf(root)
  163. if (text != f.value) {
  164. f.value = text
  165. f.dispatchEvent(new Event('input', { bubbles = true }))
  166. }
  167. }
  168. // shows `text` painted with the caret at a..b
  169. setText(root, text, a, b) {
  170. s = surfaceOf(root)
  171. s.textContent = ''
  172. paint(s, text, langOf(root))
  173. placeCaret(s, a, b)
  174. gutter(root, text)
  175. sync(root, text)
  176. }
  177. // the undo history: snapshots of the text and the caret; typing within 300 ms of the last change joins its snapshot
  178. record(root, text, a, b, force) {
  179. now = Date.now()
  180. // root.codeHist is used in place: a `let` copy of a list would be a COPY (Hybriel copies plain lists on assignment)
  181. if (root.codeHist[root.codeAt].t == text) { root.codeHist[root.codeAt] = { t = text p = a q = b } return null }
  182. if (!force && root.codeAt > 0 && now - root.codeTime < 300) {
  183. root.codeHist[root.codeAt] = { t = text p = a q = b }
  184. } else {
  185. while (root.codeHist.length > root.codeAt + 1) { root.codeHist.pop() }
  186. root.codeHist.push({ t = text p = a q = b })
  187. root.codeAt = root.codeAt + 1
  188. while (root.codeHist.length > 300) { root.codeHist.shift() root.codeAt = root.codeAt - 1 }
  189. }
  190. root.codeTime = force ? 0 : now
  191. }
  192. // a change made by a key: replace from..to by `ins`, the caret at a..b afterwards; its own undo step
  193. edit(root, lo, hi, ins, a, b) {
  194. text = textOf(surfaceOf(root))
  195. next = text.slice(0, lo) + ins + text.slice(hi)
  196. setText(root, next, a, b)
  197. record(root, next, a, b, true)
  198. }
  199. step(root, dir) {
  200. at = root.codeAt + dir
  201. if (at < 0 || at >= root.codeHist.length) { return null }
  202. root.codeAt = at
  203. root.codeTime = 0
  204. h = root.codeHist[at]
  205. setText(root, h.t, h.p, h.q)
  206. }
  207. on codeMount(e) {
  208. s = e.target
  209. s.style.animationName = 'none'
  210. root = rootOf(s)
  211. text = fieldOf(root).value
  212. s.setAttribute('aria-label', 'Code')
  213. s.setAttribute('aria-multiline', 'true')
  214. gutterOf(root).setAttribute('aria-hidden', 'true')
  215. paint(s, text, langOf(root))
  216. gutter(root, text)
  217. root.codeHist = [{ t = text p = text.length q = text.length }]
  218. root.codeAt = 0
  219. root.codeTime = 0
  220. }
  221. // typing, paste, cut, drop: the browser changed the text; paint it again and give the caret back
  222. on codeInput(e) {
  223. e.stopPropagation()
  224. if (e.isComposing) { return null }
  225. root = rootOf(e.target)
  226. s = surfaceOf(root)
  227. let c = caretOf(s)
  228. text = textOf(s)
  229. if (c == null) { c = { start = text.length end = text.length } }
  230. setText(root, text, c.start, c.end)
  231. record(root, text, c.start, c.end, false)
  232. }
  233. // Tab / Shift+Tab: one line's caret gets a tab (or spaces) / the touched lines are indented / outdented
  234. tabKey(root, text, c, back) {
  235. unit = unitOf(root)
  236. ls = text.slice(0, c.start).lastIndexOf(NL) + 1
  237. multi = text.slice(c.start, c.end).indexOf(NL) >= 0
  238. if (!back && !multi) { edit(root, c.start, c.end, unit, c.start + unit.length, c.start + unit.length) return null }
  239. // the lines from the one holding the start to the one holding the end
  240. let le = text.indexOf(NL, c.end)
  241. if (le < 0) { le = text.length }
  242. if (c.end > c.start && text.slice(c.end - 1, c.end) == NL) { le = c.end - 1 }
  243. lines = text.slice(ls, le).split(NL)
  244. out = []
  245. let first = 0
  246. let total = 0
  247. let i = 0
  248. while (i < lines.length) {
  249. let line = lines[i]
  250. let cut = 0
  251. if (back) {
  252. if (line.startsWith(TAB)) { cut = 1 }
  253. else {
  254. let k = 0
  255. while (k < unit.length && line.slice(k, k + 1) == ' ') { k = k + 1 }
  256. cut = k
  257. }
  258. out.push(line.slice(cut))
  259. } else {
  260. cut = 0 - unit.length
  261. out.push(line == '' ? line : unit + line)
  262. if (line == '') { cut = 0 }
  263. }
  264. if (i == 0) { first = cut }
  265. total = total + cut
  266. i = i + 1
  267. }
  268. let a = c.start - first
  269. if (a < ls) { a = ls }
  270. edit(root, ls, le, out.join(NL), a, c.end - total)
  271. }
  272. // pasted text goes in as plain text with its line feeds (the browser would make one <div> per line)
  273. on codePaste(e) {
  274. e.preventDefault()
  275. root = rootOf(e.target)
  276. s = surfaceOf(root)
  277. c = caretOf(s)
  278. t = e.clipboardData.getData('text/plain')
  279. if (c == null) { return null }
  280. at = c.start + t.length
  281. edit(root, c.start, c.end, t, at, at)
  282. }
  283. on codeKey(e) {
  284. if (e.isComposing) { return null }
  285. root = rootOf(e.target)
  286. s = surfaceOf(root)
  287. mod = e.ctrlKey || e.metaKey
  288. k = e.key
  289. if (mod && !e.altKey && (k == 'z' || k == 'Z')) { e.preventDefault() step(root, e.shiftKey ? 1 : 0 - 1) return null }
  290. if (mod && !e.altKey && (k == 'y' || k == 'Y')) { e.preventDefault() step(root, 1) return null }
  291. if (mod || e.altKey) { return null }
  292. c = caretOf(s)
  293. if (c == null) { return null }
  294. text = textOf(s)
  295. before = text.slice(0, c.start)
  296. after = text.slice(c.end)
  297. if (k == 'Tab') { e.preventDefault() tabKey(root, text, c, e.shiftKey) return null }
  298. if (k == 'Enter') {
  299. // the new line keeps the indent; after ( [ { it is indented once more, and a closing bracket right after goes to its own line
  300. e.preventDefault()
  301. pad = leadingBlanks(before.slice(before.lastIndexOf(NL) + 1))
  302. opens = before.length > 0 && '([{'.indexOf(before.slice(before.length - 1)) >= 0
  303. extra = opens ? pad + unitOf(root) : pad
  304. closes = after.length > 0 && ')]}'.indexOf(after.slice(0, 1)) >= 0
  305. if (opens && closes) {
  306. let at = c.start + 1 + extra.length
  307. edit(root, c.start, c.end, NL + extra + NL + pad, at, at)
  308. } else {
  309. let at = c.start + 1 + extra.length
  310. edit(root, c.start, c.end, NL + extra, at, at)
  311. }
  312. return null
  313. }
  314. if (k.length == 1) {
  315. open = '([{' + "'" + '"'
  316. close = ')]}' + "'" + '"'
  317. ix = open.indexOf(k)
  318. next = after.slice(0, 1)
  319. // a closer typed right before the same closer just steps over it
  320. if (c.start == c.end && close.indexOf(k) >= 0 && next == k) { e.preventDefault() placeCaret(s, c.start + 1, c.start + 1) return null }
  321. // a bracket typed on the empty indented line between an auto-inserted pair (Enter inside { }) closes it: the empty line goes
  322. if (c.start == c.end && ')]}'.indexOf(k) >= 0) {
  323. ls = before.lastIndexOf(NL) + 1
  324. if (ls > 0 && leadingBlanks(before.slice(ls)).length == before.length - ls) {
  325. rest = leadingBlanks(after.slice(1))
  326. if (after.slice(0, 1) == NL && after.slice(1 + rest.length, 2 + rest.length) == k) {
  327. e.preventDefault()
  328. let at = ls + rest.length + 1
  329. edit(root, ls, c.start + 2 + rest.length, rest + k, at, at)
  330. return null
  331. }
  332. }
  333. }
  334. if (ix >= 0) {
  335. prev = before.slice(before.length - 1)
  336. // an apostrophe inside a word (don't) is not an opening quote
  337. if (c.start == c.end && ix >= 3 && before.length > 0 && isWordChar(prev)) { return null }
  338. e.preventDefault()
  339. inner = text.slice(c.start, c.end)
  340. edit(root, c.start, c.end, k + inner + close.slice(ix, ix + 1), c.start + 1, c.start + 1 + inner.length)
  341. }
  342. }
  343. }
  344. // ---- 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). Syntax colours: greys only (plain code black)
  345. Style {
  346. '@keyframes code-editor-mount' { 'from' { opacity = '.999' } to { opacity = '1' } }
  347. 'code-editor' { display = 'block' border = '0' borderRadius = '.5rem' background = 'var(--color-background-sunken, #f2f2f2)' color = 'var(--color-text, #000)' overflow = 'hidden' }
  348. 'code-editor:focus-within' { outline = '2px solid var(--color-accent, #555)' outlineOffset = '-1px' }
  349. 'code-editor code-scroll' { display = 'flex' alignItems = 'stretch' overflow = 'auto' minHeight = 'var(--code-editor-min-height, 8rem)' font = '.9rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' tabSize = '4' }
  350. 'code-editor code-gutter' { position = 'sticky' left = '0' flex = 'none' boxSizing = 'border-box' minWidth = '3rem' padding = '.6rem .6rem .6rem .4rem' textAlign = 'right' whiteSpace = 'pre' userSelect = 'none' color = 'var(--color-text-muted, #555)' background = 'var(--color-surface, #ddd)' borderRight = '0' }
  351. 'code-editor article' { flex = '1 0 auto' boxSizing = 'border-box' minWidth = 'calc(100% - 3rem)' padding = '.6rem .8rem' outline = 'none' whiteSpace = 'pre' caretColor = 'var(--color-accent, #000)' animation = 'code-editor-mount 100ms infinite' }
  352. 'code-editor .tok-comment' { color = 'var(--color-text-muted, #555)' fontStyle = 'italic' }
  353. 'code-editor .tok-string' { color = 'var(--orange, #555)' }
  354. 'code-editor .tok-number' { color = 'var(--green-light, #555)' }
  355. 'code-editor .tok-keyword' { color = 'var(--color-special, #555)' }
  356. 'code-editor .tok-function' { color = 'var(--yellow, #000)' }
  357. 'code-editor .tok-type' { color = 'var(--green-dark, #555)' }
  358. }
  359. // code-gutter = the line numbers; article = the surface; the hidden fields carry the value (posts under `name`) and the settings the handlers read.
  360. View {
  361. codeEditor {
  362. codeScroll {
  363. codeGutter { }
  364. article { contenteditable = "plaintext-only" spellcheck = "false" role = "textbox" tabindex = "0" on animationiteration(e) { emit codeMount(e) } on input(e) { emit codeInput(e) } on keydown(e) { emit codeKey(e) } on paste(e) { emit codePaste(e) } }
  365. }
  366. input { class = "value" type = "hidden" name = name value = value }
  367. input { class = "language" type = "hidden" value = language }
  368. input { class = "spaces" type = "hidden" value = spaces }
  369. }
  370. }

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