// A textarea that knows what it is holding: line numbers in a gutter, YAML (or a .env) // coloured underneath the text, and the keys a compose file needs — Tab indents by two, // Shift+Tab outdents, Enter keeps the indentation of the line above (one step deeper after // a key or a list dash), and a phone, whose keyboard has none of those, gets a small bar of // them. The text itself stays in a real textarea, so selection, undo and the keyboard are // the browser's own; the colour is a
drawn behind it, kept in step line for line.
import { useEffect, useMemo, useRef, useState, ReactNode } from 'react'
export type Language = 'yaml' | 'env' | 'plain'
interface Props {
value: string; onChange: (v: string) => void; language?: Language; className?: string
readOnly?: boolean; placeholder?: string; spellCheck?: boolean
}
const INDENT = ' '
const coarse = () => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches
// One line of YAML, split into spans by what each part is. Deliberately shallow: a compose
// file is keys, scalars, lists and comments; anything cleverer is a parser, which this is not.
function yamlLine(line: string): ReactNode[] {
const out: ReactNode[] = []
let i = 0
const push = (cls: string, text: string) => { if (text) out.push(cls ? {text} : text) }
const m = /^(\s*)(-\s+)?/.exec(line)!
push('', m[1]); push('y-dash', m[2] ?? ''); i = m[0].length
const rest = line.slice(i)
if (rest.startsWith('#')) { push('y-comment', rest); return out }
// a key: up to the first colon that is followed by a space or the end
const key = /^("[^"]*"|'[^']*'|[^\s:#][^:#]*?)(:)(?=\s|$)/.exec(rest)
let tail = rest
if (key) { push('y-key', key[1]); push('y-colon', ':'); tail = rest.slice(key[0].length) }
// the value, with a trailing comment cut off
let value = tail, comment = ''
const c = / #.*$/.exec(tail) ?? (tail.startsWith('#') ? /#.*$/.exec(tail) : null)
if (c) { value = tail.slice(0, c.index); comment = tail.slice(c.index) }
const v = value.trim()
const lead = value.slice(0, value.length - value.trimStart().length)
push('', lead)
if (!v) { /* nothing */ }
else if (/^(["']).*\1$/.test(v)) push('y-string', v)
else if (/^(true|false|yes|no|on|off|null|~)$/i.test(v)) push('y-bool', v)
else if (/^[-+]?(\d[\d_]*(\.\d+)?|\.\d+)([eE][-+]?\d+)?$/.test(v) || /^0x[0-9a-fA-F]+$/.test(v)) push('y-number', v)
else if (/^[&*!]/.test(v)) push('y-anchor', v)
else if (/^[|>][-+]?\d*$/.test(v)) push('y-block', v)
else push(key ? 'y-value' : (m[2] ? 'y-value' : ''), v)
push('', value.slice(lead.length + v.length))
push('y-comment', comment)
return out
}
function envLine(line: string): ReactNode[] {
if (/^\s*#/.test(line)) return [{line}]
const m = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_.-]*)(=)(.*)$/.exec(line)
if (!m) return [line]
return [m[1], {m[2]}, =, {m[4]}]
}
function highlight(text: string, lang: Language): ReactNode[] {
const lines = text.split('\n')
const f = lang === 'yaml' ? yamlLine : lang === 'env' ? envLine : (l: string) => [l] as ReactNode[]
return lines.map((l, i) => {f(l)}{l === '' ? '' : ''})
}
// setText replaces [start, end) in a textarea with s, through the browser's own edit so Undo
// still works, and leaves the caret where asked.
function setText(ta: HTMLTextAreaElement, start: number, end: number, s: string, caret: number | [number, number]) {
ta.focus()
ta.setSelectionRange(start, end)
let done = false
try { done = document.execCommand('insertText', false, s) } catch { /* not every browser */ }
if (!done || ta.value.slice(start, start + s.length) !== s) { ta.setRangeText(s, start, end, 'end'); ta.dispatchEvent(new Event('input', { bubbles: true })) }
if (Array.isArray(caret)) ta.setSelectionRange(caret[0], caret[1]); else ta.setSelectionRange(caret, caret)
}
export default function CodeEditor({ value, onChange, language = 'yaml', className = '', readOnly, placeholder, spellCheck = false }: Props) {
const ta = useRef(null)
const pre = useRef(null)
const gutter = useRef(null)
const [touch] = useState(coarse)
const lines = useMemo(() => value.split('\n').length, [value])
const painted = useMemo(() => highlight(value, language), [value, language])
// the colour and the numbers follow the text's own scrolling
const sync = () => { const t = ta.current; if (!t) return; if (pre.current) { pre.current.scrollTop = t.scrollTop; pre.current.scrollLeft = t.scrollLeft } if (gutter.current) gutter.current.scrollTop = t.scrollTop }
useEffect(sync, [value])
// lines touched by the selection, for indent and outdent
const lineRange = (t: HTMLTextAreaElement) => {
const s = t.value.lastIndexOf('\n', t.selectionStart - 1) + 1
let e = t.value.indexOf('\n', Math.max(t.selectionEnd - (t.selectionEnd > t.selectionStart && t.value[t.selectionEnd - 1] === '\n' ? 1 : 0), t.selectionStart))
if (e < 0) e = t.value.length
return [s, e] as const
}
const indent = (out: boolean) => {
const t = ta.current; if (!t || readOnly) return
const [s, e] = lineRange(t)
const block = t.value.slice(s, e)
const sel = t.selectionStart !== t.selectionEnd || block.includes('\n')
if (!sel && !out) { const at = t.selectionStart; setText(t, at, at, INDENT, at + INDENT.length); return }
const next = block.split('\n').map((l) => (out ? l.replace(/^ {1,2}/, '') : INDENT + l)).join('\n')
if (next === block) return
const d0 = out ? -(block.length - next.length) : INDENT.length
setText(t, s, e, next, sel ? [s, s + next.length] : [Math.max(s, t.selectionStart + d0), Math.max(s, t.selectionStart + d0)])
}
const comment = () => {
const t = ta.current; if (!t || readOnly) return
const [s, e] = lineRange(t)
const block = t.value.slice(s, e), ls = block.split('\n')
const all = ls.every((l) => /^\s*#/.test(l) || !l.trim())
const next = ls.map((l) => (!l.trim() ? l : all ? l.replace(/^(\s*)# ?/, '$1') : l.replace(/^(\s*)/, '$1# '))).join('\n')
setText(t, s, e, next, [s, s + next.length])
}
const newline = () => {
const t = ta.current; if (!t || readOnly) return
const at = t.selectionStart
const lineStart = t.value.lastIndexOf('\n', at - 1) + 1
const line = t.value.slice(lineStart, at)
let ind = /^\s*/.exec(line)![0]
// one step deeper after a key or a list item that opens a block
if (language === 'yaml' && /(:|^\s*-)\s*(#.*)?$/.test(line) && line.trim() && !/^\s*#/.test(line)) ind += INDENT
setText(t, at, t.selectionEnd, '\n' + ind, at + 1 + ind.length)
}
const onKey = (e: React.KeyboardEvent) => {
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); indent(e.shiftKey) }
else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { e.preventDefault(); newline() }
else if (e.key === '/' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); comment() }
}
return (
{touch && !readOnly && (
e.preventDefault()}>
)}
)
}