Your first editor
An editor has two parts:
- the engine, a Nabu instance that owns the document (a Loro CRDT), the selection, the undo history and the commands. You create it with a factory such as createDocumentEditor();
- the NabuEditor component, which renders an engine and handles the keyboard, the clipboard and drag and drop.
Install and configure Vite first: see Installation.
Mount it
Svelte
<script>
import { NabuEditor, createDocumentEditor } from '@aionbuilders/nabu';
const engine = createDocumentEditor();
</script>
<NabuEditor {engine} />That is a complete editor: paragraphs, headings, quotes, code, callouts, rich text marks, markdown shortcuts and paste handling. What exactly you get depends on the preset; see Presets.
NabuEditor is a single contenteditable element that fills the width of its parent. Put it in a container that gives it the width, padding and typography you want.
Add the optional UI
The toolbar, the slash menu and the drag handles live in a separate entry point, and are mounted next to the editor. None of them is required.
Svelte
<script>
import { NabuEditor, createDocumentEditor } from '@aionbuilders/nabu';
import { FloatingToolbar, SlashMenu, DragHandle } from '@aionbuilders/nabu/ui';
const engine = createDocumentEditor();
</script>
<FloatingToolbar {engine} />
<SlashMenu {engine} />
<DragHandle {engine} />
<NabuEditor {engine} />Focus
engine.focus() places the caret and returns a promise that resolves once the selection is set. Call it after the component is mounted:
Svelte
<script>
import { onMount } from 'svelte';
import { NabuEditor, createTextEditor } from '@aionbuilders/nabu';
const engine = createTextEditor();
onMount(() => {
engine.focus();
});
</script>
<NabuEditor {engine} />Create an editor
Each factory is a ready-made preset:
| Factory | For |
|---|---|
| createMinimalEditor() | A <textarea> replacement: paragraphs only |
| createTextEditor() | Comment boxes, descriptions: paragraphs and rich text |
| createDocumentEditor() | Articles, notes: headings, quotes, code, callouts, toggles, media |
| createFullEditor() | Everything built in: also lists, to-dos, dialogue and regions |
| createEditor({ preset, extensions }) | The generic one: any preset, plus your own extensions |
A factory returns a Nabu engine synchronously. It works without a browser, which is what lets the examples on this page run as tests:
JavaScript
import { createDocumentEditor, Nabu } from '@aionbuilders/nabu';
const engine = createDocumentEditor();
expect(engine).toBeInstanceOf(Nabu);Every factory takes the same options: the Nabu options (NabuInit: placeholders, spellcheck, lang, tab, keymap, files, snapshot…) and extra extensions.
JavaScript
import { createTextEditor } from '@aionbuilders/nabu';
const engine = createTextEditor({
lang: 'en-GB',
spellcheck: false,
placeholders: false
});
expect(engine.lang).toBe('en-GB');
expect(engine.spellcheck).toBe(false);Read the content
engine.serialize(format) returns the document in a given format:
JavaScript
import { createDocumentEditor } from '@aionbuilders/nabu';
const engine = createDocumentEditor();
await engine.importMarkdown('# Hello\n\nSome **bold** text.');
// Markdown, as a string.
expect(engine.serialize('markdown')).toBe('# Hello\n\nSome **bold** text.');
// JSON: a plain object, safe to JSON.stringify and store.
const json = engine.serialize('json');
expect(json.version).toBe('1');
expect(json.blocks.map((block) => block.type)).toEqual(['heading', 'paragraph']);
expect(json.blocks[0].content).toEqual([{ text: 'Hello' }]);
expect(json.blocks[1].content).toEqual([
{ text: 'Some ' },
{ text: 'bold', bold: true },
{ text: ' text.' }
]);Two formats exist: 'markdown' and 'json'. The JSON export is the exact document tree: it round-trips everything, including blocks that markdown cannot express. Markdown is exact for text, lists, toggles, code and the like, but it is a lossy format by nature. Use JSON to store a document, markdown to exchange it.
An unknown format returns null.
Write the content
importJSON() and importMarkdown() replace the whole document and return a promise.
JavaScript
import { createDocumentEditor } from '@aionbuilders/nabu';
const source = createDocumentEditor();
await source.importMarkdown('## Notes\n\nFirst line.');
// Load a `serialize('json')` export, as an object or as a JSON string.
const target = createDocumentEditor();
await target.importJSON(JSON.stringify(source.serialize('json')));
expect(target.serialize('markdown')).toBe('## Notes\n\nFirst line.');- importJSON(json) accepts the object or its string form. An export of a version the editor does not know is refused (the document is left untouched).
- importMarkdown(markdown) needs MarkdownPasteExtension, which TextPreset, DocumentPreset and FullPreset include. Only the blocks of the editor's preset are produced: with TextPreset, a # heading stays a paragraph.
- Both degrade a block type the editor does not know to a paragraph; nothing is dropped.
To append a block instead of replacing everything, use insert():
JavaScript
import { createDocumentEditor } from '@aionbuilders/nabu';
const engine = createDocumentEditor();
await engine.importMarkdown('First');
engine.insert('paragraph', { text: 'Second' });
engine.insert('heading', { level: 2, text: 'A title' });
await engine.commit();
expect(engine.serialize('markdown')).toBe('First\n\nSecond\n\n## A title');Everything the user can do from the keyboard is also a command you can run with engine.exec(topic, data): toggle a mark, transform a block, undo. See Commands and keymap.
Listen to changes and export updates
The document is a Loro document, and it is public: engine.doc is the LoroDoc behind the editor. This is the way to observe changes and to export them (to sync with a server, for instance).
engine.doc.subscribe(callback) calls you after every committed change, including each keystroke, and returns an unsubscribe function:
JavaScript
import { createDocumentEditor } from '@aionbuilders/nabu';
const engine = createDocumentEditor();
const events = [];
const unsubscribe = engine.doc.subscribe((event) => {
events.push(event.by); // 'local' (the user or your code), 'import' (loaded data) or 'checkout'
});
engine.insert('paragraph', { text: 'Hello' });
await engine.commit();
await new Promise((resolve) => setTimeout(resolve)); // Loro delivers events asynchronously
expect(events).toContain('local');
expect(engine.serialize('markdown')).toBe('Hello');
unsubscribe();Loro also exports the document as binary data: a full snapshot, or only the updates since a version. Send the updates to another editor with engine.doc.import(bytes), or start an editor from a snapshot with the snapshot option:
JavaScript
import { createDocumentEditor } from '@aionbuilders/nabu';
const a = createDocumentEditor();
await a.importMarkdown('Shared text');
// A full snapshot restores an editor...
const snapshot = a.doc.export({ mode: 'snapshot' });
const b = createDocumentEditor({ snapshot });
expect(b.serialize('markdown')).toBe('Shared text');
// ...and updates keep two editors in sync.
const since = b.doc.version();
await b.importMarkdown('Edited by b');
const update = b.doc.export({ mode: 'update', from: since });
a.importRemote(update);
expect(a.serialize('markdown')).toBe('Edited by b');Use engine.importRemote(bytes) rather than engine.doc.import(bytes) for data that comes from elsewhere while the editor is on screen: it keeps the caret where it was.
Debounce your callback if you save elsewhere. For saving to the browser you do not need any of this: persistence does it for you.
Save it
To keep the document across page reloads, create the engine with createPersistedEditor. It is asynchronous: it loads the stored document first, then auto-saves every change to IndexedDB.
JavaScript
import { createPersistedEditor } from '@aionbuilders/nabu';
const engine = await createPersistedEditor({ docId: 'my-document' });Options, multi-tab behaviour, history and recovery are covered in Persistence and sync.