# Verbal (verbal-editor) > A block editor for the web with zero runtime dependencies: every block is its own contenteditable, typing costs no React renders, and every feature is a module you opt into. # Introduction > A block editor for the web that stays out of your framework's way and weighs what you use. Verbal is a block editor — paragraphs, headings, lists, to-dos, quotes, code, tables, charts, equations, columns, images and embeds — with no runtime dependencies. Every block is its own editable element: the browser handles your typing and Verbal reads it back, so typing, selecting and formatting never re-render React. ::: stats - **12.19 KB** core: editor, React binding and paragraph, JS gzip - **30.57 KB** every module, JS gzip - **0** React renders per keystroke ::: These figures come from the package's size build and its benchmark; see [Benchmarks](#/benchmarks) for how they compare with other editors. ## What makes it different - **The browser types.** Plain typing and deleting pass through to the browser untouched. The view reads the result back into the model; nothing re-renders, so a keystroke in a 1,000-block document costs the same as in an empty one. - **Everything is a module.** Core knows only paragraphs. Each block type, mark and piece of UI is a plain object you pass in, and each brings its own CSS — you pay for what you list, nothing more. - **Every change is a transaction.** Keystrokes, commands, pastes and AI suggestions all become small operations that carry their own inverse. Undo restores the document and the selection exactly. - **The platform does the heavy lifting.** Code colours use the CSS Custom Highlight API, equations compile to MathML, menus use the Popover API and dragging uses Pointer Events — no highlighter, math renderer or drag library ships. ## A first look ```jsx App.jsx import 'verbal-editor/tokens.css'; import preset from 'verbal-editor/preset'; import { Blocks, useEditor } from 'verbal-editor/react'; export default function App() { const editor = useEditor({ ...preset }); return ; } ``` That is a complete editor with every module. Most apps list only the modules they need — see [Choosing modules](#/docs/choosing-modules). ## Where to go next ::: cards - [Installation](#/docs/installation) Add the package and its stylesheet. - [Quickstart](#/docs/quickstart) A working editor in React, Next.js, Vite or plain JavaScript. - [How it works](#/docs/how-it-works) The four layers, and what happens on a keystroke. - [Writing a module](#/docs/writing-a-module) Your own blocks, marks and UI with the same contract. ::: # Installation > Add the package and its stylesheet; nothing else is installed with it. ::: code-group ```bash [npm] npm install verbal-editor ``` ```bash [pnpm] pnpm add verbal-editor ``` ```bash [yarn] yarn add verbal-editor ``` ```bash [bun] bun add verbal-editor ``` ::: The package has no dependencies. Two optional peers cover the parts that need them: | Peer | Needed for | Where it runs | | --- | --- | --- | | `react` 18 or later | the `` binding from `verbal-editor/react` | the browser | | `joi` 17 or later | `verbal.config.js`, the CLI and `verbal-editor/server` | build time and your server | Without React, render with the DOM binding from `verbal-editor/dom` instead. Joi never reaches the browser: the size check in this repository fails the build if a browser entry can reach it. ## The stylesheet Import the tokens once, anywhere in your app. Every module brings its own CSS with its JavaScript, so this is the only stylesheet you add by hand: ```js main.js import 'verbal-editor/tokens.css'; ``` `tokens.css` is also the whole theming API — see [Theming](#/docs/theming). ## TypeScript Types ship with the package: every entry has declarations generated from the JSDoc in its source, so editors and `tsc` see the same contract the docs describe. There is nothing extra to install. ## Browsers Verbal needs the CSS Custom Highlight API, which sets the floor at Chrome 105, Safari 17.2 and Firefox 140. The full list is on [Browser support](#/docs/browser-support). > [!TIP] > The package is `verbal-editor` on npm once it is published; until then, install it from a local build with `npm pack` in this repository. # Quickstart > A working editor in React, Next.js, Vite or plain JavaScript, saved on every change. Pick your setup. Each sample is a complete editor with headings, lists, to-dos, bold, italic, links and the slash menu — swap the modules for the ones you need. ::: code-group ```jsx [React] components/Editor.jsx import 'verbal-editor/tokens.css'; import heading from 'verbal-editor/blocks/heading'; import list from 'verbal-editor/blocks/list'; import todo from 'verbal-editor/blocks/todo'; import bold from 'verbal-editor/marks/bold'; import italic from 'verbal-editor/marks/italic'; import link from 'verbal-editor/marks/link'; import slash from 'verbal-editor/ui/slash'; import { Blocks, useEditor } from 'verbal-editor/react'; export default function Editor() { const editor = useEditor({ blocks: [heading, list, todo], marks: [bold, italic, link], ui: [slash] }); return ; } ``` ```jsx [Next.js] app/page.jsx 'use client'; import dynamic from 'next/dynamic'; // The editor lives in the browser: load it without server rendering. const Editor = dynamic(() => import('../components/Editor.jsx'), { ssr: false }); export default function Page() { return ; } ``` ```jsx [Vite] src/main.jsx import { createRoot } from 'react-dom/client'; import 'verbal-editor/tokens.css'; import preset from 'verbal-editor/preset'; import { Blocks, useEditor } from 'verbal-editor/react'; function App() { const editor = useEditor({ ...preset }); return ; } createRoot(document.getElementById('root')).render(); ``` ```js [Vanilla JS] main.js import 'verbal-editor/tokens.css'; import { Editor } from 'verbal-editor'; import { mount } from 'verbal-editor/dom'; import heading from 'verbal-editor/blocks/heading'; import list from 'verbal-editor/blocks/list'; import bold from 'verbal-editor/marks/bold'; import slash from 'verbal-editor/ui/slash'; const editor = new Editor({ blocks: [heading, list], marks: [bold], ui: [slash] }); mount(editor, document.getElementById('editor')); ``` ::: In Next.js, `components/Editor.jsx` is the React sample. The Vite sample starts from `npm create vite@latest -- --template react`; the vanilla one needs only an element with `id="editor"` and any bundler. ## Save and load ::: steps ### Listen for changes Every transaction — a keystroke, a paste, an undo — fires `change`. Save the document there. ### Store the JSON `getDoc()` returns a plain, deep-copied document. It serializes as it is. ### Load it back Pass it as `doc` when you create the editor, or call `setDoc` later. ::: ```js storage.js import { Editor } from 'verbal-editor'; const saved = localStorage.getItem('note'); const editor = new Editor({ doc: saved ? JSON.parse(saved) : undefined }); editor.on('change', () => localStorage.setItem('note', JSON.stringify(editor.getDoc()))); ``` > [!NOTE] > Loading is forgiving: unknown block types become paragraphs and invalid marks are dropped. Validate documents strictly on your server with [server validation](#/docs/server-validation). ## Next ::: cards - [Choosing modules](#/docs/choosing-modules) What each module adds and weighs. - [Document model](#/docs/document-model) What `getDoc()` returns. - [Transactions & undo](#/docs/transactions) Change the document from code. ::: # Choosing modules > Every block, mark and piece of UI is opt-in; here is what each one adds and weighs. The core ships one block type, the paragraph. Everything else is a module — a plain object you pass to the editor. Importing a module registers nothing by itself, and each one imports its own CSS, so an editor weighs exactly the modules you list. ```js editor.js import { Editor } from 'verbal-editor'; import heading from 'verbal-editor/blocks/heading'; import code from 'verbal-editor/blocks/code'; import bold from 'verbal-editor/marks/bold'; import toolbar from 'verbal-editor/ui/toolbar'; const editor = new Editor({ blocks: [heading, code], marks: [bold], ui: [toolbar] }); ``` ## Or take all of them `verbal-editor/preset` lists every block, mark and UI module, and re-exports the AI review helpers. Spread it into the options and add or remove from its arrays: ```js full.js import { Editor } from 'verbal-editor'; import preset from 'verbal-editor/preset'; const editor = new Editor({ ...preset, ui: preset.ui.filter((m) => m.name !== 'emoji') }); ``` ## What each one weighs Measured from the built package with gzip, the way the size gate in CI measures it. The budget column is the limit that gate enforces. | Entry | JS gzip | CSS gzip | Budget | | --- | --- | --- | --- | | Core: editor + React binding + paragraph | 12.19 KB | | 12.5KB | | Full preset: every module | 30.57 KB | 5.00 KB | 31.5KB | | [blocks/chart](#/docs/modules/blocks/chart) | 1.91 KB | 0.65 KB | | | [blocks/code](#/docs/modules/blocks/code) | 2.23 KB | 0.51 KB | | | [blocks/columns](#/docs/modules/blocks/columns) | 0.63 KB | 0.25 KB | | | [blocks/divider](#/docs/modules/blocks/divider) | 0.29 KB | 0.12 KB | | | [blocks/embed](#/docs/modules/blocks/embed) | 1.27 KB | 0.51 KB | | | [blocks/heading](#/docs/modules/blocks/heading) | 0.56 KB | 0.16 KB | | | [blocks/image](#/docs/modules/blocks/image) | 0.93 KB | 0.17 KB | | | [blocks/list](#/docs/modules/blocks/list) | 0.52 KB | 0.29 KB | | | [blocks/math](#/docs/modules/blocks/math) | 4.83 KB | 0.62 KB | | | [blocks/quote](#/docs/modules/blocks/quote) | 0.33 KB | 0.14 KB | | | [blocks/table](#/docs/modules/blocks/table) | 2.47 KB | 0.69 KB | | | [blocks/todo](#/docs/modules/blocks/todo) | 0.79 KB | 0.55 KB | | | [marks/bold](#/docs/modules/marks/bold) | 0.24 KB | 0.00 KB | | | [marks/code](#/docs/modules/marks/code) | 0.17 KB | 0.00 KB | | | [marks/italic](#/docs/modules/marks/italic) | 0.22 KB | 0.00 KB | | | [marks/link](#/docs/modules/marks/link) | 0.67 KB | 0.00 KB | | | [marks/strike](#/docs/modules/marks/strike) | 0.25 KB | 0.00 KB | | | [ui/dnd](#/docs/modules/ui/dnd) | 1.84 KB | 0.47 KB | | | [ui/emoji](#/docs/modules/ui/emoji) | 0.31 KB | 0.00 KB | | | [ui/slash](#/docs/modules/ui/slash) | 1.36 KB | 0.49 KB | | | [ui/toolbar](#/docs/modules/ui/toolbar) | 1.29 KB | 0.71 KB | | | [ai/diff](#/docs/modules/ai/diff) | 0.65 KB | 0.00 KB | | | [ai/pending](#/docs/modules/ai/pending) | 1.57 KB | 0.64 KB | | Code languages are not in these numbers: each one loads the first time a block shows it. The emoji index is fetched on the first `:` you type. ## Kinds of module | Kind | Import from | Examples | Registers | | --- | --- | --- | --- | | Blocks | `verbal-editor/blocks/` | heading, table, code | a block type, its slash entries, keys and Markdown rules | | Marks | `verbal-editor/marks/` | bold, link | a mark, its shortcut and typing rule | | UI | `verbal-editor/ui/` | slash, toolbar, dnd | menus and interactions, no document content | | AI | `verbal-editor/ai/` | pending, diff | functions you call; nothing to register | Every module has a reference page generated from its own source under [Modules](#/docs/modules). > [!TIP] > `npx verbal init` asks which modules you want and prints the imports for exactly those — see [CLI](#/docs/cli). # How it works > Four layers, and exactly what each one does on a keystroke, an Enter, a paste and an AI edit. ::: diagram layers "Four layers, top to bottom: the view, the binding, the editor and the model. Changes flow down as transactions." ::: - **Model.** A flat map of blocks with `children` arrays for order and nesting. Ids never change, so moving or undoing never renames a block. See [Document model](#/docs/document-model). - **Editor.** Turns intent — keys, commands, typing rules, module actions — into transactions of small operations, each carrying its inverse, and keeps the history. See [Transactions & undo](#/docs/transactions). - **Binding.** Renders one host element per block, each re-rendered only when what it draws changes: the block's type, its child list, or attributes a module puts on the host. Typing and props a block draws itself render nothing. React and plain DOM bindings follow the same contract. See [Rendering model](#/docs/rendering). - **View.** Owns everything inside a host: one `contenteditable` per text block, the mapping between DOM selections and model positions, and the markup for marks — written the same way in every engine, never with `execCommand`. ## A keystroke ::: steps ### The browser inserts the character `beforeinput` sees `insertText` and lets it through. No script runs before the character appears. ### The view reads it back On `input`, the view reads the block's text runs from the DOM and hands them to the editor with the selection before and after. ### The editor records it The editor diffs old and new text into `insertText`/`deleteText` operations and dispatches them with `origin: 'input'`: the model and history update, nothing is repainted and no version moves, so no block re-renders. ### Rules get a look Listeners on `input` run, then Markdown rules: `# ` at the start of a paragraph, or a closing `**`, converts in its own undo step. ::: ## Enter ::: steps ### The key is claimed Enter is handled on `keydown` (or as `insertParagraph` from `beforeinput`). A listener on `key` can claim it first, then the block's module (Enter in code inserts a newline), then shortcuts, then core. ### The block splits Core deletes any selected text, moves the text after the caret into a new block — the type comes from the module's `next` — and dispatches one transaction. ### Two blocks render The parent's version moves because its child list changed, and the new block mounts. Everything else stays as it was. ::: ## A paste ::: steps ### A module may claim it Image files become image blocks, a lone video link on an empty line becomes an embed, text pasted into a table cell stays in that cell. ### Verbal's own copy comes back exactly Copying writes the exact blocks into the HTML; pasting them back restores types, props, marks and nesting. ### Anything else is parsed, never injected Other HTML is read in an inert document through each module's parse rules; plain text is read as Markdown. The result is one transaction with fresh ids. ::: ## An AI edit `review(editor, changes)` shows the proposed text as a word diff painted over the real text with the Custom Highlight API. The model does not change while you review. Accepting dispatches one ordinary transaction — undo takes it back like anything you typed — and rejecting leaves the document byte-for-byte as it was. See [AI review](#/docs/ai-review). ```js review.js import { Editor } from 'verbal-editor'; import { review } from 'verbal-editor/ai/pending'; const editor = new Editor(); const [first] = editor.getDoc().blocks.doc.children; const { done } = review(editor, { [first]: 'A tighter first paragraph.' }); done.then(() => console.log('every hunk accepted or rejected')); ``` # Document model > A flat map of blocks with stable ids; text is runs of marks. Equal documents are equal JSON. A document is a flat map of blocks plus `children` arrays that give the order. Ids are stable: moving, nesting or undoing never renames a block. ```json document.json { "version": 1, "root": "doc", "blocks": { "doc": { "type": "doc", "children": ["a", "b"] }, "a": { "type": "heading", "props": { "level": 2 }, "content": [{ "text": "Plan", "marks": [] }] }, "b": { "type": "list", "props": { "ordered": false }, "content": [{ "text": "Ship ", "marks": [] }, { "text": "it", "marks": [{ "type": "bold" }] }], "children": ["c"] }, "c": { "type": "todo", "props": { "checked": true }, "content": [{ "text": "Write docs", "marks": [] }] } } } ``` ## Blocks | Field | Type | Description | | --- | --- | --- | | `type` | `string` | the module that owns the block; `doc` for the root | | `props` | `object` | the module's declared props, such as a heading's `level`; anything undeclared is dropped on load | | `content` | `Run[]` | the block's text; absent on void blocks (divider, image, table, chart) | | `children` | `string[]` | nested blocks in order: items under an item, cells in a table, blocks in a column | ## Runs and marks Text is a list of runs. Each run carries the marks on it, sorted by type; a mark is `{ type }` plus the attributes it needs, such as a link's `href`. Adjacent runs with the same marks are always merged, so two equal documents are equal JSON. ```json runs.json [ { "text": "Read the ", "marks": [] }, { "text": "docs", "marks": [{ "type": "bold" }, { "type": "link", "href": "https://example.com" }] } ] ``` ## Reading and writing ```js io.js import { Editor, serialize } from 'verbal-editor'; const editor = new Editor(); const doc = editor.getDoc(); // a deep copy, blocks in document order const json = serialize(doc); // the same payload as a stable string editor.setDoc(json); // replaces the document; history starts over ``` `getDoc()` returns a copy, so changing it changes nothing — to change the document, use a [transaction](#/docs/transactions). `setDoc` accepts the payload or its JSON string. ## Loading is forgiving `parse` — which the `doc` option and `setDoc` use — repairs rather than rejects: unknown block types become paragraphs, orphaned blocks are dropped, props of the wrong type are removed and invalid marks (a `javascript:` link) disappear. An API that stores documents should reject instead; use [server validation](#/docs/server-validation). > [!NOTE] > Tables keep their cells as `children` of the table, row by row, `cols` wide. Columns hold `column` blocks, and each column holds ordinary blocks. # Transactions & undo > Every change is a transaction of invertible operations; your code uses the same path as the keyboard. A keystroke, a paste, a drag, an undo and an accepted AI edit are all the same thing: a transaction of small operations, each carrying what it needs to invert itself. Your code changes the document the same way. ## Building a transaction ```js tx.js import { Editor, caret } from 'verbal-editor'; const editor = new Editor(); const first = editor.getDoc().blocks.doc.children[0]; const t = editor.tx().insertText(first, 0, 'Hello, ').insertText(first, 7, 'world'); editor.dispatch(t, { selection: caret(first, 12) }); ``` `tx()` starts a draft of the document. Each call applies one validated operation to the draft, so later calls see earlier ones (`t.get`, `t.parent`, `t.index` read the draft). Nothing changes until `dispatch`, and a dispatched transaction is one undo step. If an operation throws, the ones before it are rolled back. | Method | Does | | --- | --- | | `insertText(block, at, text, marks?)` | inserts text with the given marks | | `insertRuns(block, at, runs)` | inserts runs, each with its own marks | | `deleteText(block, from, to)` | deletes a range | | `mark(block, from, to, mark, on?)` | adds, or with `on = false` removes, a mark over a range | | `setProps(block, props)` | merges props; `null` deletes one | | `setType(block, type, props?)` | changes a block's type, keeping its text | | `insert(parent, index, block)` | inserts a block and returns its id | | `remove(id)` | removes a block and everything under it | | `move(id, parent, index)` | moves a block with its children; `index` counts after its removal | ## Commands Higher-level commands act on the current selection the way the keyboard does, and each is one transaction: ```js commands.js import { Editor, caret } from 'verbal-editor'; import heading from 'verbal-editor/blocks/heading'; import bold from 'verbal-editor/marks/bold'; const editor = new Editor({ blocks: [heading], marks: [bold] }); const first = editor.getDoc().blocks.doc.children[0]; editor.select(caret(first, 0)); editor.insert('heading', { level: 2 }); // converts the empty paragraph editor.toggleMark('bold'); // bold for whatever is typed next editor.setType('paragraph'); // every selected block editor.indent(); // Tab editor.undo(); ``` `indent`, `outdent` and `setType` take block ids as well — `editor.indent('a', 'b')` nests both in one step — and default to the blocks the selection touches. ## Selection A selection is two text points — `{ anchor, focus }`, each `{ block, offset }` — or whole blocks, `{ blocks: [...] }`. `caret(block, offset)` builds a collapsed one, and `editor.range` gives `{ block, from, to }` when the selection lies inside one block. ## Events ```js events.js import { Editor } from 'verbal-editor'; const editor = new Editor(); const stop = editor.on('change', ({ ops, origin }) => console.log(ops.length, 'ops from', origin ?? 'a command')); editor.on('selectionchange', (selection) => console.log(selection)); editor.on('key', ({ name }) => name === 'Mod-s'); // true claims the key stop(); ``` | Event | Payload | Fires | | --- | --- | --- | | `change` | `{ ops, origin }` | after every transaction; `origin` is `'input'` for typing and `'history'` for undo and redo | | `selectionchange` | the selection or `null` | whenever the selection moves | | `input` | `{ id, data }` | after typed text, before Markdown rules — return `true` to skip them | | `key` | `{ name, e }` | before any key is handled — return `true` to claim it | ## History Undo restores the document and the selection exactly. Typing in one block within a second of the last keystroke joins the previous step; moving the caret seals it. Each Markdown conversion is its own step, so one undo turns `**bold**` back into the characters you typed. The newest 500 steps are kept. > [!NOTE] > A read-only editor (`editable: false`) ignores every transaction, so commands, undo and module controls leave the document untouched. This site's docs are rendered that way. # Rendering model > One host per block, each re-rendered only when what it draws changes; typing changes nothing. A binding renders one host element per block and the list of its children; everything inside a host belongs to the editor's view. Each host subscribes to its own block and re-renders only when what it draws changes: the block's type, its child list, or attributes a module puts on the host. Props a block draws itself are patched by the view, and typing changes nothing. | Change | What renders | | --- | --- | | typing, deleting, selecting | nothing | | a mark (bold, a link) | nothing — the view repaints the text | | a prop the block draws itself (a heading level, a to-do's box) | nothing — the view patches or rebuilds that block's element | | a prop shown on the host (a list's numbering, a table's columns) | that block | | inserting or removing a block | the block whose child list changed, and the new block | | moving a block | the parents it left and joined | ## React ```jsx Page.jsx import { useEffect } from 'react'; import preset from 'verbal-editor/preset'; import { Blocks, useEditor } from 'verbal-editor/react'; export function Page({ doc, onSave }) { const editor = useEditor({ ...preset, doc }); useEffect(() => editor.on('change', () => onSave(editor.getDoc())), [editor, onSave]); return ; } ``` - `useEditor(options)` creates one editor for the component's lifetime. Options are read once; to load a different document later, call `editor.setDoc(doc)`. - `` renders the document; re-renders of your app stop there. - `useBlock(editor, id)` returns a block and re-renders when that block changes — for your own UI around blocks. ## Plain DOM `verbal-editor/dom` does the same without a framework: `mount` builds the hosts, patches only the ones whose version moved, and returns the function that removes it all. The [Core only example](#/examples/core) runs on it, with no modules at all. ```js vanilla.js import { Editor } from 'verbal-editor'; import { mount } from 'verbal-editor/dom'; import heading from 'verbal-editor/blocks/heading'; const editor = new Editor({ blocks: [heading] }); const stop = mount(editor, document.getElementById('editor')); window.addEventListener('pagehide', stop); ``` ## Counting renders Pass `onRender` to see every block render; both bindings call it. The landing's render counter and this repository's tests use it. ```jsx Watched.jsx import preset from 'verbal-editor/preset'; import { Blocks, useEditor } from 'verbal-editor/react'; export function Watched() { const editor = useEditor({ ...preset, onRender: (id) => console.count(id) }); return ; } ``` ## Read-only `editable: false` renders the same document without editing: text stays selectable and copyable, every transaction is ignored, and modules hide their controls (a code block's language picker becomes a label, table tools and chart menus disappear, to-do boxes stop toggling). ```jsx Article.jsx import preset from 'verbal-editor/preset'; import { Blocks, useEditor } from 'verbal-editor/react'; export function Article({ doc }) { const editor = useEditor({ blocks: preset.blocks, marks: preset.marks, doc, editable: false }); return ; } ``` ## Server rendering The editor and its modules touch the DOM only when mounted, so importing them on the server is safe when your bundler handles their CSS imports. The text itself is written by the view in the browser, so render the editor on the client — in Next.js, load it with `dynamic(..., { ssr: false })` as in the [Quickstart](#/docs/quickstart). # Config file > verbal.config.js names the modules your app enables and the size it may weigh; Joi checks it at build time. A config file is optional. It gives one place to name the modules your app uses, feeds [server validation](#/docs/server-validation) the same list, and lets [`verbal doctor`](#/docs/cli) hold the editor to a size budget in CI. ```js verbal.config.js import { defineConfig } from 'verbal-editor/config'; export default defineConfig({ blocks: ['heading', 'list', 'todo', 'table', 'code'], marks: ['bold', 'italic', 'link'], ui: ['slash', 'toolbar', 'dnd'], ai: false, budget: 30, }); ``` ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `blocks` | `string[]` | `[]` | block modules by name — the same names as `verbal-editor/blocks/` | | `marks` | `string[]` | `[]` | mark modules by name | | `ui` | `string[]` | `[]` | UI modules by name | | `ai` | `boolean` | `false` | whether the app uses AI review | | `budget` | `number` | `30` | KB of gzipped JavaScript the configured editor may weigh | The names come from the package itself — the config accepts exactly the modules it ships: ```js names.js import { available } from 'verbal-editor/config'; console.log(available.blocks, available.marks, available.ui); ``` ## Errors `defineConfig` validates with Joi and throws with the exact path and reason, so the build that loads the config fails. These are its real messages for `blocks: ['heading', 'tables']` and for `blocks: ['chart']` — a chart draws from a table: ```text Output Invalid Verbal config: "blocks[1]" must be one of [heading, todo, list, quote, divider, code, table, chart, math, columns, image, embed] Invalid Verbal config: "blocks" has chart, which draws from a table: add "table" ``` > [!WARNING] > The config imports Joi. Load it in build scripts and on your server — never from browser code. # CLI > verbal init writes a config and prints its imports; verbal doctor checks it and reports each module's cost. The `verbal` command ships with the package and runs at build time; it needs Joi as a dev dependency. ::: code-group ```bash [npm] npm install -D joi npx verbal init ``` ```bash [pnpm] pnpm add -D joi pnpm exec verbal init ``` ```bash [yarn] yarn add -D joi yarn verbal init ``` ```bash [bun] bun add -d joi bunx verbal init ``` ::: ## verbal init Asks which blocks, marks and UI modules to enable (Enter takes them all) and whether you use AI review, writes `verbal.config.js`, and prints the imports and the `new Editor(...)` call for exactly that set. | Flag | Does | | --- | --- | | `--yes` | takes every module without asking | | `--force` | replaces an existing config | | `[file]` | writes somewhere other than `verbal.config.js` | ## verbal doctor Validates the config and reports what each enabled module weighs, measured from the installed package. It exits non-zero when the config is invalid or the editor is over its `budget`, so it can gate CI. This is its real output for the config on the [Config file](#/docs/config-file) page, produced when this site was built: ```text Output ✓ ./verbal.config.js is valid module JS gzip CSS gzip core 11.76 KB 0.46 KB react binding 0.58 KB — blocks/heading 0.56 KB 0.16 KB blocks/list 0.52 KB 0.29 KB blocks/todo 0.79 KB 0.55 KB blocks/table 2.47 KB 0.69 KB blocks/code 2.23 KB 0.51 KB marks/bold 0.24 KB — marks/italic 0.22 KB — marks/link 0.67 KB — ui/slash 1.36 KB 0.49 KB ui/toolbar 1.29 KB 0.71 KB ui/dnd 1.84 KB 0.47 KB total 21.05 KB 3.55 KB budget 30 KB JS ``` > [!TIP] > Run `verbal doctor` in CI after your build: a module added without thought shows up as a failed check with its cost next to it. # Server validation > validateDoc rejects malformed documents at your API boundary with precise paths; it never repairs. Loading in the browser is forgiving: an unknown block becomes a paragraph. Your API should not be. `validateDoc` checks a document against the modules you enable and rejects anything malformed — nothing is coerced or repaired. ```js api.js import { defineConfig } from 'verbal-editor/config'; import { validateDoc } from 'verbal-editor/server'; const config = defineConfig({ blocks: ['heading', 'list', 'table'], marks: ['bold', 'link'] }); /** Accepts a document only if every block, prop and mark is one this app allows. */ export function ingest(body) { const { error, value } = validateDoc(body, config); if (error) return { status: 422, errors: error.details.map((d) => d.message) }; return { status: 200, doc: value }; } ``` ## What it checks - **Types.** Every block type and mark is one the config enables (all modules when the config is omitted). - **Props.** Each prop has the type its module declares — a heading's `level` is one of its levels, a to-do's `checked` a boolean. - **Text where text belongs.** Void blocks carry no `content`; code blocks carry no marks. - **Marks.** Each mark passes its module's own check, so a link with a `javascript:` target is rejected. - **One tree.** The root exists and is the only `doc`; every child exists and appears exactly once, with no orphans. ## Errors `error.details` lists every problem with a path. These are its real messages for a document with a heading at level 5 and a `javascript:` link, and for one whose root lists a block that does not exist — the tree is checked once every block is valid: ```text Output "blocks.a.props.level" must be one of [1, 2, 3] "blocks.b.content[0].marks[0]" contains an invalid value "blocks.doc.children[1]" refers to missing block "x" ``` > [!NOTE] > Validation uses Joi, so it runs on your server or in build scripts. The browser never loads it. # Writing a module > Your own blocks, marks and UI are plain objects with the same contract the built-in modules use. Core knows only paragraphs. Every other block type, mark and piece of UI is a plain object passed to the editor — the same contract the built-in modules use. The types are `BlockModule`, `MarkModule` and `UiModule` from `verbal-editor`. ## A block A callout: text with a tone, inserted from the slash menu or by typing `! ` at the start of a line. ```js callout.js import { Editor } from 'verbal-editor'; /** @type {import('verbal-editor').BlockModule} */ const callout = { type: 'callout', schema: { props: { tone: ['info', 'warning'] }, content: 'inline' }, create: (props) => ({ type: 'callout', props: { tone: 'info', ...props }, content: [] }), className: 'callout', view: { create: () => document.createElement('aside'), host: (block) => ({ 'data-tone': block.props.tone }), }, slash: { label: 'Callout', icon: '!', keywords: ['note', 'tip'] }, input: { markdown: [[/^!\s$/, () => ({ type: 'callout' })]] }, parse: { tags: ['ASIDE'] }, serialize: { markdown: (block, md) => `> Note: ${md.inline(block.content)}`, html: (block, inner) => ``, }, }; new Editor({ blocks: [callout] }); ``` | Field | What it declares | | --- | --- | | `type` | the block type; the only required field | | `schema` | props (a constructor, or the allowed values) and the content kind: `'inline'` text with marks, `'code'` plain text, `'none'` for a void block | | `create` | a fresh block of this type | | `view` | `create` returns the element that holds the text; `patch` updates it in place; `host` adds attributes to the block's host | | `slash` | one or more slash-menu entries | | `input` | scoped `keys`, global `shortcuts`, and `markdown` typing rules that turn a paragraph into this type | | `parse` | the HTML `tags` and Markdown it is read from when pasted | | `serialize` | how it is copied as Markdown and HTML | | `next` | what Enter creates after it | | `mount` | runs when the editor mounts; returns its cleanup | Style it with ordinary CSS: `.callout[data-tone="warning"] { … }`. ## A mark ```js highlight.js import { Editor } from 'verbal-editor'; /** @type {import('verbal-editor').MarkModule} */ const highlight = { type: 'highlight', tags: ['MARK'], shortcut: 'Mod-Shift-h', markdown: /==([^=\n]+)==$/, md: '==', toolbar: { label: 'H', title: 'Highlight ⌘⇧H' }, }; new Editor({ marks: [highlight] }); ``` The first tag renders the mark, `markdown` converts `==text==` as you type, `md` writes it back when copying, and `toolbar` adds a button to the selection toolbar. ## A piece of UI UI modules are framework-free: they get the editor when it mounts and return a cleanup. ```js word-count.js import { Editor } from 'verbal-editor'; /** @type {import('verbal-editor').UiModule} */ const wordCount = { name: 'word-count', mount(editor) { const badge = document.body.appendChild(document.createElement('output')); const count = () => { const words = Object.values(editor.doc.blocks).flatMap((b) => (b.content ?? []).map((r) => r.text)); badge.textContent = `${words.join(' ').split(/\s+/).filter(Boolean).length} words`; }; count(); const off = editor.on('change', count); return () => (off(), badge.remove()); }, }; new Editor({ ui: [wordCount] }); ``` Blocks and marks can have a `mount` hook too — the table uses it to keep typing in cells free of Markdown rules, the chart to redraw when its table changes. ## Rules the built-in modules follow - Never write into a block's text directly; use [transactions](#/docs/transactions), so undo stays exact. - Keep anything that is not text out of the editable element, or mark it `data-skip`. - Put positioned UI in the top layer (`popover`) or `document.body`, not inside the editor. - Hide controls under `[data-readonly]` so the module behaves in a read-only editor. > [!TIP] > This site's docs components — callouts, code groups, steps and cards — are modules written exactly this way, in `demo/markdown.js`. # AI review > AI edits arrive as a proposal painted over the real text; nothing changes until you accept. An AI edit arrives as a proposal, not a replacement. The document does not change until you accept: deletions are painted over the real text with the CSS Custom Highlight API, and the proposed text sits beside the block. Accept or reject everything — ⌘⏎ or Esc — or hunk by hunk. An accepted edit is one ordinary undo step; a rejected one leaves the document byte-for-byte as it was. ```js suggest.js import { Editor } from 'verbal-editor'; import { review } from 'verbal-editor/ai/pending'; const editor = new Editor(); /** Asks your server to rewrite every paragraph, then shows the result as a reviewable diff. */ export async function suggest() { const paragraphs = Object.entries(editor.getDoc().blocks).filter(([, b]) => b.type === 'paragraph'); const texts = Object.fromEntries(paragraphs.map(([id, b]) => [id, (b.content ?? []).map((r) => r.text).join('')])); const res = await fetch('/api/rewrite', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(texts) }); const { done } = review(editor, await res.json()); await done; } ``` `review(editor, changes)` takes block id → proposed text and returns `{ done, preview, accept, reject }`. `done` resolves once every hunk is accepted or rejected. A block you edit while reviewing is diffed again against its proposal. ## The server side Keep your model provider's key on the server. The route only has to return block id → new text: ```js rewrite.js /** * Rewrites each block's text with a model of your choice. * @param {Record} texts block id → current text * @param {(prompt: string) => Promise} callModel your provider's API */ export async function rewrite(texts, callModel) { const out = {}; for (const [id, text] of Object.entries(texts)) out[id] = await callModel(`Tighten this, keep its meaning:\n\n${text}`); return out; } ``` ## The diff `diffWords(before, after)` from `verbal-editor/ai/diff` is the word-level Myers diff the review uses, and `hunks(parts)` groups it into changes. Both work on plain strings, on the server too. ```js diff.js import { diffWords, hunks } from 'verbal-editor/ai/diff'; const parts = diffWords('the quick brown fox', 'the quick red fox'); console.log(hunks(parts).length); // 1 ``` > [!NOTE] > Try it on the [AI review example](#/examples/ai): press Suggest edits, then accept or reject. # Theming > tokens.css is the whole theming API — custom properties with a light and a dark value each. `tokens.css` is a set of CSS custom properties, each with a light and a dark value. The dark values apply by themselves when the system prefers dark; set `data-theme="light"` or `data-theme="dark"` on `` to force one. ```css theme.css :root { --v-accent: #7c3aed; --v-font: "Inter", system-ui, sans-serif; --v-radius: 10px; } ``` Override any token after importing `tokens.css` — nothing is rebuilt and no script restyles anything. Durations drop to zero when the system asks for reduced motion. Every token is listed on [Tokens](#/docs/tokens), and shown live, in the current theme, on the [Theming example](#/examples/theming). ## A brand in a few lines This site is themed exactly this way: a palette sampled from its night scene, layered over the package's tokens, dark by default. ```css brand.css :root, :root[data-theme="dark"] { color-scheme: dark; --v-bg: #0f1014; --v-fg: #e8eaf0; --v-accent: #fca942; --v-accent-fg: #0f1014; } :root[data-theme="light"] { color-scheme: light; --v-bg: #fbfaf7; --v-fg: #16171d; --v-accent: #a35500; } ``` ## Styling one block type Every block's host carries `data-type`, plus the attributes its module adds (a table's `data-header`, a callout's `data-tone`), so you can style a type without touching its module: ```css blocks.css [data-verbal] [data-type="quote"] { border-left-color: var(--v-accent); } [data-verbal] [data-type="heading"] h1 { letter-spacing: -0.03em; } [data-verbal][data-readonly] [data-type="code"] { box-shadow: none; } ``` `[data-readonly]` is set on the editor when it is created with `editable: false`. > [!TIP] > Keep contrast at 4.5:1 or better for text: `--v-fg-muted` and `--v-fg-faint` are used for secondary text, so check them against `--v-bg` and `--v-bg-soft`. # Clipboard > Copy writes exact blocks and Markdown; paste parses, never injects. Copying writes two formats at once: HTML that carries the exact blocks — types, props, marks, nesting — for pasting back into Verbal, and Markdown as plain text for everywhere else. Pasting reads, in order: 1. A module that claims the paste — image files become image blocks, a lone video link on an empty line becomes an embed, a link pasted over selected text links it, text pasted into a table cell stays in that cell. 2. Verbal's own HTML — pasted back exactly. 3. Any other HTML — read in an inert document through each module's parse rules (`

