Code Editor (CodeMirror)
CodeMirror 6 is a code editor made of an EditorView and extensions. defineWidget turns it into a JSX tag: the code is a string in your state, each change comes back as an event, and a focus command focuses the editor from the model.
Install
Section titled “Install”npm install codemirror @codemirror/state @codemirror/lang-javascriptThe widget
Section titled “The widget”import { defineWidget } from 'sygnal'import { EditorView, basicSetup } from 'codemirror'import { Compartment } from '@codemirror/state'import { javascript } from '@codemirror/lang-javascript'
// the label can change (another language): it lives in a compartment that update() reconfiguresconst labelling = new Compartment()const label = (props) => EditorView.contentAttributes.of(props.label == null ? {} : { 'aria-label': props.label })
export const CodeEditor = defineWidget({ name: 'CodeEditor', mount: (el, props, dispatch) => new EditorView({ parent: el, doc: props.code, extensions: [ basicSetup, javascript(), labelling.of(label(props)), EditorView.updateListener.of((update) => { if (update.docChanged) dispatch('edit', update.state.doc.toString()) }), ], }), update: (view, props) => { const code = view.state.doc.toString() if (props.code !== code) view.dispatch({ changes: { from: 0, to: code.length, insert: props.code } }) // getAttribute gives null for no label if ((props.label ?? null) !== view.contentDOM.getAttribute('aria-label')) view.dispatch({ effects: labelling.reconfigure(label(props)) }) }, unmount: (view) => view.destroy(), events: ['edit'], commands: { focus: (view) => view.focus() },})mountcreates the view inside the host<div>(parent: el).basicSetupbrings line numbers, history, bracket matching, search and autocompletion.- The update listener sends the whole document as the
editevent after each change. updategets the code from state. When the user typed it, it equals the editor’s document and nothing happens; otherwise one transaction replaces the document.view.dispatchis CodeMirror’s own method for applying a transaction. It has nothing to do with the widget’sdispatch, the third parameter ofmount, which sends events to the intent.- The
focuscommand focuses the editable area. A command the widget declares wins over the host element’s method of the same name, so{ focus: '.code' }runs this one, not the<div>’s.
Using it
Section titled “Using it”import { CodeEditor } from './CodeEditor.js'
const START = 'const answer = 42\n'
export function Snippet({ state }) { return ( <section> <CodeEditor className="code" label="Snippet" code={state.code} /> <p className="lines">{state.code.split('\n').length} lines</p> <button type="button" className="edit-code">Edit</button> <button type="button" className="reset">Reset</button> </section> )}
Snippet.initialState = { code: START }
Snippet.intent = ({ DOM }) => ({ EDIT: DOM.select('.code').events('edit').detail(), FOCUS_CODE: DOM.click('.edit-code'), RESET: DOM.click('.reset'),})
Snippet.model = { EDIT: (state, code) => ({ ...state, code }), FOCUS_CODE: { ELEMENT: { focus: '.code' } }, RESET: (state) => ({ ...state, code: START }),}.code .cm-editor { background: white; color: black; border: 1px solid #8888; }CodeMirror’s default theme is a light one: dark syntax colours and a light gutter, on whatever background the page has. On a page that can be dark, give the editor a light background, as here, or add a dark theme extension (@codemirror/theme-one-dark) and switch it with a compartment.
Testing
Section titled “Testing”import { test, expect } from 'vitest'import { renderComponent } from 'sygnal'import { Snippet } from './Snippet.jsx'
test('edits reach the state, Reset puts the start code back', async () => { const t = renderComponent(Snippet) await t.ready() t.widget('.code').dispatch('edit', 'const answer = 42\nconsole.log(answer)\n') await t.next((state) => state.code.includes('console.log')) expect(t.query('.lines').textContent).toBe('3 lines')
t.simulateEvent('.reset', 'click') await t.next((state) => state.code === 'const answer = 42\n') expect(t.widget('.code').props.code).toBe('const answer = 42\n')
t.simulateEvent('.edit-code', 'click') await t.settle() expect(t.commands()).toEqual([{ focus: '.code' }]) t.dispose()})CodeMirror measures text positions with layout APIs jsdom doesn’t have, so test typing in a real browser: with renderComponent(Snippet, { dom: 'real' }) under Playwright, click Edit, type, and t.waitForState((state) => ...) sees the new code; t.widget('.code').instance is the EditorView.
Measured with Vite, minified and gzipped, Sygnal not included: basicSetup with the JavaScript language adds 177 KB. For a smaller editor, replace basicSetup with minimalSetup (history, default keymap, special characters) and the extensions you want from @codemirror/view, @codemirror/commands and the language packages. defineWidget adds 1.1 KB for the first widget in an app.
Pitfalls
Section titled “Pitfalls”- Keep the comparison in
update. Replacing the document on every render would reset the cursor and the undo history while the user types. - A reset is also an edit. The transaction
updatedispatches changes the document, so the listener sends it back as aneditwith the same code. That costs one extra action and changes nothing. To skip it, add an annotation to the transaction (annotations: Transaction.remote.of(true)from@codemirror/state) and ignore updates that carry it. - Change configuration with compartments.
mountbuilds the extensions once. The label is in aCompartmentthatupdatereconfigures when thelabelprop changes (a translated app switching language); do the same to switch the language mode, the theme orreadOnlyfrom props (view.dispatch({ effects: compartment.reconfigure(...) })).@codemirror/stateis installed withcodemirror; list it yourself so there is one copy (two copies break compartments). - Label the editable element. The host
<div>is not what receives the keyboard.EditorView.contentAttributesputsaria-labelon CodeMirror’s.cm-content, the element a screen reader announces. - Tab. CodeMirror doesn’t trap Tab by default, so keyboard users can leave the editor. Adding
indentWithTabchanges that; if you do, tell users that Escape then Tab leaves the editor.