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 |
|---|---|
![]() |
![]() |
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
driftwood
Layout
Actions
Forms
- Field
- Input
- FileInput
- FileList
- Textarea
- CodeEditor
- Combobox
- RadioGroup
- NumberInput
- InlineEdit
- Label
- Select
- MultiSelect
- DatePicker
- TagsInput
- Checkbox
- Switch
- Slider
Feedback
Charts
Overlays
Navigation
Generated from the source by pnpm wiki · repository · v0.24.0

