7 CodeEditor
L4stIdi0t edited this page 2026-10-09 22:01:06 +02:00

CodeEditor

A code editor: a highlighted layer behind a transparent textarea.

The textarea is what makes this cheap and correct — caret, selection, IME, drag-and-drop and find-in-page are the browser's, not ours. The price is that the layer has to agree with it about where every character sits, so anything drawn between characters has to pay for its own width; the colour swatch does exactly that (see renderTokens in code-editor.ts). Agreement is the whole game, and there is exactly one rule that keeps it: the layer is one text flow, laid out like the textarea's, and nothing that has to line up with a character is ever a box the browser stacks. A box per line looks equivalent and is not — each one rounds its own height, the textarea rounds nothing, and by line forty the caret the browser draws sits a row away from the glyphs the reader sees, by a different amount in every engine. So the gutter numbers and the active line are placed at n * 1lh instead of stacked, wrapped rows are counted rather than assumed, and the textarea's scrollbar is subtracted from the layer's width so both wrap in the same column. Then the caret is simply the browser's own, in the right place, for free. A large file stays quick because almost none of it is re-rendered: the layer is kept as blocks of a couple of hundred lines, a block is rewritten only when its own text changes, and only the blocks near the viewport carry highlighting — the rest is the same text, plain. The gutter renders the same window. Wrapped-row counts are remembered per line. A keystroke in a 20 000-line file costs the textarea's own relayout and little else. It owns its own undo stack, because assigning to a textarea's value from script clears the browser's — every bracket and tab handler would otherwise truncate Cmd+Z. The grammar arrives as a prop and so does every preference: this component ships no languages and no persistence. An OpenSCAD one is written, as its own entry point — @l4st/driftwood/languages/openscad — so it reaches only an app that asks for it. Import the palette once, in your CSS:

@import '@l4st/driftwood/code.css';

Forms · two-way: bind:value · portalled to <body> · keyboard contract

<script lang="ts">
	import { CodeEditor } from '@l4st/driftwood';
</script>
Light Dark
code-editor code-editor in dark mode

Props

Prop Type Default What it does
value required bind: string — The document. Always the expanded text — a fold never reaches it.
language required CodeLanguage — The grammar. Anything it does not tokenize renders as plain text.
scheme 'auto' | 'light' | 'dark' 'auto' Colour scheme; auto follows the OS through the palette's light-dark().
background string — The editor surface. Left unset it is the app's card colour, so the editor follows the site theme; pass any CSS colour to override for this editor alone; the IDE's own surfaces are listed at the top of code.css.
font string 'ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", "DejaVu Sans Mono", monospace' A CSS font stack. SF Mono first, asked for three ways: ui-monospace is how Safari names it, and Chrome -- which does not know that keyword -- finds it by family or PostScript name when it is installed. Where it is not, Menlo, then the usual suspects.
size number 13 Font size in px.
ligatures boolean true Coding ligatures, if the face has them.
closeBrackets boolean true Type an opening bracket and get its closer, with skip-over on the way out.
comments boolean true Close and continue comments: an opened block comment gets its closer, Enter inside one keeps the spine and puts the closer on its own line, and Enter at the end of a // line starts another one -- pressing it on an empty marker ends the run instead.
folding boolean true Show the fold gutter and allow blocks to be collapsed.
readonly boolean false Read the document without editing it. Focus, the caret, selection, arrow-key navigation, copy and find-in-page all stay -- it is a reader, not a disabled control -- and so do folding, the hovers and the gutter. What stops is everything that would change the text: typing, paste, drop, the bracket and comment handlers, undo and redo, and the suggestion list.
lineWrapping boolean true Wrap long lines instead of scrolling sideways. On by default: a line you cannot see is worse than a line that takes two rows.
highlightActiveLine boolean true Tint the line the caret is on, and its gutter row with it.
tooltip (context: HoverContext) => string | Hint | null — What to say about the token under the pointer. Returning null shows nothing. The app owns the language, so it owns the explanations -- and it is handed the document, so it can answer about a module the document itself defines and not only about what it shipped knowing.
complete (context: CompletionContext) => Completion[] — Suggestions for the word being typed. Return [] for none. Called after every edit once the word is two characters long, and on Ctrl+Space whatever its length.
oninput () => void — Fires after every edit, once value has been updated.
class string — Merged with the component's own classes, last — so yours win.
aria-label string 'Source' Names the editing surface, e.g. "OpenSCAD source".

Keyboard

Key Does
Tab insert a tab, rather than leaving the editor
( [ { " close the pair, wrapping the selection if there is one
) ] } " step over the closer that is already there
/ * /* closes itself; typing */ in front of one uses the one that is there
Enter keep the indentation, open {} into a block, continue the comment you are in
Backspace between a pair, or the halves of an empty /**/, take both
Cmd/Ctrl + Z undo — the editor keeps its own stack, so script edits are in it
Cmd/Ctrl + Shift + Z, Ctrl + Y redo

Example

The markup behind the screenshot above, lifted from src/routes/shots/ — so it compiles, and it is exactly what produced the image.

<CodeEditor value={DEMO_SOURCE} language={openscad} aria-label="Example source" />

Notes

Notes

It needs its own stylesheet. @import '@l4st/driftwood/code.css'; after your palette — without it every token renders in the plain colour and the gutter is invisible. It is a separate import because only an app with an editor should pay for a syntax palette.

