gitoriaLog in with ident

components

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit317bd09f317bd09fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre317bd09f/components/code-editor/code-editor.hl

16.2 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) { let k = c.charCodeAt(0) return k >= 48 && k <= 57 }
  22. isUpper(c) { let k = c.charCodeAt(0) return k >= 65 && k <= 90 }
  23. isWordStart(c) { let 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. let 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. let 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. let e = document.createElement('span')
  58. e.className = 'tok-' + cls
  59. e.textContent = text
  60. host.appendChild(e)
  61. }
  62. paint(host, s, lang) {
  63. let words = wordsOf(lang)
  64. let buf = ''
  65. let i = 0
  66. let 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. let 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. let 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. let sel = document.getSelection()
  120. if (sel.rangeCount == 0 || sel.anchorNode == null || !s.contains(sel.anchorNode) || !s.contains(sel.focusNode)) { return null }
  121. let r = sel.getRangeAt(0)
  122. let a = document.createRange()
  123. a.selectNodeContents(s)
  124. a.setEnd(r.startContainer, r.startOffset)
  125. let 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. let 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. let 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. let 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. let 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. let 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. let text = textOf(surfaceOf(root))
  195. let 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. let at = root.codeAt + dir
  201. if (at < 0 || at >= root.codeHist.length) { return null }
  202. root.codeAt = at
  203. root.codeTime = 0
  204. let h = root.codeHist[at]
  205. setText(root, h.t, h.p, h.q)
  206. }
  207. on codeMount(e) {
  208. let s = e.target
  209. s.style.animationName = 'none'
  210. let root = rootOf(s)
  211. let 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. let root = rootOf(e.target)
  226. let s = surfaceOf(root)
  227. let c = caretOf(s)
  228. let 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. let unit = unitOf(root)
  236. let ls = text.slice(0, c.start).lastIndexOf(NL) + 1
  237. let 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. let lines = text.slice(ls, le).split(NL)
  244. let 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. let root = rootOf(e.target)
  276. let s = surfaceOf(root)
  277. let c = caretOf(s)
  278. let t = e.clipboardData.getData('text/plain')
  279. if (c == null) { return null }
  280. let 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. let root = rootOf(e.target)
  286. let s = surfaceOf(root)
  287. let mod = e.ctrlKey || e.metaKey
  288. let 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. let c = caretOf(s)
  293. if (c == null) { return null }
  294. let text = textOf(s)
  295. let before = text.slice(0, c.start)
  296. let 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. let pad = leadingBlanks(before.slice(before.lastIndexOf(NL) + 1))
  302. let opens = before.length > 0 && '([{'.indexOf(before.slice(before.length - 1)) >= 0
  303. let extra = opens ? pad + unitOf(root) : pad
  304. let 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. let open = '([{' + "'" + '"'
  316. let close = ')]}' + "'" + '"'
  317. let ix = open.indexOf(k)
  318. let 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. let ls = before.lastIndexOf(NL) + 1
  324. if (ls > 0 && leadingBlanks(before.slice(ls)).length == before.length - ls) {
  325. let 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. let 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. let 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; colours are the semantic tokens with the palette value as fallback (an app that declares the tokens wins)
  345. Style {
  346. '@keyframes code-editor-mount' { 'from' { opacity = '.999' } to { opacity = '1' } }
  347. 'code-editor' { display = 'block' border = '1px solid var(--color-border, rgb(70, 75, 80))' borderRadius = '.5rem' background = 'var(--color-background-sunken, rgb(15, 20, 25))' color = 'var(--color-text, rgb(195, 200, 205))' overflow = 'hidden' }
  348. 'code-editor:focus-within' { outline = '2px solid var(--color-accent, #569bd4)' 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, rgb(145, 150, 155))' background = 'var(--color-surface, rgb(35, 40, 45))' borderRight = '1px solid var(--color-border, rgb(70, 75, 80))' }
  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, #569bd4)' animation = 'code-editor-mount 100ms infinite' }
  352. 'code-editor .tok-comment' { color = 'var(--color-text-muted, rgb(145, 150, 155))' fontStyle = 'italic' }
  353. 'code-editor .tok-string' { color = 'var(--orange, #ce9178)' }
  354. 'code-editor .tok-number' { color = 'var(--green-light, #b5cea8)' }
  355. 'code-editor .tok-keyword' { color = 'var(--color-special, #c586c0)' }
  356. 'code-editor .tok-function' { color = 'var(--yellow, #dcdcaa)' }
  357. 'code-editor .tok-type' { color = 'var(--green-dark, #4ec9b0)' }
  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

  • 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