`, `
  • `, `
    `, `
    `, ``…). Scripts, styles, event handlers and unknown elements never reach the page; unknown elements degrade to paragraphs, and unsafe link targets are dropped.
    4. Plain text — read as Markdown.
    
    Inside a code block, a paste is always plain text.
    
    ## From code
    
    ```js clipboard.js
    import { Editor, caret } from 'verbal-editor';
    import preset from 'verbal-editor/preset';
    
    const editor = new Editor({ ...preset });
    const first = editor.getDoc().blocks.doc.children[0];
    editor.select(caret(first, 0));
    editor.paste({ text: '## Groceries\n- bread\n- [ ] milk' });
    
    editor.select({ blocks: editor.getDoc().blocks.doc.children });
    const copied = editor.copy(); // { html, text }
    console.log(copied?.text);
    ```
    
    ## Markdown in, Markdown out
    
    `fromMarkdown(text, editor.registry)` gives the block literals a paste would insert, and `write(blocks, editor.registry)` the `{ html, text }` a copy would produce — useful for importing notes or exporting a document:
    
    ```js markdown.js
    import { Editor, fromMarkdown, write } from 'verbal-editor';
    import preset from 'verbal-editor/preset';
    
    const editor = new Editor({ ...preset });
    const blocks = fromMarkdown('# Title\n\nSome **bold** text.', editor.registry);
    console.log(write(blocks, editor.registry).text);
    ```
    
    > [!NOTE]
    > Markdown tables paste as tables; tables inside pasted HTML become paragraphs. See [Limitations](#/docs/limitations).
    
    
    # Shortcuts
    
    > Every key and Markdown rule, generated from the modules this site registers.
    
    `Mod` is ⌘ on Apple devices and Ctrl elsewhere. These tables are generated from the registered modules, so they always match what the editor does.
    
    ## Everywhere
    
    | Keys | From |
    | --- | --- |
    | `⏎` | core |
    | `⇧⏎` | core |
    | `Backspace` | core |
    | `Delete` | core |
    | `Tab` | core |
    | `⇧Tab` | core |
    | `Escape` | core |
    | `⌘Z` | core |
    | `⌘⇧Z` | core |
    | `⌘Y` | core |
    | `⌘A` | core |
    | `⌘⌥0` | core |
    | `⌘⇧↑` | dnd |
    | `⌘⇧↓` | dnd |
    | `⌘⌥1` | [heading](#/docs/modules/blocks/heading) |
    | `⌘⌥2` | [heading](#/docs/modules/blocks/heading) |
    | `⌘⌥3` | [heading](#/docs/modules/blocks/heading) |
    | `⌘B` | [bold](#/docs/modules/marks/bold) |
    | `⌘I` | [italic](#/docs/modules/marks/italic) |
    | `⌘⇧S` | [strike](#/docs/modules/marks/strike) |
    | `⌘E` | [code](#/docs/modules/blocks/code) |
    | `⌘K` | [link](#/docs/modules/marks/link) |
    
    ## Inside a block
    
    Some keys mean something different depending on where the caret is — Tab moves between table cells, ⏎ keeps the indentation in code:
    
    | Keys | Inside |
    | --- | --- |
    | `⌘⏎` | [todo](#/docs/modules/blocks/todo) |
    | `⏎` | [code](#/docs/modules/blocks/code) |
    | `Tab` | [code](#/docs/modules/blocks/code) |
    | `⇧Tab` | [code](#/docs/modules/blocks/code) |
    | `⌘⏎` | [code](#/docs/modules/blocks/code) |
    | `Tab` | [table](#/docs/modules/blocks/table) |
    | `⇧Tab` | [table](#/docs/modules/blocks/table) |
    | `⏎` | [table](#/docs/modules/blocks/table) |
    | `↑` | [table](#/docs/modules/blocks/table) |
    | `↓` | [table](#/docs/modules/blocks/table) |
    | `Backspace` | [table](#/docs/modules/blocks/table) |
    | `Delete` | [table](#/docs/modules/blocks/table) |
    | `⌘⇧↑` | [table](#/docs/modules/blocks/table) |
    | `⌘⇧↓` | [table](#/docs/modules/blocks/table) |
    | `⏎` | [math](#/docs/modules/blocks/math) |
    
    ## As you type
    
    At the start of an empty paragraph, these turn it into another block:
    
    | Typed at the start | Becomes |
    | --- | --- |
    | `#␣` `##␣` `###␣` | [heading](#/docs/modules/blocks/heading) |
    | `[]␣` `[ ]␣` `[x]␣` | [todo](#/docs/modules/blocks/todo) |
    | `-␣` `*␣` `+␣` | [list](#/docs/modules/blocks/list) |
    | `1.␣` `1)␣` | [list](#/docs/modules/blocks/list) |
    | `>␣` `"␣` | [quote](#/docs/modules/blocks/quote) |
    | `---` | [divider](#/docs/modules/blocks/divider) |
    | \`\`\`js␣ \`\`\`␣ | [code](#/docs/modules/blocks/code) |
    | `$$␣` | [math](#/docs/modules/blocks/math) |
    
    Inside text, a closed pair of delimiters becomes a mark the moment you type the closing one:
    
    | Typed | Becomes |
    | --- | --- |
    | `$text$` | [math](#/docs/modules/blocks/math) |
    | `**text**` | [bold](#/docs/modules/marks/bold) |
    | `_text_ or *text*` | [italic](#/docs/modules/marks/italic) |
    | `~~text~~` | [strike](#/docs/modules/marks/strike) |
    | \`text\` | [code](#/docs/modules/blocks/code) |
    
    One undo turns any of these conversions back into the characters you typed.
    
    ## Selecting blocks
    
    - Esc selects the block the caret is in; ⇧↑ and ⇧↓ grow or shrink the selection; ↑ and ↓ move it.
    - ⌘A selects the block's text, then every block.
    - Drag across text, or ⇧-click, to select whole blocks.
    - With blocks selected: Backspace deletes them, Tab and ⇧Tab nest them, ⌘⇧↑ and ⌘⇧↓ move them, and the ⌘⌥ shortcuts turn them into another type — each as one undo step.
    
    ## Your own keys
    
    ```js save-key.js
    import { Editor } from 'verbal-editor';
    
    const editor = new Editor();
    editor.on('key', ({ name }) => {
      if (name !== 'Mod-s') return false;
      localStorage.setItem('note', JSON.stringify(editor.getDoc()));
      return true; // claimed: nothing else handles it
    });
    ```
    
    
    # Mobile & touch
    
    > Container-first layout, finger-sized targets, and menus that stay clear of the on-screen keyboard.
    
    Verbal is built for fingers as well as mice.
    
    - **Container-first.** The editor lays itself out by its own width, not the window's: columns stack below 640 px of editor, so an editor in a narrow side panel on a desktop behaves like one on a phone.
    - **Finger-sized targets.** On touch screens every control — the drag handle, to-do boxes, table and code-block controls, suggestion buttons, toolbar buttons — has at least a 44 × 44 px target, without changing how it looks.
    - **Nothing behind hover.** Controls that appear on hover with a mouse are always visible on touch.
    - **No zoom on focus.** Every field Verbal renders uses at least 16 px text on touch, so iOS never zooms the page.
    - **Moving blocks.** Tap a block to show its handle; drag the handle to move it (or the whole selection); tap the handle to select the block.
    - **The on-screen keyboard.** The slash menu, emoji picker, selection toolbar and equation editor stay inside the visible part of the screen; the menus and the toolbar flip above the caret when the keyboard leaves no room below, and follow it as it opens and closes.
    - **The native callout.** On touch the selection toolbar opens below the selection, clear of the system's copy and paste menu.
    
    ## In your app
    
    ```html index.html
    
    ```
    
    `interactive-widget=resizes-content` makes Chrome on Android shrink the page when the keyboard opens, so a full-height layout keeps the caret visible. If your layout reaches the screen edges, pad it with `env(safe-area-inset-*)` so nothing sits under a notch.
    
    ```css layout.css
    .page {
      padding: 16px max(16px, env(safe-area-inset-right)) 16px max(16px, env(safe-area-inset-left));
    }
    ```
    
    > [!NOTE]
    > Every release is tested on a touch Pixel 7 (Chromium) and iPhone 13 (WebKit) profile.
    
    
    # Accessibility
    
    > Keyboard first, native semantics where they exist, and an honest list of the gaps.
    
    - **Keyboard first.** Everything has a key: formatting, block types, moving blocks (⌘⇧↑ / ⌘⇧↓), selecting blocks, table navigation, the slash menu, the emoji picker, accepting or rejecting AI suggestions. See [Shortcuts](#/docs/shortcuts).
    - **Native semantics.** Headings are `

    `–`

    `, quotes `
    `, code `
    `, links ``, images ``. Equations are MathML, which screen readers can read.
    - **Roles and names.** The slash and emoji menus are listboxes with options, the toolbars are toolbars, to-do boxes are checkboxes with their state, and icon buttons carry their full name ("Link ⌘K", "Drag to move").
    - **Motion.** All animation stops under `prefers-reduced-motion`.
    - **Colour.** Every colour is a token, so a high-contrast theme is a few overrides — see [Theming](#/docs/theming).
    
    ## Checking your setup
    
    ```js audit.js
    import { Editor } from 'verbal-editor';
    import preset from 'verbal-editor/preset';
    
    // Every slash entry has a label a screen reader can announce.
    const editor = new Editor({ ...preset });
    console.log(editor.registry.slash.every((entry) => entry.label.trim().length > 0));
    ```
    
    ## Known gaps
    
    - List items are styled with CSS counters, not `
      `/`
        `, so screen readers do not announce them as lists. - Tables are grids of editable cells without table semantics. - Moving a block is not announced to screen readers. - Table columns can be resized with a pointer only. - The slash menu shows its highlighted option visually; it does not move screen-reader focus into the list. # Imports > Every entry the package exports, what it contains, and what it weighs. Each subpath is its own entry: importing one pulls in only what it needs, and every module imports its own CSS, so a bundler never includes styles for modules you do not use. Sizes are gzip, measured from the built package; an entry's size includes everything it imports. | Import | Exports | JS gzip | CSS gzip | | --- | --- | --- | --- | | `verbal-editor` | `Editor` `parse` `serialize` `write` `fromMarkdown` `tx` `invert` `caret` | 11.76 KB | 0.46 KB | | `verbal-editor/react` | `useEditor` `useBlock` `Blocks` | 12.19 KB | 0.46 KB | | `verbal-editor/preset` | `default` `blocks` `marks` `ui` `diffWords` `hunks` `review` | 19.30 KB | 3.83 KB | | `verbal-editor/blocks/heading` | `default` | 0.56 KB | 0.16 KB | | `verbal-editor/blocks/todo` | `default` | 0.79 KB | 0.55 KB | | `verbal-editor/blocks/list` | `default` | 0.52 KB | 0.29 KB | | `verbal-editor/blocks/quote` | `default` | 0.33 KB | 0.14 KB | | `verbal-editor/blocks/divider` | `default` | 0.29 KB | 0.12 KB | | `verbal-editor/blocks/code` | `default` | 2.63 KB | 0.51 KB | | `verbal-editor/blocks/table` | `default` `addRow` `addColumn` `removeRow` `removeColumn` | 2.47 KB | 0.69 KB | | `verbal-editor/blocks/chart` | `default` | 1.91 KB | 0.65 KB | | `verbal-editor/blocks/math` | `default` | 4.83 KB | 0.62 KB | | `verbal-editor/blocks/columns` | `default` | 0.63 KB | 0.25 KB | | `verbal-editor/blocks/image` | `default` | 0.93 KB | 0.17 KB | | `verbal-editor/blocks/embed` | `default` | 1.27 KB | 0.51 KB | | `verbal-editor/marks/bold` | `default` | 0.24 KB | — | | `verbal-editor/marks/italic` | `default` | 0.22 KB | — | | `verbal-editor/marks/strike` | `default` | 0.25 KB | — | | `verbal-editor/marks/code` | `default` | 0.17 KB | — | | `verbal-editor/marks/link` | `default` | 0.67 KB | — | | `verbal-editor/ui/slash` | `default` `menu` | 1.36 KB | 0.49 KB | | `verbal-editor/ui/toolbar` | `default` | 1.29 KB | 0.71 KB | | `verbal-editor/ui/dnd` | `default` | 1.84 KB | 0.47 KB | | `verbal-editor/ui/emoji` | `default` | 1.55 KB | 0.49 KB | | `verbal-editor/ai/diff` | `diffWords` `hunks` | 0.65 KB | — | | `verbal-editor/ai/pending` | `review` | 2.11 KB | 0.64 KB | | `verbal-editor/config` | `default` `defineConfig` `available` | build time | — | | `verbal-editor/server` | `validateDoc` | build time | — | | `verbal-editor/tokens.css` | every custom property | — | 1.03 KB | `/config` and `/server` run at build time and on your server — they import Joi and never reach the browser. ## One module, one import ```js imports.js import { Editor } from 'verbal-editor'; import { Blocks, useEditor } from 'verbal-editor/react'; import table, { addRow } from 'verbal-editor/blocks/table'; import { review } from 'verbal-editor/ai/pending'; console.log(typeof Editor, typeof Blocks, typeof useEditor, table.type, typeof addRow, typeof review); ``` > [!NOTE] > Types for every entry ship alongside it: each `exports` entry has a `types` condition pointing at declarations generated from the source's JSDoc. # Editor API > The Editor class — options, events and every public member, generated from its JSDoc. The editor: a document, its history, and a view that owns each block's content element. Construct it with the modules you use, render it with a binding (`` from `verbal-editor/react`), and read or change the document through the methods below. ```js const editor = new Editor({ blocks: [heading], marks: [bold], ui: [slash] }); editor.on('change', () => save(editor.getDoc())); ``` ## Options Editor options. `editable: false` renders the document read-only: text can be selected and copied, nothing changes it (every transaction is ignored) and modules hide their controls. Anything else beyond the modules and the document is read by modules or bindings: `upload(file) → Promise` (image), `unfurl(url) → Promise<{ title }>` (embed), `onRender(id)` (React binding: called on every block render). | Option | Type | | --- | --- | | `blocks?` | `BlockModule[]` | | `marks?` | `MarkModule[]` | | `ui?` | `UiModule[]` | | `doc?` | `Doc \| string` | | `editable?` | `boolean` | | `upload?` | `(file: File) => Promise` | | `unfurl?` | `(url: string) => Promise<{ title?: string } \| null>` | | `onRender?` | `(id: string) => void` | ## Events Events: 'change' ({ ops, origin }) after every transaction; 'selectionchange' (Selection | null); 'input' ({ id, data }) after typed text, before Markdown rules — return true to skip them; 'key' ({ name, e }) before any key is handled — return true to claim it. ## Members ### `new Editor()` @param {EditorOptions} [options] ### `editor.options` Options for modules and bindings (everything except blocks, marks, ui and doc). ### `editor.ui` @type {UiModule[]} ### `editor.selection` The current selection: text points, whole blocks, or null. ### `editor.store` Per-block versions that bindings subscribe to; bumped by structural edits, never by typing. ### `editor.history` Undo/redo stacks. ### `editor.registry` Every registered block type, mark, slash entry, shortcut and typing rule. ### `editor.view` The DOM side: content elements, selection mapping, events. For modules and bindings. ### `editor.doc` The live document. Treat it as read-only; change it with transactions. ### `editor.setDoc(doc)` Replaces the document; history starts over and the selection clears. | Parameter | Type | Description | | --- | --- | --- | | `doc` | `Doc \| string` | a payload from getDoc() (or its JSON) | ### `editor.getDoc()` A deep copy of the document payload, blocks in document order (HLD §3.3). Returns `Doc`. ### `editor.get(id)` A block by id (live — do not mutate). | Parameter | Type | Description | | --- | --- | --- | | `id` | `string` | — | Returns `Block | undefined`. ### `editor.parent(id)` The id of a block's parent. | Parameter | Type | Description | | --- | --- | --- | | `id` | `string` | — | Returns `string | undefined`. ### `editor.span(a, b)` The outermost blocks from `a` to `b` inclusive, in document order. | Parameter | Type | Description | | --- | --- | --- | | `a` | `string` | — | | `b` | `string` | — | Returns `string[]`. ### `editor.tx()` A new transaction on the current document; pass it to dispatch(). Returns `Tx`. ### `editor.mod(id)` The module of a block's type. | Parameter | Type | Description | | --- | --- | --- | | `id` | `string` | — | Returns `BlockModule | undefined`. ### `editor.len(id)` Length of a block's text. | Parameter | Type | Description | | --- | --- | --- | | `id` | `string` | — | Returns `number`. ### `editor.targets(s?)` What a block command acts on: the selected blocks, or every block the text selection touches. | Parameter | Type | Description | | --- | --- | --- | | `s?` | `Selection \| null` | — | Returns `string[]`. ### `editor.range` The selection when it lies inside one block, as { block, from, to }; null otherwise. Returns `Range | null`. ### `editor.on(type, fn)` Listens for an editor event; returns the function that stops listening. | Parameter | Type | Description | | --- | --- | --- | | `type` | `EditorEvent` | — | | `fn` | `(e: any, editor: Editor) => boolean \| void` | — | Returns `() => void`. ### `editor.emit(type, e?)` Calls listeners in order; true when one of them handled the event. | Parameter | Type | Description | | --- | --- | --- | | `type` | `EditorEvent` | — | | `e?` | `any` | — | Returns `boolean`. ### `editor.mount(root)` Attaches the view to its root element; every module with a mount(editor) hook starts. Bindings call this. | Parameter | Type | Description | | --- | --- | --- | | `root` | `HTMLElement` | — | ### `editor.unmount()` Detaches the view and runs every module's cleanup. ### `editor.dispatch(t, options?)` Applies a transaction: the model, then text repaints, then one version bump per structurally changed block (so only those re-render), then history and the 'change' event. A transaction that throws part-way is rolled back; a read-only editor (`editable: false`) ignores it. | Parameter | Type | Description | | --- | --- | --- | | `t` | `Tx \| object[]` | a transaction from tx(), or its ops | | `options?` | `{ selection?: Selection \| null, origin?: 'input' \| 'history' \| string, kind?: string, before?: Selection \| null }` | `selection` to select afterwards (undo restores `before`); `kind: 'text'` coalesces with adjacent typing | ### `editor.undo()` Undoes the last step, restoring the model and the selection. Returns `true`. ### `editor.redo()` Redoes the last undone step. Returns `true`. ### `editor.select(sel)` Selects text points or whole blocks, in the model and on screen. | Parameter | Type | Description | | --- | --- | --- | | `sel` | `Selection \| null` | — | ### `editor.key(name, e?)` Runs a key as if pressed: UI interceptors, then scoped module keys (block → ancestors), shortcuts, core defaults. Names look like 'Enter', 'Shift-Tab', 'Mod-b' (Mod is ⌘ on Apple, Ctrl elsewhere). | Parameter | Type | Description | | --- | --- | --- | | `name` | `string` | — | | `e?` | `any` | — | Returns `boolean` — whether something handled it. ### `editor.container(id)` Nearest ancestor that holds blocks but no text (doc, column, table…). | Parameter | Type | Description | | --- | --- | --- | | `id` | `string` | — | Returns `string | undefined`. ### `editor.split()` Enter: splits the focused block at the caret (the new block's type comes from the module's `next`). Returns `boolean`. ### `editor.join(dir)` Backspace at a block's start (dir -1) or Delete at its end (dir 1): drop a void neighbour or merge. | Parameter | Type | Description | | --- | --- | --- | | `dir` | `-1 \| 1` | — | Returns `boolean`. ### `editor.indent(ids)` Tab: nests each block under the one above it — one transaction for any number of blocks. | Parameter | Type | Description | | --- | --- | --- | | `ids` | `...string` | default: targets() | Returns `boolean`. ### `editor.outdent(ids)` Shift-Tab: each block follows its parent out; the last goes first so they keep their order. | Parameter | Type | Description | | --- | --- | --- | | `ids` | `...string` | default: targets() | Returns `boolean`. ### `editor.replace(range, str)` Replaces a range inside one block with plain text, keeping the marks at its start. | Parameter | Type | Description | | --- | --- | --- | | `range` | `Range` | — | | `str` | `string` | — | Returns `true`. ### `editor.remove(ids)` Removes whole blocks; the caret lands on the nearest remaining text block. | Parameter | Type | Description | | --- | --- | --- | | `ids` | `string[]` | — | Returns `true`. ### `editor.setType(type, props?, ids)` Turns text blocks into `type`, keeping their text — one transaction. | Parameter | Type | Description | | --- | --- | --- | | `type` | `string` | — | | `props?` | `Record` | — | | `ids` | `...string` | default: targets() | Returns `boolean`. ### `editor.insert(type, props?, t?)` Converts the focused empty paragraph into `type`, or inserts `type` after the focused block (after the whole grid when the caret is in a table cell); the caret lands where you can type next. | Parameter | Type | Description | | --- | --- | --- | | `type` | `string` | — | | `props?` | `Record` | — | | `t?` | `Tx` | a transaction to add to | Returns `string` — the new block's id. ### `editor.toggleMark(type, attrs?, force?)` Toggles (or with `force`, sets) a mark over the selection, or as a stored mark for the next typed text. | Parameter | Type | Description | | --- | --- | --- | | `type` | `string` | — | | `attrs?` | `Record` | e.g. { href } | | `force?` | `boolean` | — | Returns `boolean`. ### `editor.active(type)` Whether every selected character carries `type` (or the stored marks do, for a caret). | Parameter | Type | Description | | --- | --- | --- | | `type` | `string` | — | Returns `boolean`. ### `editor.copy(cut?)` Clipboard payload for the selection: HTML carrying the exact blocks, Markdown as plain text. | Parameter | Type | Description | | --- | --- | --- | | `cut?` | `boolean` | also remove the selection | Returns `{ html: string, text: string } | null`. ### `editor.paste(data)` Pastes at the selection: a module may claim it (files, table cells); otherwise HTML or Markdown is parsed into blocks, never injected. | Parameter | Type | Description | | --- | --- | --- | | `data` | `{ html?: string, text?: string, files?: File[] }` | — | Returns `true`. ### `editor.edit(id)` Puts the caret at the end of a text block. | Parameter | Type | Description | | --- | --- | --- | | `id` | `string` | — | Returns `true`. ### `editor.selectAll()` ⌘A: the block's text first, then every top-level block. Returns `boolean`. # Modules > Every block, mark, UI and AI module, with what it adds and what it weighs. Each module below has its own page, generated from the module itself: the text is its JSDoc, the contract is read off the module object, the size is measured from the built package, and the acceptance criteria are the rows of the feature table it cites. None of it can drift from the code. | Module | What it adds | JS gzip | CSS gzip | | --- | --- | --- | --- | | [blocks/paragraph](#/docs/modules/blocks/paragraph) | Paragraph — the one block type core ships with; every other type is a module. | core | — | | [blocks/heading](#/docs/modules/blocks/heading) | Heading 1–3 — `level` prop; the element is recreated only when the level changes. | 0.56 KB | 0.16 KB | | [blocks/todo](#/docs/modules/blocks/todo) | To-do — `checked` prop. The checkbox sits outside the editable region, so toggling it never moves the caret. | 0.79 KB | 0.55 KB | | [blocks/list](#/docs/modules/blocks/list) | List item — bullets and numbers are one module: `ordered` switches a CSS counter, so numbering stays right after any reorder. | 0.52 KB | 0.29 KB | | [blocks/quote](#/docs/modules/blocks/quote) | Quote — `
        `; `> ` input rule. Nested blocks sit inside its border. | 0.33 KB | 0.14 KB | | [blocks/divider](#/docs/modules/blocks/divider) | Divider — a void `
        `, never editable; `---` input rule. | 0.29 KB | 0.12 KB | | [blocks/code](#/docs/modules/blocks/code) | Code block — plain text in a `
        `; colours are painted with the CSS Custom Highlight API, so the editable region holds nothing but text and the caret behaves exactly as in plain text. | 2.23 KB | 0.51 KB |
        | [blocks/table](#/docs/modules/blocks/table) | Table — a grid of per-cell editable blocks: the table's children are paragraphs laid out row by row, `cols` wide, on a CSS grid. | 2.47 KB | 0.69 KB |
        | [blocks/chart](#/docs/modules/blocks/chart) | Chart — hand-written SVG line, bar, area and pie charts drawn from a table block: the first row names the series, the first column labels the points, the other cells are numbers. | 1.91 KB | 0.65 KB |
        | [blocks/math](#/docs/modules/blocks/math) | Equations — LaTeX compiled to MathML that the browser renders itself: no JS renderer, no fonts. | 4.83 KB | 0.62 KB |
        | [blocks/columns](#/docs/modules/blocks/columns) | Columns — a container of columns on a CSS grid; each column holds ordinary blocks, and blocks drag between columns like anywhere else (an empty column still takes a drop). | 0.63 KB | 0.25 KB |
        | [blocks/image](#/docs/modules/blocks/image) | Image — an `` block. Paste or drop image files, or choose them from "/image". | 0.93 KB | 0.17 KB |
        | [blocks/embed](#/docs/modules/blocks/embed) | Embed — allow-listed providers (YouTube, Vimeo, Loom, CodePen, Spotify) play in a sandboxed `