components
All repositories: gitoria
16.1 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) { k = c.charCodeAt(0) return k >= 48 && k <= 57 }isUpper(c) { k = c.charCodeAt(0) return k >= 65 && k <= 90 }isWordStart(c) { 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) {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 + 1n = 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) {e = document.createElement('span')e.className = 'tok-' + clse.textContent = texthost.appendChild(e)}paint(host, s, lang) {words = wordsOf(lang)let buf = ''let i = 0n = 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) {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) {n = toNumber(root.querySelector('input.spaces').value)return n > 0 ? repeat(' ', n) : TAB}// null when the caret is not in the surfacecaretOf(s) {sel = document.getSelection()if (sel.rangeCount == 0 || sel.anchorNode == null || !s.contains(sel.anchorNode) || !s.contains(sel.focusNode)) { return null }r = sel.getRangeAt(0)a = document.createRange()a.selectNodeContents(s)a.setEnd(r.startContainer, r.startOffset)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) {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) {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) {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) {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) {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) {text = textOf(surfaceOf(root))next = text.slice(0, lo) + ins + text.slice(hi)setText(root, next, a, b)record(root, next, a, b, true)}step(root, dir) {at = root.codeAt + dirif (at < 0 || at >= root.codeHist.length) { return null }root.codeAt = atroot.codeTime = 0h = root.codeHist[at]setText(root, h.t, h.p, h.q)}on codeMount(e) {s = e.targets.style.animationName = 'none'root = rootOf(s)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 }root = rootOf(e.target)s = surfaceOf(root)let c = caretOf(s)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) {unit = unitOf(root)ls = text.slice(0, c.start).lastIndexOf(NL) + 1multi = 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 }lines = text.slice(ls, le).split(NL)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()root = rootOf(e.target)s = surfaceOf(root)c = caretOf(s)t = e.clipboardData.getData('text/plain')if (c == null) { return null }at = c.start + t.lengthedit(root, c.start, c.end, t, at, at)}on codeKey(e) {if (e.isComposing) { return null }root = rootOf(e.target)s = surfaceOf(root)mod = e.ctrlKey || e.metaKeyk = 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 }c = caretOf(s)if (c == null) { return null }text = textOf(s)before = text.slice(0, c.start)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()pad = leadingBlanks(before.slice(before.lastIndexOf(NL) + 1))opens = before.length > 0 && '([{'.indexOf(before.slice(before.length - 1)) >= 0extra = opens ? pad + unitOf(root) : padcloses = 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) {open = '([{' + "'" + '"'close = ')]}' + "'" + '"'ix = open.indexOf(k)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) {ls = before.lastIndexOf(NL) + 1if (ls > 0 && leadingBlanks(before.slice(ls)).length == before.length - ls) {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) {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()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; 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)Style {'@keyframes code-editor-mount' { 'from' { opacity = '.999' } to { opacity = '1' } }'code-editor' { display = 'block' border = '0' borderRadius = '.5rem' background = 'var(--color-background-sunken, #f2f2f2)' color = 'var(--color-text, #000)' overflow = 'hidden' }'code-editor:focus-within' { outline = '2px solid var(--color-accent, #555)' 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, #555)' background = 'var(--color-surface, #ddd)' borderRight = '0' }'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' }'code-editor .tok-comment' { color = 'var(--color-text-muted, #555)' fontStyle = 'italic' }'code-editor .tok-string' { color = 'var(--orange, #555)' }'code-editor .tok-number' { color = 'var(--green-light, #555)' }'code-editor .tok-keyword' { color = 'var(--color-special, #555)' }'code-editor .tok-function' { color = 'var(--yellow, #000)' }'code-editor .tok-type' { color = 'var(--green-dark, #555)' }}// 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
- 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