noteloom 0.3.3 → 0.4.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.
Files changed (104) hide show
  1. package/README.md +284 -55
  2. package/bin/noteloom.mjs +139 -0
  3. package/dist/canvas.cjs +2 -0
  4. package/dist/canvas.cjs.map +1 -0
  5. package/dist/canvas.d.ts +2 -0
  6. package/dist/canvas.js +5 -0
  7. package/dist/canvas.js.map +1 -0
  8. package/dist/collab.cjs +2 -0
  9. package/dist/collab.cjs.map +1 -0
  10. package/dist/collab.d.ts +19 -0
  11. package/dist/collab.js +379 -0
  12. package/dist/collab.js.map +1 -0
  13. package/dist/comments.cjs +2 -0
  14. package/dist/comments.cjs.map +1 -0
  15. package/dist/comments.d.ts +17 -0
  16. package/dist/comments.js +19 -0
  17. package/dist/comments.js.map +1 -0
  18. package/dist/index.cjs +2 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.ts +372 -33
  21. package/dist/index.js +3983 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/persistence.cjs +2 -0
  24. package/dist/persistence.cjs.map +1 -0
  25. package/dist/persistence.d.ts +11 -0
  26. package/dist/persistence.js +12 -0
  27. package/dist/persistence.js.map +1 -0
  28. package/dist/shared/CommentAvatar-TKqsM8vG.js +53 -0
  29. package/dist/shared/CommentAvatar-TKqsM8vG.js.map +1 -0
  30. package/dist/shared/CommentAvatar-V457IgoQ.cjs +2 -0
  31. package/dist/shared/CommentAvatar-V457IgoQ.cjs.map +1 -0
  32. package/dist/shared/CommentThreadCard-CAJ3oPdr.cjs +2 -0
  33. package/dist/shared/CommentThreadCard-CAJ3oPdr.cjs.map +1 -0
  34. package/dist/shared/CommentThreadCard-Cxf8GwHJ.js +213 -0
  35. package/dist/shared/CommentThreadCard-Cxf8GwHJ.js.map +1 -0
  36. package/dist/shared/CommentsPanel-BwVVyMOC.js +76 -0
  37. package/dist/shared/CommentsPanel-BwVVyMOC.js.map +1 -0
  38. package/dist/shared/CommentsPanel-CES4uzye.cjs +2 -0
  39. package/dist/shared/CommentsPanel-CES4uzye.cjs.map +1 -0
  40. package/dist/shared/VersionHistory-Bg6fufP5.js +1120 -0
  41. package/dist/shared/VersionHistory-Bg6fufP5.js.map +1 -0
  42. package/dist/shared/VersionHistory-DELa2Wbq.cjs +12 -0
  43. package/dist/shared/VersionHistory-DELa2Wbq.cjs.map +1 -0
  44. package/dist/shared/VoiceListeningIndicator-BOfVmLK0.js +338 -0
  45. package/dist/shared/VoiceListeningIndicator-BOfVmLK0.js.map +1 -0
  46. package/dist/shared/VoiceListeningIndicator-DNX8JlQM.cjs +2 -0
  47. package/dist/shared/VoiceListeningIndicator-DNX8JlQM.cjs.map +1 -0
  48. package/dist/shared/fieldRegistry-BoYyW3GH.cjs +2 -0
  49. package/dist/shared/fieldRegistry-BoYyW3GH.cjs.map +1 -0
  50. package/dist/shared/fieldRegistry-PjJWXFoB.js +223 -0
  51. package/dist/shared/fieldRegistry-PjJWXFoB.js.map +1 -0
  52. package/dist/shared/icons-C1_s0jKK.js +4269 -0
  53. package/dist/shared/icons-C1_s0jKK.js.map +1 -0
  54. package/dist/shared/icons-DtCsXuU6.cjs +3531 -0
  55. package/dist/shared/icons-DtCsXuU6.cjs.map +1 -0
  56. package/dist/shared/index-B9jyyL8u.js +2145 -0
  57. package/dist/shared/index-B9jyyL8u.js.map +1 -0
  58. package/dist/shared/index-D9WEXpYM.cjs +2 -0
  59. package/dist/shared/index-D9WEXpYM.cjs.map +1 -0
  60. package/dist/shared/indexedDbPersistence-BKlGPFTP.js +112 -0
  61. package/dist/shared/indexedDbPersistence-BKlGPFTP.js.map +1 -0
  62. package/dist/shared/indexedDbPersistence-BWJ1h5Vp.cjs +2 -0
  63. package/dist/shared/indexedDbPersistence-BWJ1h5Vp.cjs.map +1 -0
  64. package/dist/shared/leafBlockFactory-A9eUhSe-.js +335 -0
  65. package/dist/shared/leafBlockFactory-A9eUhSe-.js.map +1 -0
  66. package/dist/shared/leafBlockFactory-Dp382eP8.cjs +2 -0
  67. package/dist/shared/leafBlockFactory-Dp382eP8.cjs.map +1 -0
  68. package/dist/shared/serialize-DWEfk0vU.cjs +3 -0
  69. package/dist/shared/serialize-DWEfk0vU.cjs.map +1 -0
  70. package/dist/shared/serialize-T9Odwx24.js +86 -0
  71. package/dist/shared/serialize-T9Odwx24.js.map +1 -0
  72. package/dist/shared/starter-kit-CY5wzYDf.js +4980 -0
  73. package/dist/shared/starter-kit-CY5wzYDf.js.map +1 -0
  74. package/dist/shared/starter-kit-lPN-NpJ5.cjs +27 -0
  75. package/dist/shared/starter-kit-lPN-NpJ5.cjs.map +1 -0
  76. package/dist/shared/usePopoverEdgeClamp-CDG26jBM.cjs +2 -0
  77. package/dist/shared/usePopoverEdgeClamp-CDG26jBM.cjs.map +1 -0
  78. package/dist/shared/usePopoverEdgeClamp-gmFm0CnI.js +109 -0
  79. package/dist/shared/usePopoverEdgeClamp-gmFm0CnI.js.map +1 -0
  80. package/dist/shared/useServiceWorkerUpdate-BduabXv6.js +94 -0
  81. package/dist/shared/useServiceWorkerUpdate-BduabXv6.js.map +1 -0
  82. package/dist/shared/useServiceWorkerUpdate-DQboGesi.cjs +2 -0
  83. package/dist/shared/useServiceWorkerUpdate-DQboGesi.cjs.map +1 -0
  84. package/dist/starter-kit.cjs +2 -0
  85. package/dist/starter-kit.cjs.map +1 -0
  86. package/dist/starter-kit.d.ts +9 -0
  87. package/dist/starter-kit.js +10 -0
  88. package/dist/starter-kit.js.map +1 -0
  89. package/dist/style.css +3529 -3283
  90. package/dist/versions.cjs +2 -0
  91. package/dist/versions.cjs.map +1 -0
  92. package/dist/versions.d.ts +11 -0
  93. package/dist/versions.js +13 -0
  94. package/dist/versions.js.map +1 -0
  95. package/dist/voice.cjs +2 -0
  96. package/dist/voice.cjs.map +1 -0
  97. package/dist/voice.d.ts +7 -0
  98. package/dist/voice.js +8 -0
  99. package/dist/voice.js.map +1 -0
  100. package/package.json +159 -92
  101. package/dist/noteloom.cjs +0 -3292
  102. package/dist/noteloom.cjs.map +0 -1
  103. package/dist/noteloom.es.js +0 -15047
  104. package/dist/noteloom.es.js.map +0 -1
