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.
- package/README.md +284 -55
- package/bin/noteloom.mjs +139 -0
- package/dist/canvas.cjs +2 -0
- package/dist/canvas.cjs.map +1 -0
- package/dist/canvas.d.ts +2 -0
- package/dist/canvas.js +5 -0
- package/dist/canvas.js.map +1 -0
- package/dist/collab.cjs +2 -0
- package/dist/collab.cjs.map +1 -0
- package/dist/collab.d.ts +19 -0
- package/dist/collab.js +379 -0
- package/dist/collab.js.map +1 -0
- package/dist/comments.cjs +2 -0
- package/dist/comments.cjs.map +1 -0
- package/dist/comments.d.ts +17 -0
- package/dist/comments.js +19 -0
- package/dist/comments.js.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +372 -33
- package/dist/index.js +3983 -0
- package/dist/index.js.map +1 -0
- package/dist/persistence.cjs +2 -0
- package/dist/persistence.cjs.map +1 -0
- package/dist/persistence.d.ts +11 -0
- package/dist/persistence.js +12 -0
- package/dist/persistence.js.map +1 -0
- package/dist/shared/CommentAvatar-TKqsM8vG.js +53 -0
- package/dist/shared/CommentAvatar-TKqsM8vG.js.map +1 -0
- package/dist/shared/CommentAvatar-V457IgoQ.cjs +2 -0
- package/dist/shared/CommentAvatar-V457IgoQ.cjs.map +1 -0
- package/dist/shared/CommentThreadCard-CAJ3oPdr.cjs +2 -0
- package/dist/shared/CommentThreadCard-CAJ3oPdr.cjs.map +1 -0
- package/dist/shared/CommentThreadCard-Cxf8GwHJ.js +213 -0
- package/dist/shared/CommentThreadCard-Cxf8GwHJ.js.map +1 -0
- package/dist/shared/CommentsPanel-BwVVyMOC.js +76 -0
- package/dist/shared/CommentsPanel-BwVVyMOC.js.map +1 -0
- package/dist/shared/CommentsPanel-CES4uzye.cjs +2 -0
- package/dist/shared/CommentsPanel-CES4uzye.cjs.map +1 -0
- package/dist/shared/VersionHistory-Bg6fufP5.js +1120 -0
- package/dist/shared/VersionHistory-Bg6fufP5.js.map +1 -0
- package/dist/shared/VersionHistory-DELa2Wbq.cjs +12 -0
- package/dist/shared/VersionHistory-DELa2Wbq.cjs.map +1 -0
- package/dist/shared/VoiceListeningIndicator-BOfVmLK0.js +338 -0
- package/dist/shared/VoiceListeningIndicator-BOfVmLK0.js.map +1 -0
- package/dist/shared/VoiceListeningIndicator-DNX8JlQM.cjs +2 -0
- package/dist/shared/VoiceListeningIndicator-DNX8JlQM.cjs.map +1 -0
- package/dist/shared/fieldRegistry-BoYyW3GH.cjs +2 -0
- package/dist/shared/fieldRegistry-BoYyW3GH.cjs.map +1 -0
- package/dist/shared/fieldRegistry-PjJWXFoB.js +223 -0
- package/dist/shared/fieldRegistry-PjJWXFoB.js.map +1 -0
- package/dist/shared/icons-C1_s0jKK.js +4269 -0
- package/dist/shared/icons-C1_s0jKK.js.map +1 -0
- package/dist/shared/icons-DtCsXuU6.cjs +3531 -0
- package/dist/shared/icons-DtCsXuU6.cjs.map +1 -0
- package/dist/shared/index-B9jyyL8u.js +2145 -0
- package/dist/shared/index-B9jyyL8u.js.map +1 -0
- package/dist/shared/index-D9WEXpYM.cjs +2 -0
- package/dist/shared/index-D9WEXpYM.cjs.map +1 -0
- package/dist/shared/indexedDbPersistence-BKlGPFTP.js +112 -0
- package/dist/shared/indexedDbPersistence-BKlGPFTP.js.map +1 -0
- package/dist/shared/indexedDbPersistence-BWJ1h5Vp.cjs +2 -0
- package/dist/shared/indexedDbPersistence-BWJ1h5Vp.cjs.map +1 -0
- package/dist/shared/leafBlockFactory-A9eUhSe-.js +335 -0
- package/dist/shared/leafBlockFactory-A9eUhSe-.js.map +1 -0
- package/dist/shared/leafBlockFactory-Dp382eP8.cjs +2 -0
- package/dist/shared/leafBlockFactory-Dp382eP8.cjs.map +1 -0
- package/dist/shared/serialize-DWEfk0vU.cjs +3 -0
- package/dist/shared/serialize-DWEfk0vU.cjs.map +1 -0
- package/dist/shared/serialize-T9Odwx24.js +86 -0
- package/dist/shared/serialize-T9Odwx24.js.map +1 -0
- package/dist/shared/starter-kit-CY5wzYDf.js +4980 -0
- package/dist/shared/starter-kit-CY5wzYDf.js.map +1 -0
- package/dist/shared/starter-kit-lPN-NpJ5.cjs +27 -0
- package/dist/shared/starter-kit-lPN-NpJ5.cjs.map +1 -0
- package/dist/shared/usePopoverEdgeClamp-CDG26jBM.cjs +2 -0
- package/dist/shared/usePopoverEdgeClamp-CDG26jBM.cjs.map +1 -0
- package/dist/shared/usePopoverEdgeClamp-gmFm0CnI.js +109 -0
- package/dist/shared/usePopoverEdgeClamp-gmFm0CnI.js.map +1 -0
- package/dist/shared/useServiceWorkerUpdate-BduabXv6.js +94 -0
- package/dist/shared/useServiceWorkerUpdate-BduabXv6.js.map +1 -0
- package/dist/shared/useServiceWorkerUpdate-DQboGesi.cjs +2 -0
- package/dist/shared/useServiceWorkerUpdate-DQboGesi.cjs.map +1 -0
- package/dist/starter-kit.cjs +2 -0
- package/dist/starter-kit.cjs.map +1 -0
- package/dist/starter-kit.d.ts +9 -0
- package/dist/starter-kit.js +10 -0
- package/dist/starter-kit.js.map +1 -0
- package/dist/style.css +3529 -3283
- package/dist/versions.cjs +2 -0
- package/dist/versions.cjs.map +1 -0
- package/dist/versions.d.ts +11 -0
- package/dist/versions.js +13 -0
- package/dist/versions.js.map +1 -0
- package/dist/voice.cjs +2 -0
- package/dist/voice.cjs.map +1 -0
- package/dist/voice.d.ts +7 -0
- package/dist/voice.js +8 -0
- package/dist/voice.js.map +1 -0
- package/package.json +159 -92
- package/dist/noteloom.cjs +0 -3292
- package/dist/noteloom.cjs.map +0 -1
- package/dist/noteloom.es.js +0 -15047
- 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;
|
|
100
|
-
--noteloom-radius-md: 4px;
|
|
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
|
|
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
|
|
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 {
|
|
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) =>
|
|
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 {
|
|
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
|
|
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 {
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
{
|
|
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, [
|
|
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
|
|
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
|
|
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 {
|
|
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 {
|
|
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) =>
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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',
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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
|
|
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
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
|
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.
|