noteloom 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,414 +1,639 @@
1
- # noteloom
2
-
3
- [![version](https://img.shields.io/npm/v/noteloom.svg?label=version&color=3178c6)](https://www.npmjs.com/package/noteloom)
4
- [![downloads](https://img.shields.io/npm/dm/noteloom.svg?label=downloads&color=44cc11)](https://www.npmjs.com/package/noteloom)
5
- [![license](https://img.shields.io/npm/l/noteloom.svg?label=license&color=44cc11)](https://github.com/vishwakarmanikhil/noteloom/blob/master/LICENSE)
6
- [![Sponsor](https://img.shields.io/badge/Sponsor-%E2%9D%A4-333?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/vishwakarmanikhil)
7
-
8
- **[Live site & docs →](https://noteloom.qusere.in)** · **[Play with the demo →](https://noteloom.qusere.in/playground/)**
9
-
10
- A React-first, block-based rich text editor with **zero runtime dependencies** — the only things it expects from your app are `react` and `react-dom`. Everything else (undo/redo, clipboard, slash commands, tables, inline widgets) is built from scratch on top of a small normalized document store.
11
-
12
- ## Why this exists
13
-
14
- Most rich-text editors either bring their own large dependency tree, or force every "special" piece of content (a dropdown, a date, a mention) onto its own line. This one is built around two ideas:
15
-
16
- - **Inline heterogeneous content is a first-class citizen.** A `select` dropdown, a date picker, or an `@mention` chip can sit in the middle of a sentence, mixed with regular text, in one paragraph — not forced onto a block of its own.
17
- - **Fine-grained React re-rendering, no virtual-DOM-for-content-editable fights.** Every block subscribes only to its own data via `useSyncExternalStore`; editing one paragraph in a 500-block document doesn't re-render anything else (see `test/performance/largeDocument.test.jsx` for the regression guard on this).
18
-
19
- ## Install
20
-
21
- ```bash
22
- npm install noteloom react react-dom
23
- ```
24
-
25
- ## Quick start
26
-
27
- ```jsx
28
- import {
29
- EditorStore,
30
- History,
31
- EditorProvider,
32
- BlockChildren,
33
- createBlockRegistry,
34
- registerBuiltInBlocks,
35
- createInlineRegistry,
36
- registerBuiltInInlineTypes,
37
- useClipboardHandlers,
38
- useSlashMenuTrigger,
39
- useEditorKeyboardShortcuts,
40
- SlashMenu,
41
- } from 'noteloom';
42
- import { useMemo, useRef } from 'react';
43
-
44
- function Editor() {
45
- const containerRef = useRef(null);
46
- const { store, registry, inlineRegistry } = useMemo(() => {
47
- const registry = createBlockRegistry();
48
- registerBuiltInBlocks(registry);
49
- const inlineRegistry = createInlineRegistry();
50
- registerBuiltInInlineTypes(inlineRegistry);
51
- const store = new History(
52
- new EditorStore({
53
- rootId: 'root',
54
- blocks: [
55
- { id: 'root', type: 'page', parentId: null, contentIds: ['p1'], props: {} },
56
- { id: 'p1', type: 'paragraph', parentId: 'root', contentIds: ['r1'], props: {} },
57
- ],
58
- runs: [{ id: 'r1', type: 'text', value: 'Hello try typing "/" for commands.', marks: {} }],
59
- }),
60
- );
61
- return { store, registry, inlineRegistry };
62
- }, []);
63
-
64
- const { onCopy, onCut, onPaste } = useClipboardHandlers();
65
- const slashMenu = useSlashMenuTrigger(containerRef);
66
- useEditorKeyboardShortcuts(containerRef);
67
-
68
- return (
69
- <EditorProvider store={store} registry={registry} inlineRegistry={inlineRegistry} history={store}>
70
- <div ref={containerRef} onCopy={onCopy} onCut={onCut} onPaste={onPaste}>
71
- <BlockChildren parentId="root" />
72
- <SlashMenu
73
- isOpen={slashMenu.isOpen}
74
- rect={slashMenu.rect}
75
- commands={slashMenu.commands}
76
- runId={slashMenu.runId}
77
- onSelect={slashMenu.selectCommand}
78
- onClose={slashMenu.close}
79
- />
80
- </div>
81
- </EditorProvider>
82
- );
83
- }
84
- ```
85
-
86
- See `examples/basic` for a complete working app (run `npm run dev`).
87
-
88
- ### Styling — zero setup required
89
-
90
- You don't need to import any CSS. The moment `<EditorProvider>` mounts, it injects a single `<style>` tag with a minimal, clean default theme — no `import 'noteloom/style.css'` line, no build-tool CSS configuration, nothing to wire up. It's idempotent (mounting more than one editor on a page only injects it once) and client-only (a no-op under SSR; hydrate as normal and it injects on mount).
91
-
92
- **Retheme it** by overriding the CSS custom properties it reads from — defined on `:root` (not scoped to a wrapper element, since portaled pieces like the slash menu and Select's popover aren't DOM descendants of the editor itself):
93
-
94
- ```css
95
- :root {
96
- --noteloom-accent: #16a34a; /* swap the indigo accent for green */
97
- --noteloom-radius-md: 4px; /* sharper corners */
98
- --noteloom-font: 'Inter', sans-serif;
99
- }
100
- ```
101
-
102
- Dark mode follows `prefers-color-scheme` automatically; to control it explicitly instead (e.g. a manual light/dark toggle), set `data-theme="dark"` or `data-theme="light"` on any ancestor (typically `<html>`) — see the full variable list in `src/style.css`.
103
-
104
- **Scope overrides to one editor instance**, or add your own class for full custom CSS, via `<EditorProvider>`'s `className`/`style` props — passing either wraps `children` in one `<div className="be-root ...">`:
105
-
106
- ```jsx
107
- <EditorProvider store={store} registry={registry} className="my-editor" style={{ '--noteloom-accent': '#16a34a' }}>
108
- ...
109
- </EditorProvider>
110
- ```
111
-
112
- No wrapper `<div>` is added unless you pass one of these props, so existing usage is unaffected either way.
113
-
114
- **Opt out entirely** with `theme="none"` — nothing gets injected, and you take full responsibility for styling every `.be-*` class yourself (or import `noteloom/style.css` manually if you just want control over *when* it loads, e.g. before your own overrides in a specific `<link>` order):
115
-
116
- ```jsx
117
- <EditorProvider store={store} registry={registry} theme="none">
118
- ```
119
-
120
- `examples/basic/src/style.css` shows the extra page-level chrome (fonts, page width, the demo's own toolbar buttons) a host app typically adds around the editor — none of that is part of the default theme itself.
121
-
122
- **Customize individual blocks**, not just the root, via `getBlockClassName`:
123
-
124
- ```jsx
125
- <EditorProvider
126
- store={store}
127
- registry={registry}
128
- getBlockClassName={(block) => (block.type === 'callout' ? 'my-callout' : undefined)}
129
- >
130
- ```
131
-
132
- Whatever string you return is appended onto that block's own root element's class list (`be-paragraph my-callout`, alongside the fixed base class) — `block` is the real block object (`type`, `id`, `props`), so you can target a type, a specific id, or a prop value (e.g. every red callout) as precisely as you like.
133
-
134
- ## Exporting the document (JSON / HTML / plain text)
135
-
136
- ```js
137
- import { exportDocumentJSON, exportDocumentHTML, exportDocumentText } from 'noteloom';
138
-
139
- exportDocumentJSON(store); // { rootId, blocks, runs } — feed straight back into `new EditorStore(...)`
140
- exportDocumentHTML(store, registry, inlineRegistry);
141
- exportDocumentText(store, registry, inlineRegistry);
142
- ```
143
-
144
- Or mount the ready-made button + modal instead of wiring your own UI:
145
-
146
- ```jsx
147
- import { DocumentExportButton } from 'noteloom';
148
-
149
- <DocumentExportButton label="View source" />
150
- ```
151
-
152
- It opens a modal with JSON/Simple JSON/HTML/Text tabs (reading live from the store every time it opens) and a Copy button — useful for debugging, or as a starting point for a real "export" feature.
153
-
154
- ## A simpler JSON shape for storage/API/CRUD use
155
-
156
- `exportDocumentJSON()` above returns the *internal engine format* — the same normalized, id-referenced graph `EditorStore` operates on (blocks reference other blocks by id; text lives in a separate `runs` collection, not embedded inline). That shape is what makes per-run reactivity, O(1) structural edits, and real nesting (toggle lists, tables, inline atomic chips) work — it's not going to look like a simple flat document, on purpose.
157
-
158
- If you just want something simpler to store, send over an API, or hand-edit — self-contained blocks in an array, `children` for nesting, no id-references to resolve — use the second, optional export/import pair instead:
159
-
160
- ```js
161
- import { exportDocumentSimpleJSON, importDocumentSimpleJSON } from 'noteloom';
162
-
163
- const json = exportDocumentSimpleJSON(store, registry, inlineRegistry);
164
- // {
165
- // "version": 1,
166
- // "blocks": [
167
- // { "id": "p1", "type": "paragraph", "data": { "text": "Hello <strong>world</strong>" } },
168
- // { "id": "h1", "type": "heading", "data": { "text": "Key features", "level": 3 } },
169
- // {
170
- // "id": "li1", "type": "listItem",
171
- // "data": { "text": "Nested item", "ordered": false, "checked": null },
172
- // "children": [ /* nested listItem blocks, same shape */ ]
173
- // },
174
- // {
175
- // "id": "t1", "type": "table",
176
- // "data": { "columns": [{ "id": "c1", "label": "Name" }], "rows": [["Cell text"]] }
177
- // }
178
- // ]
179
- // }
180
-
181
- // ...later, or on a different machine/process:
182
- const doc = importDocumentSimpleJSON(json, registry, inlineRegistry); // -> { rootId, blocks, runs }
183
- const store2 = new EditorStore(doc);
184
- ```
185
-
186
- Rich text (`data.text`) is an HTML string — the exact same per-run serialization every block type's own clipboard-copy `toHTML` already produces, so marks (bold/italic/underline/strike/code/sub/superscript/color/highlight/link) and atomic inline chips (checkbox/date/select/mention) round-trip through it the same way copy/paste already does. `table` is flattened specially (`data.columns` + `data.rows`, a 2D array) rather than exposing the internal table/row/cell block chain — the single biggest simplification versus the internal shape. Block/run ids are preserved on both export and import (useful for referencing/updating a specific block from an external system).
187
-
188
- One existing, by-design limitation carried over from clipboard paste: an atomic inline type's *core* value round-trips (a checkbox's checked state + label, a date's ISO value, a select's chosen value + label) but its full `options` list does not — only the currently-selected option survives, the same as pasting one of these chips into another instance of the editor today.
189
-
190
- This is purely an additive, alternate *interchange* format — the internal engine format above is unaffected either way, and this is not a replacement for it.
191
-
192
- ## Right-to-left / multi-language text
193
-
194
- Every block defaults to `dir="auto"` — the browser's own Unicode bidi algorithm detects direction per block from its first strong character, so a document mixing LTR and RTL blocks (an English heading over an Arabic paragraph, say) just works with zero configuration. For the cases `auto` can't infer on its own (most commonly an empty block, which has no text yet to detect a direction from), set an explicit override:
195
-
196
- ```js
197
- import { updateBlockProps } from 'noteloom';
198
-
199
- // Document-wide default:
200
- store.applyOperation(updateBlockProps(store.getRootId(), { dir: 'rtl' }));
201
- // Or just one block:
202
- store.applyOperation(updateBlockProps(blockId, { dir: 'rtl' }));
203
- ```
204
-
205
- A block's own `dir` wins over the document's; the block gutter menu also has a "Switch to right-to-left"/"left-to-right" item that sets this per-block. Code blocks are always `dir="ltr"` regardless of the surrounding document's default — code syntax (brackets, operators) is structurally LTR no matter what language a comment or string literal happens to be written in.
206
-
207
- This pass covers the reading/typing/gutter-position direction itself; a full logical-properties (`margin-inline-start` etc.) audit of every pixel value in `style.css` is deliberately out of scope for now — the highest-impact pieces (list/checkbox marker position, blockquote border side, block gutter position) already flip correctly.
208
-
209
- ## Printing & PDF
210
-
211
- `style.css` includes a built-in `@media print` stylesheet: every piece of editing chrome (the block gutter, all portaled menus, the floating toolbar, resize handles, the mobile action bar, etc.) is hidden automatically, and a block hidden via "Hide in preview" stays hidden in the printout too, regardless of whether the app happens to be toggled into preview mode at the moment you print — printing always behaves like preview mode.
212
-
213
- There's no bundled PDF-generation library (that would need a real dependency like jsPDF/pdfmake, conflicting with staying zero-runtime-dependency) — the browser's own print-to-PDF is the intended path:
214
-
215
- ```js
216
- window.print(); // Ctrl+P / Cmd+P works too — "Save as PDF" in the print dialog is your PDF export
217
- ```
218
-
219
- This only cleans up the *editor's* own chrome. A host app's own outer UI (nav bar, sidebar, its own toolbar) needs its own `@media print` rules the same way — see `examples/basic/src/style.css` for a worked example, since that chrome lives entirely outside this package.
220
-
221
- ## Voice typing
222
-
223
- `useVoiceTyping()` wraps the browser's native Web Speech API (`SpeechRecognition`) for continuous dictation mixed with spoken structural commands say "heading one", "new paragraph", "bulleted list", "quote", "undo", etc. while dictating, and the current block converts (or a new one is inserted) instead of those words being typed as text:
224
-
225
- ```jsx
226
- import { useVoiceTyping } from 'noteloom';
227
-
228
- function MicButton() {
229
- const voice = useVoiceTyping();
230
- if (!voice.isSupported) return null; // e.g. Firefox — no bundled fallback, degrades to nothing
231
- return (
232
- <button onClick={() => (voice.isListening ? voice.stop() : voice.start())}>
233
- {voice.isListening ? 'Stop dictation' : 'Start dictation'}
234
- </button>
235
- );
236
- }
237
- ```
238
-
239
- No speech-to-text SDK is bundled (same zero-runtime-dependency reasoning as PDF export above) this is built entirely on the browser's own `SpeechRecognition`/`webkitSpeechRecognition`, so `isSupported` is `false` wherever that API doesn't exist. A command is only recognized when an entire *finalized* spoken utterance (a natural pause before/after, as reported by the Speech API itself) matches a known phrase exactly — see `src/voice/voiceCommands.js` for the full table so a command word merely mentioned mid-sentence while dictating prose is never misread as a command.
240
-
241
- ## Mobile / touch support
242
-
243
- Typing "/"/"@" still works on a phone keyboard, but it's not a reliable or discoverable primary path there (autocorrect, awkward key access, nothing to discover it by) — so on a coarse (touch) pointer, mount `MobileActionBar` alongside your other trigger hooks and it takes over as the touch-first equivalent, pinned above the on-screen keyboard:
244
-
245
- ```jsx
246
- import { MobileActionBar } from 'noteloom';
247
-
248
- // next to your other trigger hooks/components, same containerRef:
249
- <MobileActionBar containerRef={containerRef} />
250
- ```
251
-
252
- It renders nothing on a mouse/trackpad, and nothing until focus is actually inside the editor. Its contents swap based on context:
253
-
254
- - **Block options** (shown whenever the caret/selection is inside any block) → Duplicate/Move up/Move down/Hide-Show/Delete, in `MobileBlockOptionsSheet` — the mobile home for the desktop per-block gutter's own grip-handle menu. The gutter itself is hidden entirely on touch input (no hover state exists to reveal it by, and its desktop position sits in a page margin that doesn't exist on a narrow viewport), so both of its actions ("+" and the options menu) live in this bar instead of the gutter on touch.
255
- - **Text selected** → formatting actions (bold/italic/underline/link) — the desktop `FloatingToolbar` bubble also disables itself on touch, so this is the single formatting surface either way (both share the same `useTextFormattingActions` hook, not two copies).
256
- - **Collapsed caret, table cell** → insert row/column.
257
- - **Collapsed caret, code block** → language picker.
258
- - **Collapsed caret, callout** → color picker.
259
- - **Collapsed caret, everywhere else** "+" (opens `MobileBlockPickerSheet`, a tap-friendly bottom sheet listing every insertable block, same commands "/" already offers), Undo/Redo, dismiss-keyboard.
260
-
261
- Trigger-menu and `Select` popovers reposition above the caret instead of below it when there isn't room before the keyboard, via `useVirtualKeyboardInset()` (also exported, in case you're positioning your own UI against the keyboard).
262
-
263
- **Touch detection deliberately isn't a static `matchMedia('(pointer: coarse)')` check** (see `useCoarsePointer`, also exported) — a touchscreen laptop reports its trackpad as the "primary" pointer even though the touchscreen sitting right there can be used at any moment, so a pure media-query check would never show touch UI on that class of device. Instead, the media query only supplies the *initial* guess (correct pre-interaction, SSR-safe); every real `pointerdown` afterward overrides it with that event's own `pointerType`, so a 2-in-1 laptop correctly shows desktop UI while the trackpad is in use and mobile UI the instant the screen is tapped, live, no reload needed. The same signal is mirrored onto `<html class="be-touch-input">` so plain CSS (the gutter-hiding rule above) reacts to it too, not just `MobileActionBar` itself.
264
-
265
- **Not included**: a touch equivalent for dragging in the block gutter to select a range of blocks Notion, TipTap, and Editor.js all keep that gesture desktop/mouse-only too.
266
-
267
- ## Built-in block types
268
-
269
- `paragraph`, `heading` (h1–h3), `listItem` (bulleted, numbered, and to-do — with Notion-style Tab/Shift+Tab nesting and Enter conventions), `table` (with row/column insert/delete), `layout` (multi-column), `divider`.
270
-
271
- ## Picking only the blocks you want
272
-
273
- `registerBuiltInBlocks`/`registerBuiltInInlineTypes` register everything at
274
- once the fastest way to a fully-featured editor. If you'd rather ship
275
- only what you actually use (TipTap's `extensions: [...]` idea), every
276
- built-in block/inline type is also exported individually, and
277
- `registerBlocks`/`registerInlineTypes` register just the ones you name:
278
-
279
- ```js
280
- import {
281
- createBlockRegistry,
282
- registerBlocks,
283
- paragraphBlockType,
284
- headingBlockType,
285
- TABLE_BLOCKS, // table needs its row/cell types alongside it — spread the whole group
286
- } from 'noteloom';
287
-
288
- const registry = createBlockRegistry();
289
- registerBlocks(registry, {
290
- paragraph: paragraphBlockType,
291
- heading: headingBlockType,
292
- ...TABLE_BLOCKS,
293
- });
294
- // registry now only knows about paragraph/heading/table — nothing else
295
- // (callout, button, embed, ...) shows up in the slash menu or renders at all.
296
- ```
297
-
298
- `registerBuiltInBlocks(registry)` is itself just `registerBlocks(registry, { paragraph: paragraphBlockType, ... })` with every type included — so mixing "give me everything" and "just these few" across different parts of your app is never an either/or choice. `layout` has the same "needs its own group" shape as `table` — see `LAYOUT_BLOCKS`. `TABLE_SELECT_INLINE_TYPES` (inline side) is only needed if you use a table's "select" column type.
299
-
300
- ## Built-in inline types
301
-
302
- Atomic, non-text content that can be spliced into running text via the slash menu at any cursor position — `select` (with in-editor add/remove-option UI), `date` (native `<input type="date">`).
303
-
304
- There's no separate hardcoded `mention` type — an `@name` chip is just an ordinary use of `createSelectFieldType` (see the next section), with `triggers: ['slash', 'at']` so it also shows up under a second, dedicated "@" trigger (`useAtMenuTrigger`), alongside "/". See the example app's "Assignee" field type for a full worked example.
305
-
306
- ## Custom select field types (static, or dynamic/API-backed)
307
-
308
- `createSelectFieldType(config)` builds a full, ready-to-register inline type from a plain config object — this is how you add your own named dropdown ("Assignee", "Status", "Priority", ...) without writing a component:
309
-
310
- ```js
311
- import { createInlineRegistry, createSelectFieldType } from 'noteloom';
312
-
313
- const inlineRegistry = createInlineRegistry();
314
-
315
- inlineRegistry.register(
316
- 'status',
317
- createSelectFieldType({
318
- type: 'status', // must match the key you register it under
319
- label: 'Status', // shown in the "/" menu and as the search box's aria-label
320
- placeholder: 'Set status…',
321
- variant: 'tag', // 'tag' = Notion-style colored pill; 'default' = plain bordered dropdown
322
- options: [
323
- { value: 'todo', label: 'To do', color: { bg: '#e9e9e7', text: '#37352f' } },
324
- { value: 'doing', label: 'In progress', color: { bg: '#fdecc8', text: '#a06400' } },
325
- { value: 'done', label: 'Done', color: { bg: '#dbeddb', text: '#2f7a2f' } },
326
- ],
327
- }),
328
- );
329
- ```
330
-
331
- `options` can also be a **function** instead of a plain array — `(query) => Option[] | Promise<Option[]>` — for a real database/API-backed search (React Select's `loadOptions`, essentially):
332
-
333
- ```js
334
- inlineRegistry.register(
335
- 'assignee',
336
- createSelectFieldType({
337
- type: 'assignee',
338
- label: 'Assignee',
339
- placeholder: 'Assign to…',
340
- variant: 'tag',
341
- triggers: ['slash', 'at'], // reachable via "/assignee" AND by typing "@" directly
342
- options: async (query) => {
343
- const res = await fetch(`/api/users?search=${encodeURIComponent(query)}`);
344
- const users = await res.json();
345
- return users.map((u) => ({ value: u.id, label: u.name }));
346
- },
347
- }),
348
- );
349
- ```
350
-
351
- A few things worth knowing about the dynamic path:
352
-
353
- - Your function is called **fresh on every keystroke**, debounced ~250ms — there's no built-in caching layer, so if you want caching, memoize inside your own function.
354
- - Only the **resolved pick** — `{ value, label }` (plus `color` for the tag variant) — is ever written onto the document. The live options list itself is never persisted, so a chip never embeds a stale snapshot of your database; re-opening it always calls your function again.
355
- - `triggers` (default `['slash']`) decides whether the type shows up under `/`, `@` (via `useAtMenuTrigger`), or both — see the "Assignee" example above. A field that doesn't read naturally after "@" (e.g. "Priority") should usually stay slash-only.
356
-
357
- ### Letting end users create their own field types, in-editor
358
-
359
- The above is for types **you** define in code. If you also want a non-technical end user to be able to create new (always static there's no way to author a fetch function through a UI) select types from inside the editor itself, mount `FieldTypeEditorModal` once and wire a button to it:
360
-
361
- ```jsx
362
- import { EditorProvider, FieldTypeEditorModal, useFieldTypeEditor } from 'noteloom';
363
-
364
- function NewFieldTypeButton() {
365
- const { openCreate } = useFieldTypeEditor();
366
- return <button onClick={openCreate}>+ New field type</button>;
367
- }
368
-
369
- // Anywhere under <EditorProvider>:
370
- <NewFieldTypeButton />
371
- <FieldTypeEditorModal />
372
- ```
373
-
374
- User-created types are persisted in the document's own `fieldTypes` collection (so they survive reload) and are automatically rehydrated back into your inline registry by `FieldTypeEditorModal` itself you don't need to call anything extra. Each chip's popover also gets a "Manage options…" entry that reopens this same modal, pre-filled, for renaming/editing/deleting the type it belongs to.
375
-
376
- ## Registering your own block/inline types
377
-
378
- ```js
379
- registry.register('myBlock', {
380
- component: MyBlockComponent, // receives only { id }
381
- isLeaf: true, // true if contentIds holds run ids, false if it holds child block ids
382
- toHTML(block, ctx) { /* ... */ },
383
- fromHTML(domNode, ctx) { /* ... or return null if this node isn't yours */ },
384
- toPlainText(block, ctx) { /* ... */ },
385
- slashCommand: { label: 'My Block', keywords: ['my'], run(store, ctx) { /* ... */ } },
386
- });
387
- ```
388
-
389
- ## Accessibility
390
-
391
- - Every portaled popover that's a genuine standalone action menu (the block gutter's Duplicate/Move/Hide/Delete menu, the block-range action menu, a table column's options menu) is keyboard-operable: opening one moves real focus onto its first item, ArrowUp/ArrowDown move between items (wrapping), Home/End jump to the first/last, and Escape closes it and returns focus to whatever opened it — not just a name-only `role="menu"` that only responds to mouse clicks.
392
- - `Modal` moves focus into the dialog (its first focusable element) on open and restores it to whatever had focus before on close — not a full focus trap (this package stays zero-dependency, and its dialogs are short, single-purpose forms, not deep navigable UI), just "focus doesn't go missing."
393
- - Structural actions that don't otherwise move focus anywhere describable (duplicate/move/hide/delete a block, or a whole selected range) announce what happened via a shared, visually-hidden `aria-live="polite"` region — screen-reader users get "Block deleted"/"3 blocks moved up" instead of silence.
394
- - Embed images have a real, separately-authored `alt` text field (a toolbar button opens a small dialog to set it) — `alt` is never silently filled in from the uploaded file's raw filename or a pasted URL string, since neither is meaningful alt text.
395
- - Table header cells have `scope="col"`, and the column-resize/embed-resize sliders both expose `aria-valuemin/valuemax/valuenow`.
396
-
397
- ## Development
398
-
399
- ```bash
400
- npm install
401
- npm run dev # examples/basic dev server
402
- npm test # vitest (jsdom + @testing-library/react)
403
- npm run build # library build (dist/, ESM + CJS)
404
- ```
405
-
406
- ## Known limitations
407
-
408
- - No accessibility affordance exists for grouping sibling list items under a shared `role="list"` container (each list item is an independent block, not wrapped in one) — adding `role="listitem"` without that ancestor would be worse than no role at all, so it's deliberately left out pending a bigger structural change.
409
- - The library doesn't render your editor's own root/surface element (that's host-rendered — see `examples/basic/src/App.jsx`'s `EditorSurface`), so it can't add `role="document"`/`aria-label` there itself; the example app demonstrates doing this on your own surface element, which is worth copying into your own app.
410
- - Cross-block mark toggling (bold/italic/underline over a selection spanning multiple blocks) applies as one store operation per block, not a single atomic undo step.
411
- - `select`'s option-adding UI and any `createSelectFieldType`-based type's options (e.g. an "Assignee" @-mention) are meant as a starting point — a real app will want to wire its own people/options source.
412
- - RTL support covers direction resolution (`dir="auto"` + per-block/document override) and the highest-impact visual pieces (list markers, blockquote border, block gutter position) a full logical-properties rewrite of every hardcoded pixel value in `style.css` is a bigger follow-up, not yet done.
413
- - Voice typing (`useVoiceTyping`) only acts on *finalized* speech results, not interim/in-progress ones, and command detection requires a spoken command to be its own complete utterance there's no explicit "command mode" trigger (push-to-command, wake phrase) yet, just pause-based auto-detection.
414
- - Automated tests run under jsdom; there is no automated real-browser test suite. If you hit an edge case jsdom can't reproduce (anything involving actual native `contentEditable` browser quirks, or the real Web Speech API), please file an issue with the exact browser/OS and steps.
1
+ # noteloom
2
+
3
+ [![version](https://img.shields.io/npm/v/noteloom.svg?label=version&color=3178c6)](https://www.npmjs.com/package/noteloom)
4
+ [![downloads](https://img.shields.io/npm/dm/noteloom.svg?label=downloads&color=44cc11)](https://www.npmjs.com/package/noteloom)
5
+ [![license](https://img.shields.io/npm/l/noteloom.svg?label=license&color=44cc11)](https://github.com/vishwakarmanikhil/noteloom/blob/master/LICENSE)
6
+ [![Sponsor](https://img.shields.io/badge/Sponsor-%E2%9D%A4-333?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/vishwakarmanikhil)
7
+
8
+ **[Live site & docs →](https://noteloom.qusere.in)** · **[Play with the demo →](https://noteloom.qusere.in/playground/)**
9
+
10
+ A React-first, block-based rich text editor with **zero runtime dependencies** — the only things it expects from your app are `react` and `react-dom`. Everything else (undo/redo, clipboard, slash commands, tables, inline widgets) is built from scratch on top of a small normalized document store.
11
+
12
+ ## Highlights
13
+
14
+ - **11 built-in block types** paragraph, heading, list (bulleted/numbered/to-do/toggle), table, multi-column layout, divider, callout, blockquote, code, toggle heading, button, and embed.
15
+ - **Inline widgets mid-sentence** — select dropdowns, dates, checkboxes, and `@mentions`, spliced directly into running text, not forced onto their own line.
16
+ - **A real default theme**, injected automatically, fully retheme-able via CSS custom properties, or opt out entirely and bring your own.
17
+ - **Mobile/touch-first UI** a bottom action bar, tap-friendly block picker, and touch-aware popovers, not just a desktop UI that technically renders on a phone.
18
+ - **Voice typing** — continuous dictation plus spoken structural commands ("heading one", "bulleted list", "undo") via the browser's own Speech API, no SDK bundled.
19
+ - **RTL & accessibility built in** — automatic per-block text direction, keyboard-operable menus, live-region announcements, and more.
20
+ - **Two JSON export shapes** — the normalized engine format, or a simpler self-contained shape for storage/API/CRUD use — plus HTML and plain-text export, all with a drop-in "View source" button.
21
+ - **Zero runtime dependencies**, a flat/normalized document model that diffs and stores cleanly, and fine-grained React re-rendering (editing one paragraph in a 500-block doc repaints just that block).
22
+
23
+ ## Why this exists
24
+
25
+ Most rich-text editors either bring their own large dependency tree, or force every "special" piece of content (a dropdown, a date, a mention) onto its own line. This one is built around two ideas:
26
+
27
+ - **Inline heterogeneous content is a first-class citizen.** A `select` dropdown, a date picker, or an `@mention` chip can sit in the middle of a sentence, mixed with regular text, in one paragraph — not forced onto a block of its own.
28
+ - **Fine-grained React re-rendering, no virtual-DOM-for-content-editable fights.** Every block subscribes only to its own data via `useSyncExternalStore`; editing one paragraph in a 500-block document doesn't re-render anything else (see `test/performance/largeDocument.test.jsx` for the regression guard on this).
29
+
30
+ ---
31
+
32
+ # Getting started
33
+
34
+ ## 1. Install
35
+
36
+ ```bash
37
+ npm install noteloom react react-dom
38
+ ```
39
+
40
+ ## 2. Create an editor
41
+
42
+ ```jsx
43
+ import { useEditor, NoteloomEditor } from 'noteloom';
44
+
45
+ function Editor() {
46
+ const editor = useEditor();
47
+ return <NoteloomEditor editor={editor} />;
48
+ }
49
+ ```
50
+
51
+ That's the whole thing. `useEditor()` creates a fully wired store (undo/redo included) and both registries pre-populated with every built-in block and inline type; `<NoteloomEditor>` renders it with clipboard, slash/emoji/@-mention menus, the floating format toolbar, keyboard shortcuts, and block-range drag already hooked up. No CSS to import either — see [Styling](#styling--zero-setup-required) below.
52
+
53
+ ## 3. (Optional) pass a starting document, or turn off undo/redo
54
+
55
+ ```jsx
56
+ const editor = useEditor({
57
+ doc: myDocumentJSON, // defaults to one empty paragraph
58
+ history: true, // default; false gives a plain EditorStore with no undo/redo
59
+ });
60
+ ```
61
+
62
+ ## 4. Try it / learn by example
63
+
64
+ ```bash
65
+ npm run dev:quickstart # the exact 3 lines above, runnable
66
+ ```
67
+
68
+ Then work through the rest of `examples/` in order — each one adds exactly one new idea on top of the last (a custom block, a custom dropdown field, theming, ...). See **[`examples/README.md`](examples/README.md)** for the full list and what each one teaches.
69
+
70
+ Everything past this point is a reference guide, in two parts:
71
+
72
+ - **[Basic guide](#basic-guide)** — every built-in feature (styling, custom field types, export, collaboration, offline, ...), all built on `useEditor()`/`<NoteloomEditor>` from step 2 above.
73
+ - **[Advanced: the granular API](#advanced-the-granular-api)** — for when you need more control than that gives you (a custom toolbar, a hand-picked subset of blocks, writing a whole new block component). `useEditor()` still hands you the raw pieces (`{ store, registry, inlineRegistry }`) to drop into this API — the two are never an either/or choice.
74
+
75
+ ---
76
+
77
+ # Basic guide
78
+
79
+ Every example below uses `editor`/`store`/`registry`/`inlineRegistry` from `useEditor()` (`const { store, registry, inlineRegistry } = editor;`) unless it says otherwise.
80
+
81
+ ## Built-in block types
82
+
83
+ `paragraph`, `heading` (h1–h3), `listItem` (bulleted, numbered, to-do, and toggle — with Tab/Shift+Tab nesting and standard Enter conventions), `table` (with row/column insert/delete), `layout` (multi-column), `divider`, `callout`, `blockquote`, `code`, `toggleHeading`, `button`, and `embed` (image/video/audio/file).
84
+
85
+ ## Built-in inline types
86
+
87
+ Atomic, non-text content that can be spliced into running text via the slash menu at any cursor position — `select` (with in-editor add/remove-option UI), `date` (native `<input type="date">`), `checkbox`.
88
+
89
+ There's no separate hardcoded `mention` type — an `@name` chip is just an ordinary use of `createSelectFieldType` (see [Custom dropdown / mention field types](#custom-dropdown--mention-field-types-static-or-dynamicapi-backed) below), with `triggers: ['slash', 'at']` so it also shows up under a second, dedicated "@" trigger (`useAtMenuTrigger`), alongside "/".
90
+
91
+ ## Styling — zero setup required
92
+
93
+ You don't need to import any CSS. The moment `<NoteloomEditor>` mounts, it injects a single `<style>` tag with a minimal, clean default theme — no `import 'noteloom/style.css'` line, no build-tool CSS configuration, nothing to wire up. It's idempotent (mounting more than one editor on a page only injects it once) and client-only (a no-op under SSR; hydrate as normal and it injects on mount).
94
+
95
+ **Retheme it** by overriding the CSS custom properties it reads from — defined on `:root` (not scoped to a wrapper element, since portaled pieces like the slash menu and Select's popover aren't DOM descendants of the editor itself):
96
+
97
+ ```css
98
+ :root {
99
+ --noteloom-accent: #16a34a; /* swap the indigo accent for green */
100
+ --noteloom-radius-md: 4px; /* sharper corners */
101
+ --noteloom-font: 'Inter', sans-serif;
102
+ }
103
+ ```
104
+
105
+ Dark mode follows `prefers-color-scheme` automatically; to control it explicitly instead (e.g. a manual light/dark toggle), set `data-theme="dark"` or `data-theme="light"` on any ancestor (typically `<html>`) — see the full variable list in `src/style.css`. (`examples/04-styling/` is a complete runnable version of everything in this section.)
106
+
107
+ **Scope overrides to one editor instance**, or add your own class for full custom CSS, via `className`/`style` — passing either wraps the editor surface in one `<div className="be-root ...">`:
108
+
109
+ ```jsx
110
+ <NoteloomEditor editor={editor} className="my-editor" style={{ '--noteloom-accent': '#16a34a' }} />
111
+ ```
112
+
113
+ No wrapper `<div>` is added unless you pass one of these props, so existing usage is unaffected either way.
114
+
115
+ **Opt out entirely** with `theme="none"` — nothing gets injected, and you take full responsibility for styling every `.be-*` class yourself (or import `noteloom/style.css` manually if you just want control over *when* it loads, e.g. before your own overrides in a specific `<link>` order):
116
+
117
+ ```jsx
118
+ <NoteloomEditor editor={editor} theme="none" />
119
+ ```
120
+
121
+ `examples/basic/src/style.css` shows the extra page-level chrome (fonts, page width, the demo's own toolbar buttons) a host app typically adds around the editor — none of that is part of the default theme itself.
122
+
123
+ **Customize individual blocks**, not just the root, via `getBlockClassName`:
124
+
125
+ ```jsx
126
+ <NoteloomEditor editor={editor} getBlockClassName={(block) => (block.type === 'callout' ? 'my-callout' : undefined)} />
127
+ ```
128
+
129
+ Whatever string you return is appended onto that block's own root element's class list (`be-paragraph my-callout`, alongside the fixed base class) — `block` is the real block object (`type`, `id`, `props`), so you can target a type, a specific id, or a prop value (e.g. every red callout) as precisely as you like.
130
+
131
+ ## Picking only the blocks/inline types you want
132
+
133
+ `useEditor()` registers every built-in block/inline type by default — the fastest way to a fully-featured editor. If you'd rather ship only what you actually use, every built-in block/inline type is also exported individually, and `registerBlocks`/`registerInlineTypes` register just the ones you name, via `useEditor()`'s own `registerBlocks`/`registerInlineTypes` options:
134
+
135
+ ```jsx
136
+ import { useEditor, NoteloomEditor, registerBlocks, paragraphBlockType, headingBlockType, TABLE_BLOCKS } from 'noteloom';
137
+
138
+ function Editor() {
139
+ const editor = useEditor({
140
+ registerBlocks: (registry) => registerBlocks(registry, { paragraph: paragraphBlockType, heading: headingBlockType, ...TABLE_BLOCKS }),
141
+ });
142
+ return <NoteloomEditor editor={editor} />;
143
+ }
144
+ ```
145
+
146
+ `registerBuiltInBlocks(registry)` (what `useEditor()` calls by default) is itself just `registerBlocks(registry, { paragraph: paragraphBlockType, ... })` with every type included — so mixing "give me everything" and "just these few" across different parts of your app is never an either/or choice. `layout`/`table` each need their own group of related types registered together — see `LAYOUT_BLOCKS`/`TABLE_BLOCKS`. `TABLE_SELECT_INLINE_TYPES` (inline side) is only needed if you use a table's "select" column type.
147
+
148
+ To keep every built-in type **and** add your own on top, call `registerBuiltInBlocks` yourself inside the callback:
149
+
150
+ ```jsx
151
+ import { useEditor, NoteloomEditor, registerBuiltInBlocks } from 'noteloom';
152
+
153
+ function Editor() {
154
+ const editor = useEditor({
155
+ registerBlocks: (registry) => {
156
+ registerBuiltInBlocks(registry); // keep everything built-in...
157
+ registry.register('myCustomType', myBlockTypeEntry); // ...plus your own (see "Advanced" below)
158
+ },
159
+ });
160
+ return <NoteloomEditor editor={editor} />;
161
+ }
162
+ ```
163
+
164
+ `examples/02-custom-block/` is a complete runnable version of this pattern.
165
+
166
+ ## Custom dropdown / mention field types (static, or dynamic/API-backed)
167
+
168
+ `createSelectFieldType(config)` builds a full, ready-to-register inline type from a plain config object — this is how you add your own named dropdown ("Assignee", "Status", "Priority", ...) **without writing a component**:
169
+
170
+ ```jsx
171
+ import { useEditor, NoteloomEditor, registerBuiltInInlineTypes, createSelectFieldType } from 'noteloom';
172
+
173
+ const statusFieldType = createSelectFieldType({
174
+ type: 'status', // must match the key you register it under
175
+ label: 'Status', // shown in the "/" menu and as the search box's aria-label
176
+ placeholder: 'Set status…',
177
+ variant: 'tag', // 'tag' = colored pill; 'default' = plain bordered dropdown
178
+ options: [
179
+ { value: 'todo', label: 'To do', color: { bg: '#e9e9e7', text: '#37352f' } },
180
+ { value: 'doing', label: 'In progress', color: { bg: '#fdecc8', text: '#a06400' } },
181
+ { value: 'done', label: 'Done', color: { bg: '#dbeddb', text: '#2f7a2f' } },
182
+ ],
183
+ });
184
+
185
+ function Editor() {
186
+ const editor = useEditor({
187
+ registerInlineTypes: (inlineRegistry) => {
188
+ registerBuiltInInlineTypes(inlineRegistry);
189
+ inlineRegistry.register('status', statusFieldType);
190
+ },
191
+ });
192
+ return <NoteloomEditor editor={editor} />;
193
+ }
194
+ ```
195
+
196
+ `examples/03-custom-field-type/` is a complete runnable version of this pattern.
197
+
198
+ `options` can also be a **function** instead of a plain array — `(query) => Option[] | Promise<Option[]>` — for a real database/API-backed search (React Select's `loadOptions`, essentially):
199
+
200
+ ```js
201
+ createSelectFieldType({
202
+ type: 'assignee',
203
+ label: 'Assignee',
204
+ placeholder: 'Assign to…',
205
+ variant: 'tag',
206
+ triggers: ['slash', 'at'], // reachable via "/assignee" AND by typing "@" directly
207
+ options: async (query) => {
208
+ const res = await fetch(`/api/users?search=${encodeURIComponent(query)}`);
209
+ const users = await res.json();
210
+ return users.map((u) => ({ value: u.id, label: u.name }));
211
+ },
212
+ });
213
+ ```
214
+
215
+ A few things worth knowing about the dynamic path:
216
+
217
+ - Your function is called **fresh on every keystroke**, debounced ~250ms — there's no built-in caching layer, so if you want caching, memoize inside your own function.
218
+ - Only the **resolved pick** — `{ value, label }` (plus `color` for the tag variant) — is ever written onto the document. The live options list itself is never persisted, so a chip never embeds a stale snapshot of your database; re-opening it always calls your function again.
219
+ - `triggers` (default `['slash']`) decides whether the type shows up under `/`, `@` (via `useAtMenuTrigger`), or both. A field that doesn't read naturally after "@" (e.g. "Priority") should usually stay slash-only.
220
+
221
+ ### Letting end users create their own field types, in-editor
222
+
223
+ The above is for types **you** define in code. If you also want a non-technical end user to be able to create new (always static there's no way to author a fetch function through a UI) select types from inside the editor itself, mount `FieldTypeEditorModal` once and wire a button to it, anywhere inside `<NoteloomEditor>` (as `children`, or in your own chrome around it via `useFieldTypeEditor`):
224
+
225
+ ```jsx
226
+ import { NoteloomEditor, FieldTypeEditorModal, useFieldTypeEditor } from 'noteloom';
227
+
228
+ function NewFieldTypeButton() {
229
+ const { openCreate } = useFieldTypeEditor();
230
+ return <button onClick={openCreate}>+ New field type</button>;
231
+ }
232
+
233
+ <NoteloomEditor editor={editor}>
234
+ <NewFieldTypeButton />
235
+ <FieldTypeEditorModal />
236
+ </NoteloomEditor>;
237
+ ```
238
+
239
+ User-created types are persisted in the document's own `fieldTypes` collection (so they survive reload) and are automatically rehydrated back into your inline registry by `FieldTypeEditorModal` itself you don't need to call anything extra. Each chip's popover also gets a "Manage options…" entry that reopens this same modal, pre-filled, for renaming/editing/deleting the type it belongs to.
240
+
241
+ ## Exporting the document (JSON / HTML / plain text)
242
+
243
+ ```js
244
+ import { exportDocumentJSON, exportDocumentHTML, exportDocumentText } from 'noteloom';
245
+
246
+ exportDocumentJSON(store); // { rootId, blocks, runs } feed straight back into `useEditor({ doc })`
247
+ exportDocumentHTML(store, registry, inlineRegistry);
248
+ exportDocumentText(store, registry, inlineRegistry);
249
+ ```
250
+
251
+ Or mount the ready-made button + modal instead of wiring your own UI:
252
+
253
+ ```jsx
254
+ import { DocumentExportButton } from 'noteloom';
255
+
256
+ <DocumentExportButton label="View source" />
257
+ ```
258
+
259
+ It opens a modal with JSON/Simple JSON/HTML/Text tabs (reading live from the store every time it opens) and a Copy button useful for debugging, or as a starting point for a real "export" feature.
260
+
261
+ ### A simpler JSON shape for storage/API/CRUD use
262
+
263
+ `exportDocumentJSON()` above returns the *internal engine format* the same normalized, id-referenced graph `EditorStore` operates on (blocks reference other blocks by id; text lives in a separate `runs` collection, not embedded inline). That shape is what makes per-run reactivity, O(1) structural edits, and real nesting (toggle lists, tables, inline atomic chips) work it's not going to look like a simple flat document, on purpose.
264
+
265
+ If you just want something simpler to store, send over an API, or hand-edit self-contained blocks in an array, `children` for nesting, no id-references to resolve use the second, optional export/import pair instead:
266
+
267
+ ```js
268
+ import { exportDocumentSimpleJSON, importDocumentSimpleJSON } from 'noteloom';
269
+
270
+ const json = exportDocumentSimpleJSON(store, registry, inlineRegistry);
271
+ // {
272
+ // "version": 1,
273
+ // "blocks": [
274
+ // { "id": "p1", "type": "paragraph", "data": { "text": "Hello <strong>world</strong>" } },
275
+ // { "id": "h1", "type": "heading", "data": { "text": "Key features", "level": 3 } },
276
+ // {
277
+ // "id": "li1", "type": "listItem",
278
+ // "data": { "text": "Nested item", "ordered": false, "checked": null },
279
+ // "children": [ /* nested listItem blocks, same shape */ ]
280
+ // },
281
+ // {
282
+ // "id": "t1", "type": "table",
283
+ // "data": { "columns": [{ "id": "c1", "label": "Name" }], "rows": [["Cell text"]] }
284
+ // }
285
+ // ]
286
+ // }
287
+
288
+ // ...later, or on a different machine/process:
289
+ const doc = importDocumentSimpleJSON(json, registry, inlineRegistry); // -> { rootId, blocks, runs }
290
+ const editor2 = useEditor({ doc }); // or `new EditorStore(doc)` directly outside React
291
+ ```
292
+
293
+ Rich text (`data.text`) is an HTML string — the exact same per-run serialization every block type's own clipboard-copy `toHTML` already produces, so marks (bold/italic/underline/strike/code/sub/superscript/color/highlight/link) and atomic inline chips (checkbox/date/select/mention) round-trip through it the same way copy/paste already does. `table` is flattened specially (`data.columns` + `data.rows`, a 2D array) rather than exposing the internal table/row/cell block chain — the single biggest simplification versus the internal shape. Block/run ids are preserved on both export and import (useful for referencing/updating a specific block from an external system).
294
+
295
+ One existing, by-design limitation carried over from clipboard paste: an atomic inline type's *core* value round-trips (a checkbox's checked state + label, a date's ISO value, a select's chosen value + label) but its full `options` list does not — only the currently-selected option survives, the same as pasting one of these chips into another instance of the editor today.
296
+
297
+ This is purely an additive, alternate *interchange* format — the internal engine format above is unaffected either way, and this is not a replacement for it.
298
+
299
+ ## Right-to-left / multi-language text
300
+
301
+ Every block defaults to `dir="auto"` — the browser's own Unicode bidi algorithm detects direction per block from its first strong character, so a document mixing LTR and RTL blocks (an English heading over an Arabic paragraph, say) just works with zero configuration. For the cases `auto` can't infer on its own (most commonly an empty block, which has no text yet to detect a direction from), set an explicit override:
302
+
303
+ ```js
304
+ import { operations } from 'noteloom';
305
+
306
+ // Document-wide default:
307
+ store.applyOperation(operations.updateBlockProps(store.getRootId(), { dir: 'rtl' }));
308
+ // Or just one block:
309
+ store.applyOperation(operations.updateBlockProps(blockId, { dir: 'rtl' }));
310
+ ```
311
+
312
+ A block's own `dir` wins over the document's; the block gutter menu also has a "Switch to right-to-left"/"left-to-right" item that sets this per-block. Code blocks are always `dir="ltr"` regardless of the surrounding document's default — code syntax (brackets, operators) is structurally LTR no matter what language a comment or string literal happens to be written in.
313
+
314
+ This pass covers the reading/typing/gutter-position direction itself; a full logical-properties (`margin-inline-start` etc.) audit of every pixel value in `style.css` is deliberately out of scope for now — the highest-impact pieces (list/checkbox marker position, blockquote border side, block gutter position) already flip correctly.
315
+
316
+ ## Printing & PDF
317
+
318
+ `style.css` includes a built-in `@media print` stylesheet: every piece of editing chrome (the block gutter, all portaled menus, the floating toolbar, resize handles, the mobile action bar, etc.) is hidden automatically, and a block hidden via "Hide in preview" stays hidden in the printout too, regardless of whether the app happens to be toggled into preview mode at the moment you print printing always behaves like preview mode.
319
+
320
+ There's no bundled PDF-generation library (that would need a real dependency like jsPDF/pdfmake, conflicting with staying zero-runtime-dependency) — the browser's own print-to-PDF is the intended path:
321
+
322
+ ```js
323
+ window.print(); // Ctrl+P / Cmd+P works too "Save as PDF" in the print dialog is your PDF export
324
+ ```
325
+
326
+ This only cleans up the *editor's* own chrome. A host app's own outer UI (nav bar, sidebar, its own toolbar) needs its own `@media print` rules the same way — see `examples/basic/src/style.css` for a worked example, since that chrome lives entirely outside this package.
327
+
328
+ ## Voice typing
329
+
330
+ `useVoiceTyping()` wraps the browser's native Web Speech API (`SpeechRecognition`) for continuous dictation mixed with spoken structural commands — say "heading one", "new paragraph", "bulleted list", "quote", "undo", etc. while dictating, and the current block converts (or a new one is inserted) instead of those words being typed as text:
331
+
332
+ ```jsx
333
+ import { useVoiceTyping } from 'noteloom';
334
+
335
+ function MicButton() {
336
+ const voice = useVoiceTyping();
337
+ if (!voice.isSupported) return null; // e.g. Firefox — no bundled fallback, degrades to nothing
338
+ return (
339
+ <button onClick={() => (voice.isListening ? voice.stop() : voice.start())}>
340
+ {voice.isListening ? 'Stop dictation' : 'Start dictation'}
341
+ </button>
342
+ );
343
+ }
344
+ ```
345
+
346
+ No speech-to-text SDK is bundled (same zero-runtime-dependency reasoning as PDF export above) — this is built entirely on the browser's own `SpeechRecognition`/`webkitSpeechRecognition`, so `isSupported` is `false` wherever that API doesn't exist. A command is only recognized when an entire *finalized* spoken utterance (a natural pause before/after, as reported by the Speech API itself) matches a known phrase exactly — see `src/voice/voiceCommands.js` for the full table — so a command word merely mentioned mid-sentence while dictating prose is never misread as a command.
347
+
348
+ ## Mobile / touch support
349
+
350
+ Typing "/"/"@" still works on a phone keyboard, but it's not a reliable or discoverable primary path there (autocorrect, awkward key access, nothing to discover it by) — so on a coarse (touch) pointer, `MobileActionBar` takes over as the touch-first equivalent, pinned above the on-screen keyboard. It needs direct access to the same DOM element your editor surface renders into (to track focus/selection inside it), which `<NoteloomEditor>` doesn't expose — so this one piece needs the [granular API](#advanced-the-granular-api):
351
+
352
+ ```jsx
353
+ import { MobileActionBar } from 'noteloom';
354
+
355
+ // next to your other trigger hooks/components, same containerRef:
356
+ <MobileActionBar containerRef={containerRef} />
357
+ ```
358
+
359
+ `examples/basic` has this fully wired up (run `npm run dev`, then resize to a narrow viewport or open it on a phone).
360
+
361
+ It renders nothing on a mouse/trackpad, and nothing until focus is actually inside the editor. Its contents swap based on context:
362
+
363
+ - **Block options** (shown whenever the caret/selection is inside any block) → Duplicate/Move up/Move down/Hide-Show/Delete, in `MobileBlockOptionsSheet` — the mobile home for the desktop per-block gutter's own grip-handle menu. The gutter itself is hidden entirely on touch input (no hover state exists to reveal it by, and its desktop position sits in a page margin that doesn't exist on a narrow viewport), so both of its actions ("+" and the options menu) live in this bar instead of the gutter on touch.
364
+ - **Text selected** → formatting actions (bold/italic/underline/link) — the desktop `FloatingToolbar` bubble also disables itself on touch, so this is the single formatting surface either way (both share the same `useTextFormattingActions` hook, not two copies).
365
+ - **Collapsed caret, table cell** → insert row/column.
366
+ - **Collapsed caret, code block** → language picker.
367
+ - **Collapsed caret, callout** → color picker.
368
+ - **Collapsed caret, everywhere else** → "+" (opens `MobileBlockPickerSheet`, a tap-friendly bottom sheet listing every insertable block, same commands "/" already offers), Undo/Redo, dismiss-keyboard.
369
+
370
+ Trigger-menu and `Select` popovers reposition above the caret instead of below it when there isn't room before the keyboard, via `useVirtualKeyboardInset()` (also exported, in case you're positioning your own UI against the keyboard).
371
+
372
+ **Touch detection deliberately isn't a static `matchMedia('(pointer: coarse)')` check** (see `useCoarsePointer`, also exported) — a touchscreen laptop reports its trackpad as the "primary" pointer even though the touchscreen sitting right there can be used at any moment, so a pure media-query check would never show touch UI on that class of device. Instead, the media query only supplies the *initial* guess (correct pre-interaction, SSR-safe); every real `pointerdown` afterward overrides it with that event's own `pointerType`, so a 2-in-1 laptop correctly shows desktop UI while the trackpad is in use and mobile UI the instant the screen is tapped, live, no reload needed. The same signal is mirrored onto `<html class="be-touch-input">` so plain CSS (the gutter-hiding rule above) reacts to it too, not just `MobileActionBar` itself.
373
+
374
+ **Not included**: a touch equivalent for dragging in the block gutter to select a range of blocksmost block editors keep that gesture desktop/mouse-only too.
375
+
376
+ ## Accessibility
377
+
378
+ - Every portaled popover that's a genuine standalone action menu (the block gutter's Duplicate/Move/Hide/Delete menu, the block-range action menu, a table column's options menu) is keyboard-operable: opening one moves real focus onto its first item, ArrowUp/ArrowDown move between items (wrapping), Home/End jump to the first/last, and Escape closes it and returns focus to whatever opened it — not just a name-only `role="menu"` that only responds to mouse clicks.
379
+ - `Modal` moves focus into the dialog (its first focusable element) on open and restores it to whatever had focus before on close — not a full focus trap (this package stays zero-dependency, and its dialogs are short, single-purpose forms, not deep navigable UI), just "focus doesn't go missing."
380
+ - Structural actions that don't otherwise move focus anywhere describable (duplicate/move/hide/delete a block, or a whole selected range) announce what happened via a shared, visually-hidden `aria-live="polite"` region — screen-reader users get "Block deleted"/"3 blocks moved up" instead of silence.
381
+ - Embed images have a real, separately-authored `alt` text field (a toolbar button opens a small dialog to set it) `alt` is never silently filled in from the uploaded file's raw filename or a pasted URL string, since neither is meaningful alt text.
382
+ - Table header cells have `scope="col"`, and the column-resize/embed-resize sliders both expose `aria-valuemin/valuemax/valuenow`.
383
+
384
+ ## Offline persistence
385
+
386
+ For a fully offline editor — no server, no internet required — documents can auto-save to IndexedDB (native browser API, no added dependency) and reload themselves on the next visit:
387
+
388
+ ```jsx
389
+ import { useEditor, NoteloomEditor, usePersistedDocument } from 'noteloom';
390
+
391
+ function App() {
392
+ const editor = useEditor({ doc: myStarterDoc });
393
+ const { isLoaded } = usePersistedDocument({ store: editor.store, docId: 'my-document-id' });
394
+
395
+ if (!isLoaded) return <p>Loading…</p>;
396
+ return <NoteloomEditor editor={editor} />;
397
+ }
398
+ ```
399
+
400
+ On mount, this loads whatever was last saved under `docId` (if anything) and replaces the store's content with it; every edit after that — typing, structural changes, even changes arriving from a collaborating peer via `CollabSession` — is auto-saved back, debounced (default 500ms of quiet) so a full-document write doesn't fire on every keystroke. Different `docId`s are stored independently, so one browser can hold many separate documents (e.g. keyed by page/route). A runnable example is in `examples/offline-persist/` — run `npm run dev:offline-persist`, type something, then reload the page or close and reopen the tab.
401
+
402
+ Lower-level pieces, if `usePersistedDocument`'s all-in-one behavior doesn't fit (a non-React host app, custom load/save timing, etc.):
403
+ - `savePersistedDocument(docId, doc)` / `loadPersistedDocument(docId)` / `deletePersistedDocument(docId)` / `listPersistedDocumentIds()` — the raw IndexedDB operations `usePersistedDocument` is built on.
404
+ - `createAutoPersistence({ store, docId, debounceMs, onError })` — just the debounced auto-save half, if you want to handle the initial load yourself. Returns `{ stop, flush }`.
405
+
406
+ This is standalone — works with a solo, non-collaborating store just as well as one wired to `CollabSession` (a collaborated-on document also gets saved locally, so it survives even after every peer disconnects). Note this only makes the *editing* work offline; if the app itself is loaded from a dev server or web host, opening it for the very first time (or after clearing cache) still needs that host to be reachable once — that's the separate concern the next section covers.
407
+
408
+ ### Offline app shell (PWA)
409
+
410
+ `usePersistedDocument` makes the *document* offline-capable; it doesn't make the *app itself* loadable with no network — that needs a service worker precaching the HTML/JS/CSS, which is a build-level concern (the exact list of files to cache is whatever your bundler outputs), not something a runtime library can inject. This package doesn't ship a service worker implementation for that reason — instead:
411
+
412
+ - Use a standard Vite PWA setup [`vite-plugin-pwa`](https://vite-pwa-org.netlify.app/) is the common choice, and requires no noteloom-specific configuration; a working example is in `examples/offline-persist/vite.config.js`.
413
+ - `useServiceWorkerUpdate()` (exported from the package) is the one genuinely reusable piece: it watches for a newly-installed service worker sitting in the "waiting" state (the standard signal a fresh build is ready) and gives you a way to activate it —
414
+
415
+ ```js
416
+ import { useServiceWorkerUpdate } from 'noteloom';
417
+
418
+ function UpdateBanner() {
419
+ const { updateAvailable, applyUpdate } = useServiceWorkerUpdate();
420
+ if (!updateAvailable) return null;
421
+ return <button onClick={applyUpdate}>Update available — reload</button>;
422
+ }
423
+ ```
424
+
425
+ Works with any service worker registration, however it got there — it only observes, it doesn't register one itself.
426
+
427
+ Run `npm run dev:offline-persist`, then `npx vite build --config examples/offline-persist/vite.config.js && npx vite preview --config examples/offline-persist/vite.config.js` to try the built (not dev-mode) version — service workers only activate on a real build. Load it once online, then disconnect entirely and reload: the app shell still loads, and editing/persistence both keep working, since IndexedDB has no network dependency of its own.
428
+
429
+ ## Live collaboration (experimental)
430
+
431
+ Real-time multi-peer editing, built as a custom **block-tree CRDT** — not a generic text-CRDT library bolted on — so it stays true to the zero-runtime-dependency design. Peers connect directly over WebRTC; you bring your own signaling (a WebSocket relay, Firebase/Supabase realtime, or anything else that can pass small JSON messages between two peers) to bootstrap the connection.
432
+
433
+ ```jsx
434
+ import { useEditor, NoteloomEditor, CollabSession } from 'noteloom';
435
+ import { useEffect } from 'react';
436
+
437
+ function App() {
438
+ const editor = useEditor({ doc: myDoc });
439
+
440
+ useEffect(() => {
441
+ // `signaling` is any object shaped like SignalingChannel (src/sync/signaling.js):
442
+ // { localPeerId, send(toPeerId, message), onMessage(cb) }
443
+ const session = new CollabSession({ history: editor.store, signaling });
444
+ session.connect(remotePeerId, { initiator: true }); // `initiator: true` on exactly one side of each pair
445
+ return () => session.destroy();
446
+ }, []);
447
+
448
+ return <NoteloomEditor editor={editor} />;
449
+ }
450
+ ```
451
+
452
+ From then on, every edit made via `editor.store` (typing, inserting/moving/deleting blocks, "Turn into" type conversions) is automatically broadcast to connected peers, and incoming changes merge in live.
453
+
454
+ ### Signaling options
455
+
456
+ `CollabSession` only needs *something* that can pass small JSON messages between two peers to bootstrap their WebRTC connection — it never needs to touch the internet itself. Two ready-to-use signaling backends:
457
+
458
+ - **Same-browser demo, zero server** — `examples/collab/` uses the native `BroadcastChannel` API so every tab open on the same machine can find and sync with each other. Run `npm run dev:collab` and open the URL in two tabs. Good for trying the feature out; only works within one browser.
459
+ - **Real multi-device collaboration — same WiFi/LAN, no internet required, or over the open internet if you point it at a public host** — `createWebSocketSignaling()` (exported from the package) connects to a small relay server that only ever sees connection-setup messages, never document content:
460
+
461
+ ```js
462
+ import { createWebSocketSignaling, CollabSession } from 'noteloom';
463
+
464
+ const signaling = createWebSocketSignaling({
465
+ url: 'ws://192.168.1.5:8080', // a relay running on your LAN -- or any host, if you want internet-wide instead
466
+ roomId: 'my-document-id', // anyone using the same roomId ends up in the same room
467
+ peerId: crypto.randomUUID(),
468
+ });
469
+ const session = new CollabSession({ history: editor.store, signaling });
470
+
471
+ signaling.onPeerDiscovered((remotePeerId) => {
472
+ const initiator = signaling.localPeerId > remotePeerId; // deterministic tie-break
473
+ session.connect(remotePeerId, { initiator });
474
+ });
475
+ ```
476
+
477
+ A minimal reference relay server (Node, `ws`-based, ~80 lines, **not** part of the npm package) lives in `tools/lan-relay-server/` — see its README for how to run it and the wire protocol. A full runnable example wiring it up is in `examples/lan-collab/` — run `npm run dev:lan-collab` (after starting the relay), open the URL in two tabs, and it works with zero internet connectivity as long as both tabs can reach the relay.
478
+
479
+ ### Presence / awareness (live cursors, who's online)
480
+
481
+ `CollabSession` also carries ephemeral "here's where I am" data alongside the document sync — entirely separate from the document CRDT (never persisted, never merge-conflicted, just "whatever the last message said"):
482
+
483
+ ```js
484
+ import { usePresence } from 'noteloom';
485
+
486
+ // broadcast your own position (throttled automatically, ~100ms by default)
487
+ session.setLocalPresence({ runId: caret.runId, offset: caret.offset, name: 'Alex' });
488
+
489
+ // react to everyone else's, reactively
490
+ function PeerCursors({ session }) {
491
+ const presence = usePresence(session); // Map<peerId, data>, re-renders on change
492
+ return [...presence.entries()].map(([peerId, data]) => /* render however you like */);
493
+ }
494
+ ```
495
+
496
+ What presence *contains* is entirely up to you — a cursor position, a display name, a color, a "currently viewing" flag — `CollabSession` only relays the data, it never inspects or interprets it. A peer's entry disappears from `usePresence`'s map the instant they disconnect, and a newly-joining peer receives everyone's already-set presence immediately rather than waiting for their next move. `examples/collab/` renders this as live colored carets with peer-id labels, resolving `{runId, offset}` to an on-screen position the same way the editor's own selection code does (via the `[data-run-id]` DOM convention) — see `PeerCursors` in its `App.jsx` for the full (host-app-level, not package-level) rendering logic.
497
+
498
+ **How conflicts resolve:**
499
+ - Concurrent inserts (even at the same position) — both survive, converging to the same order on every peer.
500
+ - Concurrent delete vs. edit of the same block — the delete wins.
501
+ - Concurrent type-conversion of the same block ("Turn into") — one type wins deterministically (the same one, on every peer), not two duplicate blocks.
502
+ - Concurrent edits to a run's text — whole-value last-write-wins (the newer edit replaces the older one entirely; character-level interleaving is not implemented).
503
+
504
+ ### Tombstone garbage collection
505
+
506
+ Deleted blocks/runs are kept as "tombstones" rather than actually removed — necessary so a concurrent operation that references a since-deleted item (an insert anchored to it, say) can still resolve correctly no matter when it arrives. Left alone, this grows without bound over a long enough session. To actually reclaim that memory:
507
+
508
+ ```js
509
+ import { useEditor, createPeriodicTombstoneGC } from 'noteloom';
510
+
511
+ const editor = useEditor({ doc: myDoc });
512
+ const gc = createPeriodicTombstoneGC({ store: editor.store, intervalMs: 60 * 60 * 1000, maxAgeMs: 24 * 60 * 60 * 1000 }); // hourly sweep, 24h retention (both defaults, shown explicitly)
513
+
514
+ // later, when the store is no longer in use:
515
+ gc.stop();
516
+ ```
517
+
518
+ Or call `store.pruneTombstones({ maxAgeMs })` yourself on whatever schedule you want — `createPeriodicTombstoneGC` is just a thin timer wrapper around it. `store.getTombstoneCount()` tells you how many are currently being retained, if you want to observe growth before deciding on a policy. Both work identically whether `store` is a plain `EditorStore` or a `History` wrapping one, and pruning is never itself an undo step (it doesn't change the visible document — the pruned content was already invisible).
519
+
520
+ **Why a time-based threshold is safe here specifically:** this only works because of how `CollabSession` reconnects — a peer rejoining after any absence gets a full document *snapshot* (`syncResponse`), never a replay of the ops it missed. That means a peer offline longer than the GC threshold never needs an old tombstone to resolve a stale reference; it just adopts the current state directly. The only residual risk is a single *already-connected* peer somehow stalling for exactly as long as the threshold and then delivering a queued message afterward — implausible for a live, reliable, ordered WebRTC data channel (which disconnects long before that under any real interruption), but not impossible, which is why this is opt-in rather than automatic.
521
+
522
+ **Known limitations — read before relying on this in production:**
523
+ - **Undo is local-only, and can overwrite a peer's edit to the same run.** Your undo/redo never touches a peer's changes directly — but because text merges as *whole-value* LWW (see above), undoing your own past edit to a run replays an old full-string snapshot, which will clobber anything a peer has since typed into that same run. Avoid undoing text you know a peer may have touched; a true fix requires character-level text merging, which is a deliberately larger, not-yet-built change.
524
+ - **Deleted content isn't garbage-collected automatically, but can be — opt-in.** Tombstones are kept by default (needed so a late-arriving concurrent operation can still resolve correctly), which means unbounded memory growth over a long enough session unless you do something about it. `store.pruneTombstones({ maxAgeMs })` (default 24h) removes tombstones older than that safely — see "Tombstone garbage collection" above. Nothing calls this automatically; wire up `createPeriodicTombstoneGC` (or call it yourself) if you want it handled for you.
525
+ - **A peer joining with their own existing (different) document does not merge with yours.** `CollabSession` only adopts a peer's document wholesale when your own side is still empty — the common "open a shared link and get the document" flow. Reconciling two independently-created, already-diverged documents on first contact is a fundamentally harder problem (no shared id space) and isn't attempted.
526
+ - **Reconnecting after a dropped connection re-syncs the full document**, not just what was missed — simple and correct, at the cost of O(document size) traffic per reconnect.
527
+ - Only structural block changes and field edits (props, type, run text) are collaboration-aware. A few coarse "resync" operations (`setBlockContentIds`, `replaceRunSpan`, `setBlockRuns` — used for DOM-reconciliation escape hatches like paste-into-contentEditable or IME composition) remain local-only for now.
528
+ - Large single messages (e.g. an embedded video/file's `data:` URL, or a full-document `syncResponse` for a big document) are transparently fragmented, flow-controlled against the data channel's own backpressure, and reassembled under the hood — you don't need to do anything for this, but very large embeds mean more individual send calls and somewhat higher latency to fully arrive.
529
+
530
+ ---
531
+
532
+ # Advanced: the granular API
533
+
534
+ Everything above is `useEditor()`/`<NoteloomEditor>` — a convenience layer over the pieces below, nothing hidden behind them. This section is for when you need more control than that gives you: a custom toolbar, mobile chrome mounted separately, or a hand-rolled surface element.
535
+
536
+ ## Building the editor by hand
537
+
538
+ ```jsx
539
+ import {
540
+ EditorStore,
541
+ History,
542
+ EditorProvider,
543
+ BlockChildren,
544
+ createBlockRegistry,
545
+ registerBuiltInBlocks,
546
+ createInlineRegistry,
547
+ registerBuiltInInlineTypes,
548
+ useClipboardHandlers,
549
+ useSlashMenuTrigger,
550
+ useEditorKeyboardShortcuts,
551
+ SlashMenu,
552
+ } from 'noteloom';
553
+ import { useMemo, useRef } from 'react';
554
+
555
+ function Editor() {
556
+ const containerRef = useRef(null);
557
+ const { store, registry, inlineRegistry } = useMemo(() => {
558
+ const registry = createBlockRegistry();
559
+ registerBuiltInBlocks(registry);
560
+ const inlineRegistry = createInlineRegistry();
561
+ registerBuiltInInlineTypes(inlineRegistry);
562
+ const store = new History(
563
+ new EditorStore({
564
+ rootId: 'root',
565
+ blocks: [
566
+ { id: 'root', type: 'page', parentId: null, contentIds: ['p1'], props: {} },
567
+ { id: 'p1', type: 'paragraph', parentId: 'root', contentIds: ['r1'], props: {} },
568
+ ],
569
+ runs: [{ id: 'r1', type: 'text', value: 'Hello — try typing "/" for commands.', marks: {} }],
570
+ }),
571
+ );
572
+ return { store, registry, inlineRegistry };
573
+ }, []);
574
+
575
+ const { onCopy, onCut, onPaste } = useClipboardHandlers();
576
+ const slashMenu = useSlashMenuTrigger(containerRef);
577
+ useEditorKeyboardShortcuts(containerRef);
578
+
579
+ return (
580
+ <EditorProvider store={store} registry={registry} inlineRegistry={inlineRegistry} history={store}>
581
+ <div ref={containerRef} onCopy={onCopy} onCut={onCut} onPaste={onPaste}>
582
+ <BlockChildren parentId="root" />
583
+ <SlashMenu
584
+ isOpen={slashMenu.isOpen}
585
+ rect={slashMenu.rect}
586
+ commands={slashMenu.commands}
587
+ runId={slashMenu.runId}
588
+ onSelect={slashMenu.selectCommand}
589
+ onClose={slashMenu.close}
590
+ />
591
+ </div>
592
+ </EditorProvider>
593
+ );
594
+ }
595
+ ```
596
+
597
+ See `examples/basic` for a complete working app built this way (run `npm run dev`) — it wires up everything the Basic guide above covers individually (mobile chrome, voice typing, export, field-type management, ...) from these same granular pieces.
598
+
599
+ ## Registering a brand-new block/inline type, from scratch
600
+
601
+ The [Basic guide](#basic-guide) above covers *picking* existing types and *configuring* dropdown/mention field types via `createSelectFieldType` — no component required for either. Writing an entirely new block or inline type (its own React component, HTML/plain-text serialization, its own slash command) is the one thing that's inherently advanced regardless of which path built your registry:
602
+
603
+ ```js
604
+ registry.register('myBlock', {
605
+ component: MyBlockComponent, // receives only { id }
606
+ isLeaf: true, // true if contentIds holds run ids, false if it holds child block ids
607
+ toHTML(block, ctx) { /* ... */ },
608
+ fromHTML(domNode, ctx) { /* ... or return null if this node isn't yours */ },
609
+ toPlainText(block, ctx) { /* ... */ },
610
+ slashCommand: { label: 'My Block', keywords: ['my'], run(store, ctx) { /* ... */ } },
611
+ });
612
+ ```
613
+
614
+ `registry` here is `editor.registry` from `useEditor()` (call this inside the `registerBlocks` callback shown in [Picking only the blocks/inline types you want](#picking-only-the-blocksinline-types-you-want)) or a hand-built one from above — both work identically. `examples/02-custom-block/` is a complete, runnable, non-text example (a 5-star rating widget) with comments walking through every field.
615
+
616
+ ---
617
+
618
+ # Development
619
+
620
+ ```bash
621
+ npm install
622
+ npm run dev:quickstart # examples/01-quickstart — useEditor()/<NoteloomEditor>
623
+ npm run dev # examples/basic — the same editor built from the granular API
624
+ npm test # vitest (jsdom + @testing-library/react)
625
+ npm run typecheck # tsc --noEmit against src/index.d.ts
626
+ npm run build # library build (dist/, ESM + CJS + index.d.ts)
627
+ ```
628
+
629
+ See `examples/README.md` for the rest of the runnable examples, and `CONTRIBUTING.md` for the full contributor guide.
630
+
631
+ ## Known limitations
632
+
633
+ - No accessibility affordance exists for grouping sibling list items under a shared `role="list"` container (each list item is an independent block, not wrapped in one) — adding `role="listitem"` without that ancestor would be worse than no role at all, so it's deliberately left out pending a bigger structural change.
634
+ - `<NoteloomEditor>` renders `role="document"`/`aria-label` on its own surface element; if you build the surface yourself via the granular API (no library-rendered root element there — see `examples/basic/src/App.jsx`'s `EditorSurface`), add those attributes yourself the same way.
635
+ - Cross-block mark toggling (bold/italic/underline over a selection spanning multiple blocks) applies as one store operation per block, not a single atomic undo step.
636
+ - `select`'s option-adding UI and any `createSelectFieldType`-based type's options (e.g. an "Assignee" @-mention) are meant as a starting point — a real app will want to wire its own people/options source.
637
+ - RTL support covers direction resolution (`dir="auto"` + per-block/document override) and the highest-impact visual pieces (list markers, blockquote border, block gutter position) — a full logical-properties rewrite of every hardcoded pixel value in `style.css` is a bigger follow-up, not yet done.
638
+ - Voice typing (`useVoiceTyping`) only acts on *finalized* speech results, not interim/in-progress ones, and command detection requires a spoken command to be its own complete utterance — there's no explicit "command mode" trigger (push-to-command, wake phrase) yet, just pause-based auto-detection.
639
+ - Automated tests run under jsdom; there is no automated real-browser test suite. If you hit an edge case jsdom can't reproduce (anything involving actual native `contentEditable` browser quirks, or the real Web Speech API), please file an issue with the exact browser/OS and steps.