package/README.md CHANGED
@@ -78,6 +78,24 @@ Everything past this point is a reference guide, in two parts:
78
78
 
79
79
  Every example below uses `editor`/`store`/`registry`/`inlineRegistry` from `useEditor()` (`const { store, registry, inlineRegistry } = editor;`) unless it says otherwise.
80
80
 
81
+ ## Import paths
82
+
83
+ The basic editor is one import — `import { useEditor, NoteloomEditor } from 'noteloom'` — and nothing below changes that. The heavier, optional features also have their own entry points so a bundler can drop the ones you don't use:
84
+
85
+ | Import | What's in it |
86
+ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
87
+ | `noteloom` | the editor, every built-in block/inline type, slash menu, clipboard, undo/redo, export, templates, find & replace, presence |
88
+ | `noteloom/theme` (or `noteloom/style.css`) | the default theme stylesheet |
89
+ | `noteloom/collab` | `CollabSession`, `PeerConnection`, the CRDT primitives, WebSocket signaling |
90
+ | `noteloom/persistence` | `usePersistedDocument`, `createAutoPersistence`, the raw IndexedDB ops, `useServiceWorkerUpdate` |
91
+ | `noteloom/comments` | `addComment`/`replyToComment`/…, `useComments`, `CommentsPanel` and the other comment components |
92
+ | `noteloom/versions` | `createAutoVersionHistory`, `VersionHistory`, `diffDocumentsHTML`, `useDocumentVersions` |
93
+ | `noteloom/voice` | `useVoiceTyping`, `VoicePermissionModal`, `VoiceListeningIndicator`, `listVoiceCommands` |
94
+ | `noteloom/canvas` | `canvasBlockType` (the freehand-drawing block — the single heaviest component, so it's opt-in) |
95
+ | `noteloom/starter-kit` | `starterKit()`, `defineBlock`, `defineInline`, `registerExtensions` — the extension-authoring workflow |
96
+
97
+ Every name in those feature entries is **also still exported from `noteloom`** itself, so existing imports keep working unchanged. Prefer the subpath in new code — the main-entry re-exports of these will be removed in a future major version (see `docs/repackaging-plan.md`).
98
+
81
99
  ## Built-in block types
82
100
 
83
101
  `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).
@@ -96,8 +114,8 @@ You don't need to import any CSS. The moment `<NoteloomEditor>` mounts, it injec
96
114
 
97
115
  ```css
98
116
  :root {
99
- --noteloom-accent: #16a34a; /* swap the indigo accent for green */
100
- --noteloom-radius-md: 4px; /* sharper corners */
117
+ --noteloom-accent: #16a34a; /* swap the indigo accent for green */
118
+ --noteloom-radius-md: 4px; /* sharper corners */
101
119
  --noteloom-font: 'Inter', sans-serif;
102
120
  }
103
121
  ```
@@ -112,7 +130,7 @@ Dark mode follows `prefers-color-scheme` automatically; to control it explicitly
112
130
 
113
131
  No wrapper `<div>` is added unless you pass one of these props, so existing usage is unaffected either way.
114
132
 
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):
133
+ **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
134
 
117
135
  ```jsx
118
136
  <NoteloomEditor editor={editor} theme="none" />
@@ -123,7 +141,10 @@ No wrapper `<div>` is added unless you pass one of these props, so existing usag
123
141
  **Customize individual blocks**, not just the root, via `getBlockClassName`:
124
142
 
125
143
  ```jsx
126
- <NoteloomEditor editor={editor} getBlockClassName={(block) => (block.type === 'callout' ? 'my-callout' : undefined)} />
144
+ <NoteloomEditor
145
+ editor={editor}
146
+ getBlockClassName={(block) => (block.type === 'callout' ? 'my-callout' : undefined)}
147
+ />
127
148
  ```
128
149
 
129
150
  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,11 +154,23 @@ Whatever string you return is appended onto that block's own root element's clas
133
154
  `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
155
 
135
156
  ```jsx
136
- import { useEditor, NoteloomEditor, registerBlocks, paragraphBlockType, headingBlockType, TABLE_BLOCKS } from 'noteloom';
157
+ import {
158
+ useEditor,
159
+ NoteloomEditor,
160
+ registerBlocks,
161
+ paragraphBlockType,
162
+ headingBlockType,
163
+ TABLE_BLOCKS,
164
+ } from 'noteloom';
137
165
 
138
166
  function Editor() {
139
167
  const editor = useEditor({
140
- registerBlocks: (registry) => registerBlocks(registry, { paragraph: paragraphBlockType, heading: headingBlockType, ...TABLE_BLOCKS }),
168
+ registerBlocks: (registry) =>
169
+ registerBlocks(registry, {
170
+ paragraph: paragraphBlockType,
171
+ heading: headingBlockType,
172
+ ...TABLE_BLOCKS,
173
+ }),
141
174
  });
142
175
  return <NoteloomEditor editor={editor} />;
143
176
  }
@@ -163,12 +196,77 @@ function Editor() {
163
196
 
164
197
  `examples/02-custom-block/` is a complete runnable version of this pattern.
165
198
 
199
+ **Scaffold one:** `npx noteloom new block <name>` (or `new inline <name>`) writes a `<name>/` folder with a `defineBlock()`/`defineInline()` starting point, a component, and a test.
200
+
201
+ ### `defineBlock` / `extensions` — the newer way
202
+
203
+ The `registerBlocks` callback above still works, but the recommended way to author and register a type is now `defineBlock()` / `defineInline()` plus an `extensions` array:
204
+
205
+ ```jsx
206
+ import { useEditor, NoteloomEditor, defineBlock, starterKit } from 'noteloom';
207
+ // (defineBlock/defineInline/registerExtensions/starterKit are also on `noteloom/starter-kit`)
208
+
209
+ const rating = defineBlock({
210
+ name: 'rating', // the block `type`
211
+ component: RatingBlock, // your React component, gets { id }
212
+ contentModel: 'void', // 'blocks' (default) | 'runs' | 'void' — sets isLeaf
213
+ defaultProps: { stars: 0 },
214
+ toHTML: (block) => `<div data-stars="${block.props.stars}"></div>`,
215
+ slashCommand: { label: 'Rating', keywords: ['stars'], run: /* … */ },
216
+ });
217
+
218
+ function Editor() {
219
+ const editor = useEditor({
220
+ extensions: [...starterKit(), rating], // every built-in, plus yours
221
+ });
222
+ return <NoteloomEditor editor={editor} />;
223
+ }
224
+ ```
225
+
226
+ - `starterKit()` is every built-in block + inline type as an array; `starterKit({ exclude: ['canvas'] })` drops some. `useEditor()` with no `extensions` registers exactly this same set.
227
+ - Passing `extensions` turns off the automatic built-ins (it's opt-in, like `registerBlocks`) — spread `starterKit()` in if you want them. A `registerBlocks` callback passed alongside `extensions` still runs, on top.
228
+ - `defineBlock` validates its config and throws on obvious mistakes (missing `name`/`component`, bad `contentModel`). The result is still a plain registry entry, so `registry.register('rating', rating)` also works.
229
+ - `registerExtensions(array, { registry, inlineRegistry })` does the same registration against registries you made yourself.
230
+ - `defineExtension({ name, blocks?, inlineTypes?, keymap?, onBeforeInput?, onPaste?, setup? })` is one extension unit for the `extensions` array. Beyond bundling types, it carries **behavior**:
231
+
232
+ ```jsx
233
+ import { useEditor, NoteloomEditor, defineExtension, smartQuotes } from 'noteloom';
234
+
235
+ const clearFormatting = defineExtension({
236
+ name: 'clear-formatting',
237
+ keymap: {
238
+ 'Mod-\\': (ctx) => {
239
+ // ctx: { store, registry, inlineRegistry, container, getBlock, getRun,
240
+ // getRootId, applyOperation, applyOperations, getSelection,
241
+ // getCaret, setCaret, subscribe }
242
+ /* …strip marks over ctx.getSelection()… */
243
+ return true; // truthy = handled: preventDefault + don't let built-ins see it
244
+ },
245
+ },
246
+ setup: (ctx) => {
247
+ const stop = ctx.subscribe(() => {
248
+ /* react to every change */
249
+ });
250
+ return stop; // cleanup on unmount
251
+ },
252
+ });
253
+
254
+ const editor = useEditor({ extensions: [...starterKit(), smartQuotes(), clearFormatting] });
255
+ ```
256
+
257
+ `keymap` keys use `Mod` (Ctrl/Cmd), `Shift`, `Alt`; `onBeforeInput` / `onPaste` get `(ctx, event)` and follow the same "return truthy = handled" rule. `smartQuotes()` and `autoPairBrackets()` are ready-made ones. (Markdown-style "# " → heading rules are still block-coupled and not expressible here yet.)
258
+
166
259
  ## Custom dropdown / mention field types (static, or dynamic/API-backed)
167
260
 
168
261
  `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
262
 
170
263
  ```jsx
171
- import { useEditor, NoteloomEditor, registerBuiltInInlineTypes, createSelectFieldType } from 'noteloom';
264
+ import {
265
+ useEditor,
266
+ NoteloomEditor,
267
+ registerBuiltInInlineTypes,
268
+ createSelectFieldType,
269
+ } from 'noteloom';
172
270
 
173
271
  const statusFieldType = createSelectFieldType({
174
272
  type: 'status', // must match the key you register it under
@@ -195,7 +293,7 @@ function Editor() {
195
293
 
196
294
  `examples/03-custom-field-type/` is a complete runnable version of this pattern.
197
295
 
198
- Inserting one from "/" or "@" opens its picker immediately, focused and ready to search — no second click needed to actually pick something right after inserting it. Picking a value then moves focus straight on to the next block, so filling in a form-like document ("Diagnosis: [pick]", then the next line, then the next...) is a smooth insert → pick → keep-typing flow. Reopening an *existing* chip elsewhere to change its value later doesn't also jump away — this only applies right after a fresh insertion.
296
+ Inserting one from "/" or "@" opens its picker immediately, focused and ready to search — no second click needed to actually pick something right after inserting it. Picking a value then moves focus straight on to the next block, so filling in a form-like document ("Diagnosis: [pick]", then the next line, then the next...) is a smooth insert → pick → keep-typing flow. Reopening an _existing_ chip elsewhere to change its value later doesn't also jump away — this only applies right after a fresh insertion.
199
297
 
200
298
  `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):
201
299
 
@@ -244,13 +342,21 @@ User-created types are persisted in the document's own `fieldTypes` collection (
244
342
 
245
343
  Once at least one is created this way, a table column set to "Select" type gets a **"Copy options from…"** dropdown in its own menu (alongside its usual "+ New field type" button) — pick one to seed the column's option list from it in one shot, instead of typing the same options out again by hand. It's a one-time copy, not a live link: renaming/adding/removing options on the column afterward never touches the source field type.
246
344
 
247
- ## Exporting the document (JSON / HTML / plain text)
345
+ ## Exporting the document (JSON / HTML / Markdown / Word / PDF / plain text)
248
346
 
249
347
  ```js
250
- import { exportDocumentJSON, exportDocumentHTML, exportDocumentText } from 'noteloom';
348
+ import {
349
+ exportDocumentJSON,
350
+ exportDocumentHTML,
351
+ exportDocumentMarkdown,
352
+ exportDocumentWordHTML,
353
+ exportDocumentText,
354
+ } from 'noteloom';
251
355
 
252
356
  exportDocumentJSON(store); // a JSON *string* — JSON.parse() it to get { version, rootId, blocks, runs }, usable as useEditor({ doc })
253
357
  exportDocumentHTML(store, registry, inlineRegistry);
358
+ exportDocumentMarkdown(store, registry, inlineRegistry); // headings, bold/italic/strike/code/links, lists (incl. GFM task lists), quotes, fenced code, tables
359
+ exportDocumentWordHTML(store, registry, inlineRegistry); // exportDocumentHTML wrapped with the Word MSO namespace — save with a .doc extension and Word opens it directly
254
360
  exportDocumentText(store, registry, inlineRegistry);
255
361
  ```
256
362
 
@@ -259,16 +365,32 @@ Or mount the ready-made button + modal instead of wiring your own UI:
259
365
  ```jsx
260
366
  import { DocumentExportButton } from 'noteloom';
261
367
 
262
- <DocumentExportButton label="View source" />
368
+ <DocumentExportButton label="View source" />;
263
369
  ```
264
370
 
265
- 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.
371
+ It opens a modal with JSON/Simple JSON/HTML/Markdown/Text tabs (reading live from the store every time it opens), a Copy button, and two direct-download actions:
372
+
373
+ - **Print / Save as PDF** — calls the browser's own `window.print()`; "Save as PDF" is a standard destination in every major browser's print dialog, and the editor's own `@media print` stylesheet already hides all editor-only chrome (toolbars, menus, this modal itself), so what prints is just the document content. No PDF-writing code of any kind, in keeping with this package having zero runtime dependencies.
374
+ - **Download Word (.doc)** — downloads `exportDocumentWordHTML`'s output with a `.doc` extension. Not a real `.docx` (that's a zip of XML files, and hand-writing a zip container is out of scope for this package) — Word opens Word-flavored HTML saved as `.doc` directly via MIME sniffing, a well-known, dependency-free trick.
266
375
 
267
- ### A simpler JSON shape for storage/API/CRUD use
376
+ Useful for debugging, or as a starting point for a real "export" feature.
377
+
378
+ ### The document format
379
+
380
+ The **simple format** is the canonical one for storage / APIs / hand-editing: self-contained blocks in an array, `children` for nesting, each block's own fields under `data`, no id-references to resolve. `editor.toJSON()` returns it and `useEditor({ doc })` accepts it (the shape is auto-detected, so an internal-format doc still works):
381
+
382
+ ```jsx
383
+ const editor = useEditor();
384
+ const doc = editor.toJSON(); // { version: 1, blocks: [{ id, type, data, children? }] }
385
+ // ...store it, send it, edit it...
386
+ const editor2 = useEditor({ doc }); // loads it straight back
387
+ ```
268
388
 
269
- `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.
389
+ Its JSON Schema is published at [`docs/document.schema.json`](docs/document.schema.json) (`version: 1`); a CI test validates every export against it and checks `simple store simple` is byte-stable.
270
390
 
271
- If you just want something simpler to store, send over an API, or hand-editself-contained blocks in an array, `children` for nesting, no id-references to resolve use the second, optional export/import pair instead:
391
+ The **internal engine format** the normalized, id-referenced `{ rootId, blocks, runs }` graph `EditorStore` operates on is what makes per-run reactivity, O(1) structural edits, and real nesting work. It's unversioned and reachable via `editor.toJSON({ format: 'internal' })` / `exportDocumentJSON()` when you need it (collab, debugging), but it's an implementation detail, not something to build against.
392
+
393
+ The lower-level `exportDocumentSimpleJSON` / `importDocumentSimpleJSON` pair is still there for non-React use:
272
394
 
273
395
  ```js
274
396
  import { exportDocumentSimpleJSON, importDocumentSimpleJSON } from 'noteloom';
@@ -293,14 +415,14 @@ const json = exportDocumentSimpleJSON(store, registry, inlineRegistry);
293
415
 
294
416
  // ...later, or on a different machine/process:
295
417
  const doc = importDocumentSimpleJSON(json, registry, inlineRegistry); // -> { rootId, blocks, runs }
296
- const editor2 = useEditor({ doc }); // or `new EditorStore(doc)` directly outside React
418
+ const editor2 = useEditor({ doc: JSON.parse(json) }); // useEditor detects the shape; or new EditorStore(doc) outside React
297
419
  ```
298
420
 
299
421
  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).
300
422
 
301
- 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.
423
+ 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.
302
424
 
303
- 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.
425
+ 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.
304
426
 
305
427
  ## Templates
306
428
 
@@ -309,7 +431,12 @@ Two kinds — a **document template** seeds a whole new editor (`useEditor({ doc
309
431
  **Block templates — reusable snippets, insertable via "/":**
310
432
 
311
433
  ```js
312
- import { EditorStore, captureBlockTemplate, registerBlockTemplates, registerBuiltInBlocks } from 'noteloom';
434
+ import {
435
+ EditorStore,
436
+ captureBlockTemplate,
437
+ registerBlockTemplates,
438
+ registerBuiltInBlocks,
439
+ } from 'noteloom';
313
440
 
314
441
  // Build once (a throwaway store is fine — only its content is captured):
315
442
  const draftStore = new EditorStore({
@@ -317,7 +444,13 @@ const draftStore = new EditorStore({
317
444
  blocks: [
318
445
  { id: 'root', type: 'page', parentId: null, contentIds: ['h1', 'li1'], props: {} },
319
446
  { id: 'h1', type: 'heading', parentId: 'root', contentIds: ['r1'], props: { level: 2 } },
320
- { id: 'li1', type: 'listItem', parentId: 'root', contentIds: [], props: { ordered: true, titleRunIds: ['r2'] } },
447
+ {
448
+ id: 'li1',
449
+ type: 'listItem',
450
+ parentId: 'root',
451
+ contentIds: [],
452
+ props: { ordered: true, titleRunIds: ['r2'] },
453
+ },
321
454
  ],
322
455
  runs: [
323
456
  { id: 'r1', type: 'text', value: 'Meeting agenda', marks: {} },
@@ -329,19 +462,28 @@ const agendaSnippet = captureBlockTemplate(draftStore, ['h1', 'li1']);
329
462
  const editor = useEditor({
330
463
  registerBlocks: (registry) => {
331
464
  registerBuiltInBlocks(registry);
332
- registerBlockTemplates(registry, [{ id: 'agenda', label: 'Meeting agenda', keywords: ['agenda'], roots: agendaSnippet.roots }]);
465
+ registerBlockTemplates(registry, [
466
+ { id: 'agenda', label: 'Meeting agenda', keywords: ['agenda'], roots: agendaSnippet.roots },
467
+ ]);
333
468
  },
334
469
  });
335
470
  ```
336
471
 
337
- Typing "/agenda" now shows "Meeting agenda" in the slash menu, same as any built-in block — no changes needed to `SlashMenu`/`useSlashMenuTrigger`, since `registerBlockTemplates` registers under the hood exactly the way a real block type does (just one that's never actually rendered — only its *captured content*, which already has real block types, gets inserted). `insertBlockTemplate(store, template, { parentId, index })` does the same insertion directly, if you want a button instead of/alongside "/".
472
+ Typing "/agenda" now shows "Meeting agenda" in the slash menu, same as any built-in block — no changes needed to `SlashMenu`/`useSlashMenuTrigger`, since `registerBlockTemplates` registers under the hood exactly the way a real block type does (just one that's never actually rendered — only its _captured content_, which already has real block types, gets inserted). `insertBlockTemplate(store, template, { parentId, index })` does the same insertion directly, if you want a button instead of/alongside "/".
338
473
 
339
- **Document templates — starter documents:** no new primitives needed — a document template *is* a `DocumentJSON`, so `useEditor({ doc: someTemplate.doc })` already covers "start a new editor from it." To apply one to an **already-mounted** editor instead, use `applyDocumentTemplate(store, doc)`.
474
+ **Document templates — starter documents:** no new primitives needed — a document template _is_ a `DocumentJSON`, so `useEditor({ doc: someTemplate.doc })` already covers "start a new editor from it." To apply one to an **already-mounted** editor instead, use `applyDocumentTemplate(store, doc)`.
340
475
 
341
476
  **Saving/browsing a library of templates** (either kind), persisted so it survives reload:
342
477
 
343
478
  ```jsx
344
- import { useEditor, NoteloomEditor, useTemplates, TemplatePicker, saveTemplate, exportDocumentJSON } from 'noteloom';
479
+ import {
480
+ useEditor,
481
+ NoteloomEditor,
482
+ useTemplates,
483
+ TemplatePicker,
484
+ saveTemplate,
485
+ exportDocumentJSON,
486
+ } from 'noteloom';
345
487
 
346
488
  function NewDocumentScreen({ onPick }) {
347
489
  const { templates, isLoaded } = useTemplates({ scope: 'document' }); // or 'block', or omit for both
@@ -398,7 +540,14 @@ For the granular API, render the pieces yourself anywhere under an `<EditorProvi
398
540
  Pass `onComment` instead of `commentAuthorId` — it's called with the selected range and you decide what happens next (open your own modal, pick the author yourself):
399
541
 
400
542
  ```jsx
401
- import { addComment, replyToComment, resolveComment, deleteComment, useComments, resolveMultiRunSelection } from 'noteloom';
543
+ import {
544
+ addComment,
545
+ replyToComment,
546
+ resolveComment,
547
+ deleteComment,
548
+ useComments,
549
+ resolveMultiRunSelection,
550
+ } from 'noteloom';
402
551
 
403
552
  <NoteloomEditor
404
553
  editor={editor}
@@ -425,9 +574,21 @@ function CommentsSidebar({ store }) {
425
574
  <ul>
426
575
  {comments.map((thread) => (
427
576
  <li key={thread.id}>
428
- {thread.messages.map((m) => <p key={m.id}>{m.authorId}: {m.text}</p>)}
429
- <button onClick={() => replyToComment(store, thread.id, { authorId: currentUser.id, text: '...' })}>Reply</button>
430
- <button onClick={() => resolveComment(store, thread.id, !thread.resolved)}>{thread.resolved ? 'Reopen' : 'Resolve'}</button>
577
+ {thread.messages.map((m) => (
578
+ <p key={m.id}>
579
+ {m.authorId}: {m.text}
580
+ </p>
581
+ ))}
582
+ <button
583
+ onClick={() =>
584
+ replyToComment(store, thread.id, { authorId: currentUser.id, text: '...' })
585
+ }
586
+ >
587
+ Reply
588
+ </button>
589
+ <button onClick={() => resolveComment(store, thread.id, !thread.resolved)}>
590
+ {thread.resolved ? 'Reopen' : 'Resolve'}
591
+ </button>
431
592
  <button onClick={() => deleteComment(store, thread.id)}>Delete</button>
432
593
  </li>
433
594
  ))}
@@ -440,7 +601,7 @@ function CommentsSidebar({ store }) {
440
601
 
441
602
  A comment thread is `{ id, blockId, anchorRunIds, resolved, messages: [{ id, authorId, text, createdAt }] }`. `CommentThreadCard`/`CommentComposer` (the pieces `CommentPopover`/`CommentsPanel` are built from) are exported too, for reusing the built-in look while customizing the surrounding layout.
442
603
 
443
- **Scope, stated plainly:** a thread's own metadata (text, author, replies, resolved flag) is fully collaboration-aware — it broadcasts live to connected peers and undoes/redoes normally. The *highlighted range* it's anchored to is local-only in collaboration for v1: a newly-joining peer sees it correctly (full document snapshots always include it), but an already-connected peer won't see someone else's brand-new highlight appear live until their next resync. This isn't a new gap introduced by comments — every other range-based formatting operation (bold, italic, highlight, ...) already has this exact scope today, since none of them have a CRDT-safe wire representation yet.
604
+ **Scope, stated plainly:** a thread's own metadata (text, author, replies, resolved flag) is fully collaboration-aware — it broadcasts live to connected peers and undoes/redoes normally. The _highlighted range_ it's anchored to is local-only in collaboration for v1: a newly-joining peer sees it correctly (full document snapshots always include it), but an already-connected peer won't see someone else's brand-new highlight appear live until their next resync. This isn't a new gap introduced by comments — every other range-based formatting operation (bold, italic, highlight, ...) already has this exact scope today, since none of them have a CRDT-safe wire representation yet.
444
605
 
445
606
  `thread.anchorRunIds` is a creation-time hint only, meant for jumping to roughly where a comment was made — it is **not** re-validated after a later formatting edit splits or re-mints run ids in that range. To reliably find where a comment's highlight actually lives right now, look at which runs' `marks.commentIds` include it (exactly what `deleteComment` itself does internally via `removeCommentMarkEverywhere`), not `anchorRunIds`.
446
607
 
@@ -483,9 +644,47 @@ A block's own `dir` wins over the document's; the block gutter menu also has a "
483
644
 
484
645
  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.
485
646
 
647
+ ## Find & replace
648
+
649
+ Built into `<NoteloomEditor>` — Ctrl/Cmd+F, while the editor has focus, opens a find bar with a live match count, Previous/Next (wraps around), Match case / Whole word toggles, and an optional Replace/Replace All row. Only intercepts the shortcut while this editor has focus, so a host page's own native browser find elsewhere on the page is untouched.
650
+
651
+ Matches are scoped to a single text run — a search term split across a formatting boundary (e.g. half bold, half plain) or landing inside a non-text run (a select/date/mention chip) won't be found. Highlighting uses the [CSS Custom Highlight API](https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API) rather than inserting elements into the document — it paints purely at the rendering layer, so it can never interfere with the editor's own precise contentEditable-to-data sync. Older Firefox (no support for that API) still gets fully working search/navigate/replace, just without the visual highlight.
652
+
653
+ Building custom find UI, or using the granular API:
654
+
655
+ ```jsx
656
+ import { useFindInDocument, FindBar, findMatches, replaceAllMatches } from 'noteloom';
657
+
658
+ // Drop-in bar, same one NoteloomEditor already wires up:
659
+ function MyEditorSurface({ containerRef }) {
660
+ const find = useFindInDocument(containerRef);
661
+ return <FindBar {...find} />;
662
+ }
663
+
664
+ // Or work with matches directly, headless:
665
+ const matches = findMatches(store, 'hello', { caseSensitive: false, wholeWord: false });
666
+ replaceAllMatches(store, matches, 'hi');
667
+ ```
668
+
669
+ ## Table sort, filter & footer aggregates
670
+
671
+ Every table's column menu (the "⋮" trigger on each header cell) gains three extra tools, no configuration needed:
672
+
673
+ - **Sort ascending / Sort descending** — a real, one-time row reorder (like a spreadsheet's "Sort A→Z"), type-aware per column (text sorts case-insensitively and numerically when the values look like numbers, date by its actual date, checkbox unchecked-before-checked, select by its label). Blank cells always sort to the end. It's undoable like any other edit, but not a continuously-reapplied live view — editing a cell afterward doesn't re-trigger the sort.
674
+ - **Filter** — a text box; rows not containing the query are hidden from view. This is local, ephemeral UI state: nothing is written to the document, nothing syncs to collaborators, and it resets on reload — the real content is completely untouched, the same way `usePreviewMode`'s own "Hide in preview" is a display concern, not a data one.
675
+ - **Footer aggregate** — Count / Count filled / Count empty / Sum / Average / Min / Max, shown in a footer row, recomputed from whatever rows are currently visible (so it reflects an active filter). There's no formula/expression engine behind this — deliberately out of scope for a zero-runtime-dependency package with no sandboxed code-execution story — `sum`/`average`/`min`/`max` just parse each cell's own plain text as a number and skip whatever doesn't parse, so a plain "text" column full of numbers aggregates correctly without needing a dedicated "number" column type.
676
+
677
+ ```js
678
+ import { sortTableByColumn, setColumnAggregate, computeColumnAggregate } from 'noteloom';
679
+
680
+ sortTableByColumn(store, tableId, colIndex, 'asc', inlineRegistry);
681
+ setColumnAggregate(store, tableId, colIndex, 'sum'); // persisted column metadata — which aggregate to show
682
+ computeColumnAggregate(runs, columnType, 'sum', inlineRegistry); // the actual computed value, given the runs you want to include
683
+ ```
684
+
486
685
  ## Printing & PDF
487
686
 
488
- `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.
687
+ `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 find bar, 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.
489
688
 
490
689
  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:
491
690
 
@@ -493,7 +692,9 @@ There's no bundled PDF-generation library (that would need a real dependency lik
493
692
  window.print(); // Ctrl+P / Cmd+P works too — "Save as PDF" in the print dialog is your PDF export
494
693
  ```
495
694
 
496
- 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.
695
+ (`DocumentExportButton`'s own "Print / Save as PDF" button, see the exporting section above, is exactly this call.)
696
+
697
+ 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.
497
698
 
498
699
  ## Voice typing
499
700
 
@@ -513,7 +714,7 @@ function MicButton() {
513
714
  }
514
715
  ```
515
716
 
516
- 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.
717
+ 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.
517
718
 
518
719
  ## Mobile / touch support
519
720
 
@@ -523,7 +724,7 @@ Typing "/"/"@" still works on a phone keyboard, but it's not a reliable or disco
523
724
  import { MobileActionBar } from 'noteloom';
524
725
 
525
726
  // next to your other trigger hooks/components, same containerRef:
526
- <MobileActionBar containerRef={containerRef} />
727
+ <MobileActionBar containerRef={containerRef} />;
527
728
  ```
528
729
 
529
730
  `examples/basic` has this fully wired up (run `npm run dev`, then resize to a narrow viewport or open it on a phone).
@@ -539,7 +740,7 @@ It renders nothing on a mouse/trackpad, and nothing until focus is actually insi
539
740
 
540
741
  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).
541
742
 
542
- **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.
743
+ **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.
543
744
 
544
745
  **Not included**: a touch equivalent for dragging in the block gutter to select a range of blocks — most block editors keep that gesture desktop/mouse-only too.
545
746
 
@@ -580,14 +781,15 @@ const { isLoaded, save } = usePersistedDocument({
580
781
  ```
581
782
 
582
783
  Lower-level pieces, if `usePersistedDocument`'s all-in-one behavior doesn't fit (a non-React host app, custom load/save timing, etc.):
784
+
583
785
  - `savePersistedDocument(docId, doc)` / `loadPersistedDocument(docId)` / `deletePersistedDocument(docId)` / `listPersistedDocumentIds()` — the raw IndexedDB operations `usePersistedDocument` is built on.
584
786
  - `createAutoPersistence({ store, docId, debounceMs, onError })` — just the debounced auto-save half, if you want to handle the initial load yourself. Returns `{ stop, flush }` — `flush()` returns a Promise that resolves once the write actually lands (or immediately if there was nothing pending).
585
787
 
586
- 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.
788
+ 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.
587
789
 
588
790
  ### Offline app shell (PWA)
589
791
 
590
- `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:
792
+ `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:
591
793
 
592
794
  - 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`.
593
795
  - `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 —
@@ -637,7 +839,7 @@ A few things worth knowing:
637
839
  }).then((r) => r.json());
638
840
  await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });
639
841
  return { src: publicUrl };
640
- }
842
+ };
641
843
  ```
642
844
  Any other cloud storage (Cloudinary, Supabase Storage, R2, GCS, ...) is one of these two shapes — a single API call back with a hosted URL, or a signed-URL handshake — since this package only ever needs the final `{ src }`, not how it got there.
643
845
  - **Small/medium/large file handling** is entirely `uploadFile`'s own business, off `file.size` (bytes) — this package deliberately hardcodes no byte thresholds of its own, since what counts as "large" varies wildly by app:
@@ -646,11 +848,13 @@ A few things worth knowing:
646
848
  if (file.size < 200 * 1024) return { src: await inlineAsDataUrl(file) }; // small: keep it simple
647
849
  if (file.size < 25 * 1024 * 1024) return uploadToYourServer(file); // medium
648
850
  return uploadToS3Multipart(file); // large: chunked/multipart
649
- }
851
+ };
650
852
  ```
651
853
  - While `uploadFile` is resolving, the block shows an "Uploading…" state; if it rejects, a dismissible error message is shown instead and nothing is written to the document — the file input stays available to try again.
652
854
  - `maxFileSize` (bytes) only applies to the **built-in, zero-config `data:` URL fallback** — an oversized file is rejected with a clear error instead of silently bloating the document. It has no effect once `uploadFile` is configured, since the host's own function (or backend) is what decides what it can handle.
653
855
  - `useFileUpload()` exposes the same `{ uploadFile, maxFileSize }` to your own components, for building custom upload UI outside the `embed` block that still honors the same configuration.
856
+ - **Pasting** a raw image/media file straight from the OS clipboard (a screenshot, an OS-level "Copy Image") works too, going through this exact same `uploadFile`/`maxFileSize` resolution and inserting an `embed` block — no separate configuration needed. (Copying an already-rendered `<img>`/`<video>`/`<audio>` _from a webpage_ instead reconstructs it from the pasted HTML, unrelated to this upload path.)
857
+ - **Rich link embeds**: pasting a YouTube, Vimeo, Loom, Figma, CodePen, or Spotify link into any embed block's URL field (or via the "Embed link" slash command) auto-detects it and renders a real interactive iframe instead of a broken `<img>`/`<video>` tag — no configuration needed, and no network fetch involved (pure URL pattern matching, see `src/blocks/embed/oembedProviders.js`).
654
858
 
655
859
  ## Live collaboration (experimental)
656
860
 
@@ -679,7 +883,7 @@ From then on, every edit made via `editor.store` (typing, inserting/moving/delet
679
883
 
680
884
  ### Signaling options
681
885
 
682
- `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:
886
+ `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:
683
887
 
684
888
  - **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.
685
889
  - **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:
@@ -689,7 +893,7 @@ From then on, every edit made via `editor.store` (typing, inserting/moving/delet
689
893
 
690
894
  const signaling = createWebSocketSignaling({
691
895
  url: 'ws://192.168.1.5:8080', // a relay running on your LAN -- or any host, if you want internet-wide instead
692
- roomId: 'my-document-id', // anyone using the same roomId ends up in the same room
896
+ roomId: 'my-document-id', // anyone using the same roomId ends up in the same room
693
897
  peerId: crypto.randomUUID(),
694
898
  });
695
899
  const session = new CollabSession({ history: editor.store, signaling });
@@ -719,13 +923,14 @@ function PeerCursors({ session }) {
719
923
  }
720
924
  ```
721
925
 
722
- 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.
926
+ 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.
723
927
 
724
928
  **How conflicts resolve:**
929
+
725
930
  - Concurrent inserts (even at the same position) — both survive, converging to the same order on every peer.
726
931
  - Concurrent delete vs. edit of the same block — the delete wins.
727
932
  - Concurrent type-conversion of the same block ("Turn into") — one type wins deterministically (the same one, on every peer), not two duplicate blocks.
728
- - Concurrent edits to a run's text — merge at the *character* level (a real per-run CRDT, the same ordered-list mechanism blocks already use, just one level down): two peers editing different parts of the same run both survive, and two peers inserting at the exact same position both survive too, interleaved deterministically (identically on every peer) rather than one silently overwriting the other.
933
+ - Concurrent edits to a run's text — merge at the _character_ level (a real per-run CRDT, the same ordered-list mechanism blocks already use, just one level down): two peers editing different parts of the same run both survive, and two peers inserting at the exact same position both survive too, interleaved deterministically (identically on every peer) rather than one silently overwriting the other.
729
934
 
730
935
  ### Tombstone garbage collection
731
936
 
@@ -735,7 +940,11 @@ Deleted blocks/runs are kept as "tombstones" rather than actually removed — ne
735
940
  import { useEditor, createPeriodicTombstoneGC } from 'noteloom';
736
941
 
737
942
  const editor = useEditor({ doc: myDoc });
738
- const gc = createPeriodicTombstoneGC({ store: editor.store, intervalMs: 60 * 60 * 1000, maxAgeMs: 24 * 60 * 60 * 1000 }); // hourly sweep, 24h retention (both defaults, shown explicitly)
943
+ const gc = createPeriodicTombstoneGC({
944
+ store: editor.store,
945
+ intervalMs: 60 * 60 * 1000,
946
+ maxAgeMs: 24 * 60 * 60 * 1000,
947
+ }); // hourly sweep, 24h retention (both defaults, shown explicitly)
739
948
 
740
949
  // later, when the store is no longer in use:
741
950
  gc.stop();
@@ -743,17 +952,18 @@ gc.stop();
743
952
 
744
953
  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).
745
954
 
746
- **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.
955
+ **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.
747
956
 
748
957
  ### Reconnecting reliably
749
958
 
750
959
  `CollabSession`/`createWebSocketSignaling` deliberately don't retry anything themselves (see the class doc comment) — a dropped connection is a transport-layer concern left to the host app, on purpose, so this stays a small library rather than growing an opinionated retry/backoff policy no two apps would agree on. `examples/lan-collab/` is a complete, runnable reference for the two pieces most apps end up needing on top:
751
960
 
752
961
  - **A watchdog that actually reconnects.** `createWebSocketSignaling` exposes no `close`/`error` event for the relay connection dying silently (a sleeping laptop, a WiFi drop, the relay restarting) — so periodically checking "do I currently have zero live peers, and has it been a while since I last tried" and, if so, tearing down and recreating the whole signaling + session is the only reliable way to notice and recover. Also worth reacting to the browser's own `online` event immediately, rather than waiting for the next timer tick.
753
- - **Actually catching up, not just resuming.** A reconnecting peer that keeps its existing (non-empty) store — the right default, so a solo editing session isn't wiped by a network blip — never re-triggers `CollabSession`'s adopt-a-snapshot path, since that only fires when a store is genuinely empty (see "A peer joining with their own existing document" below). Left alone, this peer silently misses everything the room changed while it was away. The fix: on a genuine *reconnect* (never the very first connection) where nothing was typed locally in the gap, reset the store back to that same empty shape first — the same field-level reset `usePersistedDocument` uses internally — so the ordinary adopt-on-empty flow does the catching-up. If local edits *were* made while disconnected, keep them as-is; there's no safe way to both preserve them and adopt someone else's snapshot without a real merge (see the next limitation).
962
+ - **Actually catching up, not just resuming.** A reconnecting peer that keeps its existing (non-empty) store — the right default, so a solo editing session isn't wiped by a network blip — never re-triggers `CollabSession`'s adopt-a-snapshot path, since that only fires when a store is genuinely empty (see "A peer joining with their own existing document" below). Left alone, this peer silently misses everything the room changed while it was away. The fix: on a genuine _reconnect_ (never the very first connection) where nothing was typed locally in the gap, reset the store back to that same empty shape first — the same field-level reset `usePersistedDocument` uses internally — so the ordinary adopt-on-empty flow does the catching-up. If local edits _were_ made while disconnected, keep them as-is; there's no safe way to both preserve them and adopt someone else's snapshot without a real merge (see the next limitation).
754
963
 
755
964
  **Known limitations — read before relying on this in production:**
756
- - **Undo is local-only, and only ever touches your own edits.** Undo/redo of a text edit works by tombstoning/restoring the exact character ids *you* inserted/deleted (not by replaying an old whole-string snapshot), so undoing your own past edit to a run can never remove a peer's concurrent edit to that same run, no matter how they're interleaved. One narrower case remains open: concurrent *formatting* (bold/italic, which splits a run into new runs with new ids) racing a concurrent *edit* of the exact same run is a run-list-level (not character-level) concern this doesn't cover.
965
+
966
+ - **Undo is local-only, and only ever touches your own edits.** Undo/redo of a text edit works by tombstoning/restoring the exact character ids _you_ inserted/deleted (not by replaying an old whole-string snapshot), so undoing your own past edit to a run can never remove a peer's concurrent edit to that same run, no matter how they're interleaved. One narrower case remains open: concurrent _formatting_ (bold/italic, which splits a run into new runs with new ids) racing a concurrent _edit_ of the exact same run is a run-list-level (not character-level) concern this doesn't cover.
757
967
  - **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.
758
968
  - **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. This is also why the reconnect pattern above only ever resets a store that has no unsynced local edits of its own.
759
969
  - **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. See "Reconnecting reliably" above for making the reconnect itself actually happen.
@@ -799,7 +1009,9 @@ function Editor() {
799
1009
  { id: 'root', type: 'page', parentId: null, contentIds: ['p1'], props: {} },
800
1010
  { id: 'p1', type: 'paragraph', parentId: 'root', contentIds: ['r1'], props: {} },
801
1011
  ],
802
- runs: [{ id: 'r1', type: 'text', value: 'Hello — try typing "/" for commands.', marks: {} }],
1012
+ runs: [
1013
+ { id: 'r1', type: 'text', value: 'Hello — try typing "/" for commands.', marks: {} },
1014
+ ],
803
1015
  }),
804
1016
  );
805
1017
  return { store, registry, inlineRegistry };
@@ -810,7 +1022,12 @@ function Editor() {
810
1022
  useEditorKeyboardShortcuts(containerRef);
811
1023
 
812
1024
  return (
813
- <EditorProvider store={store} registry={registry} inlineRegistry={inlineRegistry} history={store}>
1025
+ <EditorProvider
1026
+ store={store}
1027
+ registry={registry}
1028
+ inlineRegistry={inlineRegistry}
1029
+ history={store}
1030
+ >
814
1031
  <div ref={containerRef} onCopy={onCopy} onCut={onCut} onPaste={onPaste}>
815
1032
  <BlockChildren parentId="root" />
816
1033
  <SlashMenu
@@ -831,16 +1048,28 @@ See `examples/basic` for a complete working app built this way (run `npm run dev
831
1048
 
832
1049
  ## Registering a brand-new block/inline type, from scratch
833
1050
 
834
- 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:
1051
+ 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:
835
1052
 
836
1053
  ```js
837
1054
  registry.register('myBlock', {
838
1055
  component: MyBlockComponent, // receives only { id }
839
1056
  isLeaf: true, // true if contentIds holds run ids, false if it holds child block ids
840
- toHTML(block, ctx) { /* ... */ },
841
- fromHTML(domNode, ctx) { /* ... or return null if this node isn't yours */ },
842
- toPlainText(block, ctx) { /* ... */ },
843
- slashCommand: { label: 'My Block', keywords: ['my'], run(store, ctx) { /* ... */ } },
1057
+ toHTML(block, ctx) {
1058
+ /* ... */
1059
+ },
1060
+ fromHTML(domNode, ctx) {
1061
+ /* ... or return null if this node isn't yours */
1062
+ },
1063
+ toPlainText(block, ctx) {
1064
+ /* ... */
1065
+ },
1066
+ slashCommand: {
1067
+ label: 'My Block',
1068
+ keywords: ['my'],
1069
+ run(store, ctx) {
1070
+ /* ... */
1071
+ },
1072
+ },
844
1073
  });
845
1074
  ```
846
1075
 
@@ -859,7 +1088,7 @@ npm run typecheck # tsc --noEmit against src/index.d.ts
859
1088
  npm run build # library build (dist/, ESM + CJS + index.d.ts)
860
1089
  ```
861
1090
 
862
- See `examples/README.md` for the rest of the runnable examples, and `CONTRIBUTING.md` for the full contributor guide.
1091
+ See `examples/README.md` for the rest of the runnable examples, and `CONTRIBUTING.md` for the full contributor guide. Upgrading from an earlier version? See **[`docs/migration.md`](docs/migration.md)**.
863
1092
 
864
1093
  ## Known limitations
865
1094
 
@@ -868,6 +1097,6 @@ See `examples/README.md` for the rest of the runnable examples, and `CONTRIBUTIN
868
1097
  - 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.
869
1098
  - `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.
870
1099
  - 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.
871
- - 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.
1100
+ - 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.
872
1101
  - 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.
873
1102
  - A comment's highlighted range is local-only in collaboration for v1 (same scope every other range-based formatting operation already has — see [Comments](#comments)); a comment thread's `anchorRunIds` is a creation-time hint only, not re-validated after later formatting edits reshape that range.