components
All repositories: gitoria
16.2 KB
// code-editor.hl — THE CODE EDITOR, all Hybriel (port of custom-ide's CodeJar editor): the code is highlighted while you type, line// numbers follow, Tab / Shift+Tab indent and outdent, Enter keeps the indent, brackets and quotes close themselves, Ctrl+Z / Ctrl+Shift+Z// undo and redo. `CodeEditor { name = "source" value = "let x = 1" language = "hybriel" }` — the value is the plain text.// Like CodeJar: ONE contenteditable="plaintext-only" element holds the code as text nodes and <span class="tok-…"> tokens; after every// edit the text is read back, painted again and the caret put back by its text offset. The framework never re-renders the surface// (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; that hands the editor its first content. 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 text).// Languages: hybriel, js (javascript), plain. The undo history is the editor's own (snapshots of the text and the caret), as in CodeJar.String name = 'code'String value = ''String language = 'hybriel'Number spaces = 0static NL = ""static TAB = " "static BS = 'x\y'.slice(1, 2)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 '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 '// ---- small text helpers ---------------------------------------------------------------------------------------------isDigit(c) { let k = c.charCodeAt(0) return k >= 48 && k <= 57 }isUpper(c) { let k = c.charCodeAt(0) return k >= 65 && k <= 90 }isWordStart(c) { let k = c.charCodeAt(0) return (k >= 65 && k <= 90) || (k >= 97 && k <= 122) || k == 95 || k == 36 || k > 127 }isWordChar(c) { return isWordStart(c) || isDigit(c) }wordsOf(lang) {if (lang == 'hybriel') { return hybrielWords }if (lang == 'js' || lang == 'javascript') { return jsWords }return ''}lineEnd(s, i) {let j = s.indexOf(NL, i)return j < 0 ? s.length : j}// the end (exclusive) of the string that opens at i with the quote q: a backslash masks the next character; only a template// string (`) runs over linesstringEnd(s, i, q) {let j = i + 1let n = s.lengthwhile (j < n) {let c = s.slice(j, j + 1)if (c == BS) { j = j + 2 }else if (c == q) { return j + 1 }else if (c == NL && q != '`') { return j }else { j = j + 1 }}return n}// the leading blanks of a lineleadingBlanks(line) {let k = 0while (k < line.length && (line.slice(k, k + 1) == ' ' || line.slice(k, k + 1) == TAB)) { k = k + 1 }return line.slice(0, k)}repeat(c, n) { let s = '' let i = 0 while (i < n) { s = s + c i = i + 1 } return s }// ---- highlighting: the text → text nodes and <span class="tok-…"> in a host ---------------------------------------------span(host, cls, text) {let e = document.createElement('span')e.className = 'tok-' + clse.textContent = texthost.appendChild(e)}paint(host, s, lang) {let words = wordsOf(lang)let buf = ''let i = 0let n = s.lengthwhile (i < n) {let c = s.slice(i, i + 1)let two = s.slice(i, i + 2)let end = -1let cls = ''if (lang != 'plain') {if (two == '//') { end = lineEnd(s, i) cls = 'comment' }else if (two == '/*') { let j = s.indexOf('*/', i + 2) end = j < 0 ? n : j + 2 cls = 'comment' }else if (c == "'" || c == '"' || c == '`') { end = stringEnd(s, i, c) cls = 'string' }else if (isDigit(c) && (i == 0 || !isWordChar(s.slice(i - 1, i)))) {let j = i + 1while (j < n && (isWordChar(s.slice(j, j + 1)) || (s.slice(j, j + 1) == '.' && isDigit(s.slice(j + 1, j + 2))))) { j = j + 1 }end = jcls = 'number'}else if (isWordStart(c)) {let j = i + 1while (j < n && isWordChar(s.slice(j, j + 1))) { j = j + 1 }let w = s.slice(i, j)let prev = i > 0 ? s.slice(i - 1, i) : ''if (words.indexOf(' ' + w + ' ') >= 0 && prev != '.') { end = j cls = 'keyword' }else if (s.slice(j, j + 1) == '(') { end = j cls = 'function' }else if (isUpper(c) && w.length > 1) { end = j cls = 'type' }else { buf = buf + w i = j end = -2 }}}if (end > i) {if (buf != '') { host.appendChild(document.createTextNode(buf)) buf = '' }span(host, cls, s.slice(i, end))i = end} else if (end == -1) { buf = buf + c i = i + 1 }}// one more line feed at the very end: the browser shows the caret of a last empty line only when a line feed follows ithost.appendChild(document.createTextNode(buf + NL))}// the code in the surface, without that last line feedtextOf(s) {let t = s.textContentreturn t.endsWith(NL) ? t.slice(0, t.length - 1) : t}// ---- the caret as text offsets (the DOM is rebuilt by paint, so the caret is saved as numbers) -----------------------------rootOf(node) { return node.closest('code-editor') }surfaceOf(root) { return root.querySelector('article') }gutterOf(root) { return root.querySelector('code-gutter') }fieldOf(root) { return root.querySelector('input.value') }langOf(root) { return root.querySelector('input.language').value }unitOf(root) {let n = toNumber(root.querySelector('input.spaces').value)return n > 0 ? repeat(' ', n) : TAB}// null when the caret is not in the surfacecaretOf(s) {let sel = document.getSelection()if (sel.rangeCount == 0 || sel.anchorNode == null || !s.contains(sel.anchorNode) || !s.contains(sel.focusNode)) { return null }let r = sel.getRangeAt(0)let a = document.createRange()a.selectNodeContents(s)a.setEnd(r.startContainer, r.startOffset)let b = document.createRange()b.selectNodeContents(s)b.setEnd(r.endContainer, r.endOffset)return { start = a.toString().length end = b.toString().length }}placeCaret(s, start, end) {let w = document.createTreeWalker(s, 4)let pos = 0let sn = nulllet so = 0let en = nulllet eo = 0let node = w.nextNode()while (node != null) {let len = node.data.lengthif (sn == null && start <= pos + len) { sn = node so = start - pos }if (en == null && end <= pos + len) { en = node eo = end - pos }pos = pos + lennode = w.nextNode()}if (sn == null) { sn = s so = s.childNodes.length }if (en == null) { en = s eo = s.childNodes.length }document.getSelection().setBaseAndExtent(sn, so, en, eo)}// ---- the editor ---------------------------------------------------------------------------------------------------------// line numbers: rewritten only when the number of lines changedgutter(root, text) {let n = text.split(NL).lengthif (root.codeLines == n) { return null }root.codeLines = nlet out = ''let i = 1while (i <= n) { out = out + (i > 1 ? NL : '') + i i = i + 1 }gutterOf(root).textContent = out}// the text is the value: written to the field and announced when it changedsync(root, text) {let f = fieldOf(root)if (text != f.value) {f.value = textf.dispatchEvent(new Event('input', { bubbles = true }))}}// shows `text` painted with the caret at a..bsetText(root, text, a, b) {let s = surfaceOf(root)s.textContent = ''paint(s, text, langOf(root))placeCaret(s, a, b)gutter(root, text)sync(root, text)}// the undo history: snapshots of the text and the caret; typing within 300 ms of the last change joins its snapshotrecord(root, text, a, b, force) {let now = Date.now()// root.codeHist is used in place: a `let` copy of a list would be a COPY (Hybriel copies plain lists on assignment)if (root.codeHist[root.codeAt].t == text) { root.codeHist[root.codeAt] = { t = text p = a q = b } return null }if (!force && root.codeAt > 0 && now - root.codeTime < 300) {root.codeHist[root.codeAt] = { t = text p = a q = b }} else {while (root.codeHist.length > root.codeAt + 1) { root.codeHist.pop() }root.codeHist.push({ t = text p = a q = b })root.codeAt = root.codeAt + 1while (root.codeHist.length > 300) { root.codeHist.shift() root.codeAt = root.codeAt - 1 }}root.codeTime = force ? 0 : now}// a change made by a key: replace from..to by `ins`, the caret at a..b afterwards; its own undo stepedit(root, lo, hi, ins, a, b) {let text = textOf(surfaceOf(root))let next = text.slice(0, lo) + ins + text.slice(hi)setText(root, next, a, b)record(root, next, a, b, true)}step(root, dir) {let at = root.codeAt + dirif (at < 0 || at >= root.codeHist.length) { return null }root.codeAt = atroot.codeTime = 0let h = root.codeHist[at]setText(root, h.t, h.p, h.q)}on codeMount(e) {let s = e.targets.style.animationName = 'none'let root = rootOf(s)let text = fieldOf(root).values.setAttribute('aria-label', 'Code')s.setAttribute('aria-multiline', 'true')gutterOf(root).setAttribute('aria-hidden', 'true')paint(s, text, langOf(root))gutter(root, text)root.codeHist = [{ t = text p = text.length q = text.length }]root.codeAt = 0root.codeTime = 0}// typing, paste, cut, drop: the browser changed the text; paint it again and give the caret backon codeInput(e) {e.stopPropagation()if (e.isComposing) { return null }let root = rootOf(e.target)let s = surfaceOf(root)let c = caretOf(s)let text = textOf(s)if (c == null) { c = { start = text.length end = text.length } }setText(root, text, c.start, c.end)record(root, text, c.start, c.end, false)}// Tab / Shift+Tab: one line's caret gets a tab (or spaces) / the touched lines are indented / outdentedtabKey(root, text, c, back) {let unit = unitOf(root)let ls = text.slice(0, c.start).lastIndexOf(NL) + 1let multi = text.slice(c.start, c.end).indexOf(NL) >= 0if (!back && !multi) { edit(root, c.start, c.end, unit, c.start + unit.length, c.start + unit.length) return null }// the lines from the one holding the start to the one holding the endlet le = text.indexOf(NL, c.end)if (le < 0) { le = text.length }if (c.end > c.start && text.slice(c.end - 1, c.end) == NL) { le = c.end - 1 }let lines = text.slice(ls, le).split(NL)let out = []let first = 0let total = 0let i = 0while (i < lines.length) {let line = lines[i]let cut = 0if (back) {if (line.startsWith(TAB)) { cut = 1 }else {let k = 0while (k < unit.length && line.slice(k, k + 1) == ' ') { k = k + 1 }cut = k}out.push(line.slice(cut))} else {cut = 0 - unit.lengthout.push(line == '' ? line : unit + line)if (line == '') { cut = 0 }}if (i == 0) { first = cut }total = total + cuti = i + 1}let a = c.start - firstif (a < ls) { a = ls }edit(root, ls, le, out.join(NL), a, c.end - total)}// pasted text goes in as plain text with its line feeds (the browser would make one <div> per line)on codePaste(e) {e.preventDefault()let root = rootOf(e.target)let s = surfaceOf(root)let c = caretOf(s)let t = e.clipboardData.getData('text/plain')if (c == null) { return null }let at = c.start + t.lengthedit(root, c.start, c.end, t, at, at)}on codeKey(e) {if (e.isComposing) { return null }let root = rootOf(e.target)let s = surfaceOf(root)let mod = e.ctrlKey || e.metaKeylet k = e.keyif (mod && !e.altKey && (k == 'z' || k == 'Z')) { e.preventDefault() step(root, e.shiftKey ? 1 : 0 - 1) return null }if (mod && !e.altKey && (k == 'y' || k == 'Y')) { e.preventDefault() step(root, 1) return null }if (mod || e.altKey) { return null }let c = caretOf(s)if (c == null) { return null }let text = textOf(s)let before = text.slice(0, c.start)let after = text.slice(c.end)if (k == 'Tab') { e.preventDefault() tabKey(root, text, c, e.shiftKey) return null }if (k == 'Enter') {// the new line keeps the indent; after ( [ { it is indented once more, and a closing bracket right after goes to its own linee.preventDefault()let pad = leadingBlanks(before.slice(before.lastIndexOf(NL) + 1))let opens = before.length > 0 && '([{'.indexOf(before.slice(before.length - 1)) >= 0let extra = opens ? pad + unitOf(root) : padlet closes = after.length > 0 && ')]}'.indexOf(after.slice(0, 1)) >= 0if (opens && closes) {let at = c.start + 1 + extra.lengthedit(root, c.start, c.end, NL + extra + NL + pad, at, at)} else {let at = c.start + 1 + extra.lengthedit(root, c.start, c.end, NL + extra, at, at)}return null}if (k.length == 1) {let open = '([{' + "'" + '"'let close = ')]}' + "'" + '"'let ix = open.indexOf(k)let next = after.slice(0, 1)// a closer typed right before the same closer just steps over itif (c.start == c.end && close.indexOf(k) >= 0 && next == k) { e.preventDefault() placeCaret(s, c.start + 1, c.start + 1) return null }// a bracket typed on the empty indented line between an auto-inserted pair (Enter inside { }) closes it: the empty line goesif (c.start == c.end && ')]}'.indexOf(k) >= 0) {let ls = before.lastIndexOf(NL) + 1if (ls > 0 && leadingBlanks(before.slice(ls)).length == before.length - ls) {let rest = leadingBlanks(after.slice(1))if (after.slice(0, 1) == NL && after.slice(1 + rest.length, 2 + rest.length) == k) {e.preventDefault()let at = ls + rest.length + 1edit(root, ls, c.start + 2 + rest.length, rest + k, at, at)return null}}}if (ix >= 0) {let prev = before.slice(before.length - 1)// an apostrophe inside a word (don't) is not an opening quoteif (c.start == c.end && ix >= 3 && before.length > 0 && isWordChar(prev)) { return null }e.preventDefault()let inner = text.slice(c.start, c.end)edit(root, c.start, c.end, k + inner + close.slice(ix, ix + 1), c.start + 1, c.start + 1 + inner.length)}}}// ---- look: the component's own Style; colours are the semantic tokens with the palette value as fallback (an app that declares the tokens wins)Style {'@keyframes code-editor-mount' { 'from' { opacity = '.999' } to { opacity = '1' } }'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' }'code-editor:focus-within' { outline = '2px solid var(--color-accent, #569bd4)' outlineOffset = '-1px' }'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' }'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))' }'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' }'code-editor .tok-comment' { color = 'var(--color-text-muted, rgb(145, 150, 155))' fontStyle = 'italic' }'code-editor .tok-string' { color = 'var(--orange, #ce9178)' }'code-editor .tok-number' { color = 'var(--green-light, #b5cea8)' }'code-editor .tok-keyword' { color = 'var(--color-special, #c586c0)' }'code-editor .tok-function' { color = 'var(--yellow, #dcdcaa)' }'code-editor .tok-type' { color = 'var(--green-dark, #4ec9b0)' }}// code-gutter = the line numbers; article = the surface; the hidden fields carry the value (posts under `name`) and the settings the handlers read.View {codeEditor {codeScroll {codeGutter { }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) } }}input { class = "value" type = "hidden" name = name value = value }input { class = "language" type = "hidden" value = language }input { class = "spaces" type = "hidden" value = spaces }}}
Branches
- mainmain branch
Latest commits
- 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