`, ``, ``…). 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 `