Bring a grammar. The kit ships none: a language is the app's, and one bundled grammar invites thirty. A CodeLanguage is a name and one function from source to a flat list of tokens, and anything it does not recognise falls through as plain text, so a fifty-line grammar is already useful. The gallery's toy OpenSCAD one is in src/routes/demo-language.ts — that is the worked example.

The token vocabulary is deliberately language-neutral. keyword, builtin, special, group, annotation, todo, hex — a scheme written for one language colours the next one sensibly. Add a type and it needs a .tok-<type> rule in code.css and nothing else.

Why a textarea, and what it costs. Caret, selection, IME, drag-and-drop, spellcheck and find-in-page are the browser's — none of which a contenteditable or a canvas editor gets for free. The price is that the highlighted layer must agree with the textarea about where every character sits, so anything drawn between characters has to pay for its own width. The colour swatch does: it is inserted in front of the # as a real inline box of 0.8em, and the literal beside it gives back exactly that much through a negative letter-spacing over its own characters. The colour's glyphs run up to 0.8em tighter than the textarea's; by the character after the literal the two layers are back in step, so the line's total width — and every click past the colour — is unchanged.

Undo is ours. Assigning to a textarea's value from script clears the browser's undo stack, and this component assigns on every bracket, tab and fold — so Cmd+Z would stop at the last programmatic edit. It keeps its own stack instead, coalescing runs of typing, and intercepts historyUndo/historyRedo so the native one never runs.

Regions fold, and are named. // region Plate … // endregion is a fold like a brace block is, spelled every way the editors that popularised it accept — //#region, // #region, // end region, any case. The marker itself reads as a comment and the name takes --code-group, a hue of its own rather than the annotation colour — a region heading and a @param were indistinguishable, and the heading is the thing you skim a long file for. Hovering the closing marker names the region it closes and says how long it is, which is the one place in a file where what you want to know is somewhere else. It is a comment convention, so nothing in the language knows about it: the editor finds them.

Comments close and carry on. /* gets its */ the way a bracket does, and Enter inside the pair opens it into three lines with the closer on its own — press it again and the spine continues, so a paragraph of comment is typed rather than aligned by hand. A // line does the same: Enter starts another one, and Enter on a marker with nothing after it ends the run and takes the marker with it, which is the only way out that does not need Backspace. Enter also keeps the indentation of the line it leaves, adds one tab after an unclosed bracket, and opens {|} into a block — a caret that lands in column 0 after every newline is the thing that makes an editor feel like a textarea. All of it is comments and closeBrackets; turn either off and Enter is the browser's again.

Folding never reaches your document. A folded region is replaced in the view by a marker — a comment carrying an id in zero-width characters, so it takes no columns and the layers stay aligned — and value is always the expanded text. Editing a folded line opens it first; the marker is never editable.

A tooltip is yours to answer. tooltip(context) is handed the token under the pointer and the document it sits in — { token, line, column, from, to, text, source } — and returns a { code, text, params, swatch }, a bare string, or null for nothing. code is coloured with your grammar; text never is, so a hex literal in a sentence stays one colour; params is a list of [name, description] laid out as a table under the first paragraph, the names in the code's own face, because a parameter list reads like one only when the names line up. Being given source is what lets a hover answer about a name the document itself defines, rather than only about what the language shipped knowing.

OpenSCAD is written, and opt-in. The editor knows no grammars, but one language ships as its own entry point, so it reaches only an app that imports it:

<script lang="ts">
	import { CodeEditor } from '@l4st/driftwood';
	import {
		openscad,
		openscadHover,
		openscadComplete
	} from '@l4st/driftwood/languages/openscad';

	let source = $state('cube([10, 10, 4]);\n');
</script>

<CodeEditor
	bind:value={source}
	language={openscad}
	tooltip={openscadHover}
	complete={openscadComplete}
	aria-label="Model source"
/>

It carries the built-in surface of the language — 91 entries, each with a signature, what it is for, and the argument that catches people out — and it reads the document as well: hover a call to a module or function you wrote and you get its signature, the comment directly above its definition, and the line it is on — and a region marker or a section header above a definition is not mistaken for its doc comment. A @param name description in that comment becomes a row in the hover's argument table rather than a line of prose — the same table the built-ins use — and any other @tag becomes a row keyed by the tag. In the source itself @param colours as an annotation, // TODO: as a todo, and a // ==== Section ==== line as a group heading.

It knows the Customizer as well, which is the half of a .scad file that is a comment to the compiler and a control panel to everyone else. /* [Size] */ is a section header and colours as one; /* [Hidden] */ is not a header at all -- it is where the Customizer stops reading -- so it takes the todo colour and reads as the warning it is. The widget spec after an assignment is coloured by what is in it: // [1:8] and // [0.8:0.2:3] put their numbers in the number colour, // [#46566b:Slate, #de3a2b:Signal] puts a chip beside each literal and its label in the string colour, and the brackets and colons between them take the annotation colour. Hovering a header says what it groups; hovering a spec says what widget it makes -- a slider from 1 to 8 in steps of 1, a drop-down of two values, a 24-character limit.

scadSymbols(source) is exported too, for a symbol list or a go-to-definition of your own, and OPENSCAD is the table itself if you would rather render the reference somewhere else.

Forms · Field · Input · FileInput · FileList · Textarea · Combobox · RadioGroup · NumberInput · InlineEdit · Label · Select · MultiSelect · DatePicker · TagsInput · Checkbox · Switch · Slider


← All components · Getting started · Theming · source