noteloom 0.3.2 → 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 +344 -53
- 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 +392 -32
- 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 -3200
- 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 -3209
- package/dist/noteloom.cjs.map +0 -1
- package/dist/noteloom.es.js +0 -14622
- 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,6 +293,8 @@ function Editor() {
|
|
|
195
293
|
|
|
196
294
|
`examples/03-custom-field-type/` is a complete runnable version of this pattern.
|
|
197
295
|
|
|
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.
|
|
297
|
+
|
|
198
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):
|
|
199
299
|
|
|
200
300
|
```js
|
|
@@ -217,6 +317,8 @@ A few things worth knowing about the dynamic path:
|
|
|
217
317
|
- Your function is called **fresh on every keystroke**, debounced ~250ms — there's no built-in caching layer, so if you want caching, memoize inside your own function.
|
|
218
318
|
- Only the **resolved pick** — `{ value, label }` (plus `color` for the tag variant) — is ever written onto the document. The live options list itself is never persisted, so a chip never embeds a stale snapshot of your database; re-opening it always calls your function again.
|
|
219
319
|
- `triggers` (default `['slash']`) decides whether the type shows up under `/`, `@` (via `useAtMenuTrigger`), or both. A field that doesn't read naturally after "@" (e.g. "Priority") should usually stay slash-only.
|
|
320
|
+
- A **static** array works just as well when it comes from a JSON file — `import options from './options.json'` (or fetch it once at setup) is already a plain array by the time it reaches `options`, no special handling needed. A hybrid of both ("show a local list, search an API once the user types") is just a function that returns the static list for an empty query and calls your API otherwise — the same debounce applies regardless of what the function does inside.
|
|
321
|
+
- The option list itself is **virtualized** — only the rows currently scrolled into view are ever mounted, so a list of thousands of options (static or a big resolved page) scrolls smoothly, same as a list of ten.
|
|
220
322
|
|
|
221
323
|
### Letting end users create their own field types, in-editor
|
|
222
324
|
|
|
@@ -238,13 +340,23 @@ function NewFieldTypeButton() {
|
|
|
238
340
|
|
|
239
341
|
User-created types are persisted in the document's own `fieldTypes` collection (so they survive reload) and are automatically rehydrated back into your inline registry by `FieldTypeEditorModal` itself — you don't need to call anything extra. Each chip's popover also gets a "Manage options…" entry that reopens this same modal, pre-filled, for renaming/editing/deleting the type it belongs to.
|
|
240
342
|
|
|
241
|
-
|
|
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.
|
|
344
|
+
|
|
345
|
+
## Exporting the document (JSON / HTML / Markdown / Word / PDF / plain text)
|
|
242
346
|
|
|
243
347
|
```js
|
|
244
|
-
import {
|
|
348
|
+
import {
|
|
349
|
+
exportDocumentJSON,
|
|
350
|
+
exportDocumentHTML,
|
|
351
|
+
exportDocumentMarkdown,
|
|
352
|
+
exportDocumentWordHTML,
|
|
353
|
+
exportDocumentText,
|
|
354
|
+
} from 'noteloom';
|
|
245
355
|
|
|
246
356
|
exportDocumentJSON(store); // a JSON *string* — JSON.parse() it to get { version, rootId, blocks, runs }, usable as useEditor({ doc })
|
|
247
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
|
|
248
360
|
exportDocumentText(store, registry, inlineRegistry);
|
|
249
361
|
```
|
|
250
362
|
|
|
@@ -253,16 +365,32 @@ Or mount the ready-made button + modal instead of wiring your own UI:
|
|
|
253
365
|
```jsx
|
|
254
366
|
import { DocumentExportButton } from 'noteloom';
|
|
255
367
|
|
|
256
|
-
<DocumentExportButton label="View source"
|
|
368
|
+
<DocumentExportButton label="View source" />;
|
|
257
369
|
```
|
|
258
370
|
|
|
259
|
-
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.
|
|
260
375
|
|
|
261
|
-
|
|
376
|
+
Useful for debugging, or as a starting point for a real "export" feature.
|
|
262
377
|
|
|
263
|
-
|
|
378
|
+
### The document format
|
|
264
379
|
|
|
265
|
-
|
|
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
|
+
```
|
|
388
|
+
|
|
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.
|
|
390
|
+
|
|
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:
|
|
266
394
|
|
|
267
395
|
```js
|
|
268
396
|
import { exportDocumentSimpleJSON, importDocumentSimpleJSON } from 'noteloom';
|
|
@@ -287,14 +415,14 @@ const json = exportDocumentSimpleJSON(store, registry, inlineRegistry);
|
|
|
287
415
|
|
|
288
416
|
// ...later, or on a different machine/process:
|
|
289
417
|
const doc = importDocumentSimpleJSON(json, registry, inlineRegistry); // -> { rootId, blocks, runs }
|
|
290
|
-
const editor2 = useEditor({ doc }); // or
|
|
418
|
+
const editor2 = useEditor({ doc: JSON.parse(json) }); // useEditor detects the shape; or new EditorStore(doc) outside React
|
|
291
419
|
```
|
|
292
420
|
|
|
293
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).
|
|
294
422
|
|
|
295
|
-
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.
|
|
296
424
|
|
|
297
|
-
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.
|
|
298
426
|
|
|
299
427
|
## Templates
|
|
300
428
|
|
|
@@ -303,7 +431,12 @@ Two kinds — a **document template** seeds a whole new editor (`useEditor({ doc
|
|
|
303
431
|
**Block templates — reusable snippets, insertable via "/":**
|
|
304
432
|
|
|
305
433
|
```js
|
|
306
|
-
import {
|
|
434
|
+
import {
|
|
435
|
+
EditorStore,
|
|
436
|
+
captureBlockTemplate,
|
|
437
|
+
registerBlockTemplates,
|
|
438
|
+
registerBuiltInBlocks,
|
|
439
|
+
} from 'noteloom';
|
|
307
440
|
|
|
308
441
|
// Build once (a throwaway store is fine — only its content is captured):
|
|
309
442
|
const draftStore = new EditorStore({
|
|
@@ -311,7 +444,13 @@ const draftStore = new EditorStore({
|
|
|
311
444
|
blocks: [
|
|
312
445
|
{ id: 'root', type: 'page', parentId: null, contentIds: ['h1', 'li1'], props: {} },
|
|
313
446
|
{ id: 'h1', type: 'heading', parentId: 'root', contentIds: ['r1'], props: { level: 2 } },
|
|
314
|
-
{
|
|
447
|
+
{
|
|
448
|
+
id: 'li1',
|
|
449
|
+
type: 'listItem',
|
|
450
|
+
parentId: 'root',
|
|
451
|
+
contentIds: [],
|
|
452
|
+
props: { ordered: true, titleRunIds: ['r2'] },
|
|
453
|
+
},
|
|
315
454
|
],
|
|
316
455
|
runs: [
|
|
317
456
|
{ id: 'r1', type: 'text', value: 'Meeting agenda', marks: {} },
|
|
@@ -323,19 +462,28 @@ const agendaSnippet = captureBlockTemplate(draftStore, ['h1', 'li1']);
|
|
|
323
462
|
const editor = useEditor({
|
|
324
463
|
registerBlocks: (registry) => {
|
|
325
464
|
registerBuiltInBlocks(registry);
|
|
326
|
-
registerBlockTemplates(registry, [
|
|
465
|
+
registerBlockTemplates(registry, [
|
|
466
|
+
{ id: 'agenda', label: 'Meeting agenda', keywords: ['agenda'], roots: agendaSnippet.roots },
|
|
467
|
+
]);
|
|
327
468
|
},
|
|
328
469
|
});
|
|
329
470
|
```
|
|
330
471
|
|
|
331
|
-
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 "/".
|
|
332
473
|
|
|
333
|
-
**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)`.
|
|
334
475
|
|
|
335
476
|
**Saving/browsing a library of templates** (either kind), persisted so it survives reload:
|
|
336
477
|
|
|
337
478
|
```jsx
|
|
338
|
-
import {
|
|
479
|
+
import {
|
|
480
|
+
useEditor,
|
|
481
|
+
NoteloomEditor,
|
|
482
|
+
useTemplates,
|
|
483
|
+
TemplatePicker,
|
|
484
|
+
saveTemplate,
|
|
485
|
+
exportDocumentJSON,
|
|
486
|
+
} from 'noteloom';
|
|
339
487
|
|
|
340
488
|
function NewDocumentScreen({ onPick }) {
|
|
341
489
|
const { templates, isLoaded } = useTemplates({ scope: 'document' }); // or 'block', or omit for both
|
|
@@ -392,7 +540,14 @@ For the granular API, render the pieces yourself anywhere under an `<EditorProvi
|
|
|
392
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):
|
|
393
541
|
|
|
394
542
|
```jsx
|
|
395
|
-
import {
|
|
543
|
+
import {
|
|
544
|
+
addComment,
|
|
545
|
+
replyToComment,
|
|
546
|
+
resolveComment,
|
|
547
|
+
deleteComment,
|
|
548
|
+
useComments,
|
|
549
|
+
resolveMultiRunSelection,
|
|
550
|
+
} from 'noteloom';
|
|
396
551
|
|
|
397
552
|
<NoteloomEditor
|
|
398
553
|
editor={editor}
|
|
@@ -419,9 +574,21 @@ function CommentsSidebar({ store }) {
|
|
|
419
574
|
<ul>
|
|
420
575
|
{comments.map((thread) => (
|
|
421
576
|
<li key={thread.id}>
|
|
422
|
-
{thread.messages.map((m) =>
|
|
423
|
-
|
|
424
|
-
|
|
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>
|
|
425
592
|
<button onClick={() => deleteComment(store, thread.id)}>Delete</button>
|
|
426
593
|
</li>
|
|
427
594
|
))}
|
|
@@ -434,7 +601,7 @@ function CommentsSidebar({ store }) {
|
|
|
434
601
|
|
|
435
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.
|
|
436
603
|
|
|
437
|
-
**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.
|
|
438
605
|
|
|
439
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`.
|
|
440
607
|
|
|
@@ -477,9 +644,47 @@ A block's own `dir` wins over the document's; the block gutter menu also has a "
|
|
|
477
644
|
|
|
478
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.
|
|
479
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
|
+
|
|
480
685
|
## Printing & PDF
|
|
481
686
|
|
|
482
|
-
`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.
|
|
483
688
|
|
|
484
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:
|
|
485
690
|
|
|
@@ -487,7 +692,9 @@ There's no bundled PDF-generation library (that would need a real dependency lik
|
|
|
487
692
|
window.print(); // Ctrl+P / Cmd+P works too — "Save as PDF" in the print dialog is your PDF export
|
|
488
693
|
```
|
|
489
694
|
|
|
490
|
-
|
|
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.
|
|
491
698
|
|
|
492
699
|
## Voice typing
|
|
493
700
|
|
|
@@ -507,7 +714,7 @@ function MicButton() {
|
|
|
507
714
|
}
|
|
508
715
|
```
|
|
509
716
|
|
|
510
|
-
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.
|
|
511
718
|
|
|
512
719
|
## Mobile / touch support
|
|
513
720
|
|
|
@@ -517,7 +724,7 @@ Typing "/"/"@" still works on a phone keyboard, but it's not a reliable or disco
|
|
|
517
724
|
import { MobileActionBar } from 'noteloom';
|
|
518
725
|
|
|
519
726
|
// next to your other trigger hooks/components, same containerRef:
|
|
520
|
-
<MobileActionBar containerRef={containerRef}
|
|
727
|
+
<MobileActionBar containerRef={containerRef} />;
|
|
521
728
|
```
|
|
522
729
|
|
|
523
730
|
`examples/basic` has this fully wired up (run `npm run dev`, then resize to a narrow viewport or open it on a phone).
|
|
@@ -533,7 +740,7 @@ It renders nothing on a mouse/trackpad, and nothing until focus is actually insi
|
|
|
533
740
|
|
|
534
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).
|
|
535
742
|
|
|
536
|
-
**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.
|
|
537
744
|
|
|
538
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.
|
|
539
746
|
|
|
@@ -563,15 +770,26 @@ function App() {
|
|
|
563
770
|
|
|
564
771
|
On mount, this loads whatever was last saved under `docId` (if anything) and replaces the store's content with it; every edit after that — typing, structural changes, even changes arriving from a collaborating peer via `CollabSession` — is auto-saved back, debounced (default 500ms of quiet) so a full-document write doesn't fire on every keystroke. Different `docId`s are stored independently, so one browser can hold many separate documents (e.g. keyed by page/route). A runnable example is in `examples/offline-persist/` — run `npm run dev:offline-persist`, type something, then reload the page or close and reopen the tab.
|
|
565
772
|
|
|
773
|
+
Everything already auto-saves, but `usePersistedDocument` also wires up the keyboard shortcut every user reaches for anyway: **Ctrl+S (Windows/Linux) or Cmd+S (Mac)** forces an immediate save (skipping the rest of the debounce window) and blocks the browser's own "Save Page" dialog from popping up instead — pass `{ saveShortcut: false }` to opt out, and `onSave` (fires after every save, shortcut-triggered or manual) to show your own "Saved" feedback. The hook also returns `save()` directly, for a manual Save button:
|
|
774
|
+
|
|
775
|
+
```jsx
|
|
776
|
+
const { isLoaded, save } = usePersistedDocument({
|
|
777
|
+
store: editor.store,
|
|
778
|
+
docId: 'my-document-id',
|
|
779
|
+
onSave: () => showSavedToast(),
|
|
780
|
+
});
|
|
781
|
+
```
|
|
782
|
+
|
|
566
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
|
+
|
|
567
785
|
- `savePersistedDocument(docId, doc)` / `loadPersistedDocument(docId)` / `deletePersistedDocument(docId)` / `listPersistedDocumentIds()` — the raw IndexedDB operations `usePersistedDocument` is built on.
|
|
568
|
-
- `createAutoPersistence({ store, docId, debounceMs, onError })` — just the debounced auto-save half, if you want to handle the initial load yourself. Returns `{ stop, flush }
|
|
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).
|
|
569
787
|
|
|
570
|
-
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.
|
|
571
789
|
|
|
572
790
|
### Offline app shell (PWA)
|
|
573
791
|
|
|
574
|
-
`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:
|
|
575
793
|
|
|
576
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`.
|
|
577
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 —
|
|
@@ -590,6 +808,54 @@ This is standalone — works with a solo, non-collaborating store just as well a
|
|
|
590
808
|
|
|
591
809
|
Run `npm run dev:offline-persist`, then `npx vite build --config examples/offline-persist/vite.config.js && npx vite preview --config examples/offline-persist/vite.config.js` to try the built (not dev-mode) version — service workers only activate on a real build. Load it once online, then disconnect entirely and reload: the app shell still loads, and editing/persistence both keep working, since IndexedDB has no network dependency of its own.
|
|
592
810
|
|
|
811
|
+
## File & image uploads
|
|
812
|
+
|
|
813
|
+
The image/video/audio/file block (`embed`, reachable via "/image", "/video", etc.) ships with zero configuration needed: a picked or dropped file is read straight into a `data:` URL and stored directly in the document. That keeps everything fully self-contained — works offline, round-trips through copy/paste and undo/redo like any other block — at the cost of bloating the document for large media, since this package has no backend of its own to hand a file to instead.
|
|
814
|
+
|
|
815
|
+
For real upload-to-a-server behavior — local disk, AWS S3, or any other cloud storage — pass `uploadFile` to `<NoteloomEditor>` (or `<EditorProvider>` for the granular API):
|
|
816
|
+
|
|
817
|
+
```jsx
|
|
818
|
+
<NoteloomEditor
|
|
819
|
+
editor={editor}
|
|
820
|
+
uploadFile={async (file, { kind }) => {
|
|
821
|
+
const body = new FormData();
|
|
822
|
+
body.append('file', file);
|
|
823
|
+
const res = await fetch('/api/upload', { method: 'POST', body });
|
|
824
|
+
const { url } = await res.json();
|
|
825
|
+
return { src: url }; // { name?, mimeType? } also accepted, defaulting to the file's own
|
|
826
|
+
}}
|
|
827
|
+
/>
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
A few things worth knowing:
|
|
831
|
+
|
|
832
|
+
- **AWS S3** (or any presigned-URL-style object storage) is the same shape, just two requests instead of one — ask your own backend for a presigned PUT URL, then `PUT` the file straight to it:
|
|
833
|
+
```js
|
|
834
|
+
uploadFile: async (file) => {
|
|
835
|
+
const { uploadUrl, publicUrl } = await fetch('/api/s3-presign', {
|
|
836
|
+
method: 'POST',
|
|
837
|
+
headers: { 'Content-Type': 'application/json' },
|
|
838
|
+
body: JSON.stringify({ filename: file.name, contentType: file.type }),
|
|
839
|
+
}).then((r) => r.json());
|
|
840
|
+
await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });
|
|
841
|
+
return { src: publicUrl };
|
|
842
|
+
};
|
|
843
|
+
```
|
|
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.
|
|
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:
|
|
846
|
+
```js
|
|
847
|
+
uploadFile: async (file) => {
|
|
848
|
+
if (file.size < 200 * 1024) return { src: await inlineAsDataUrl(file) }; // small: keep it simple
|
|
849
|
+
if (file.size < 25 * 1024 * 1024) return uploadToYourServer(file); // medium
|
|
850
|
+
return uploadToS3Multipart(file); // large: chunked/multipart
|
|
851
|
+
};
|
|
852
|
+
```
|
|
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.
|
|
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.
|
|
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`).
|
|
858
|
+
|
|
593
859
|
## Live collaboration (experimental)
|
|
594
860
|
|
|
595
861
|
Real-time multi-peer editing, built as a custom **block-tree CRDT** — not a generic text-CRDT library bolted on — so it stays true to the zero-runtime-dependency design. Peers connect directly over WebRTC; you bring your own signaling (a WebSocket relay, Firebase/Supabase realtime, or anything else that can pass small JSON messages between two peers) to bootstrap the connection.
|
|
@@ -617,7 +883,7 @@ From then on, every edit made via `editor.store` (typing, inserting/moving/delet
|
|
|
617
883
|
|
|
618
884
|
### Signaling options
|
|
619
885
|
|
|
620
|
-
`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:
|
|
621
887
|
|
|
622
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.
|
|
623
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:
|
|
@@ -627,7 +893,7 @@ From then on, every edit made via `editor.store` (typing, inserting/moving/delet
|
|
|
627
893
|
|
|
628
894
|
const signaling = createWebSocketSignaling({
|
|
629
895
|
url: 'ws://192.168.1.5:8080', // a relay running on your LAN -- or any host, if you want internet-wide instead
|
|
630
|
-
roomId: 'my-document-id',
|
|
896
|
+
roomId: 'my-document-id', // anyone using the same roomId ends up in the same room
|
|
631
897
|
peerId: crypto.randomUUID(),
|
|
632
898
|
});
|
|
633
899
|
const session = new CollabSession({ history: editor.store, signaling });
|
|
@@ -657,13 +923,14 @@ function PeerCursors({ session }) {
|
|
|
657
923
|
}
|
|
658
924
|
```
|
|
659
925
|
|
|
660
|
-
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.
|
|
661
927
|
|
|
662
928
|
**How conflicts resolve:**
|
|
929
|
+
|
|
663
930
|
- Concurrent inserts (even at the same position) — both survive, converging to the same order on every peer.
|
|
664
931
|
- Concurrent delete vs. edit of the same block — the delete wins.
|
|
665
932
|
- Concurrent type-conversion of the same block ("Turn into") — one type wins deterministically (the same one, on every peer), not two duplicate blocks.
|
|
666
|
-
- 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.
|
|
667
934
|
|
|
668
935
|
### Tombstone garbage collection
|
|
669
936
|
|
|
@@ -673,7 +940,11 @@ Deleted blocks/runs are kept as "tombstones" rather than actually removed — ne
|
|
|
673
940
|
import { useEditor, createPeriodicTombstoneGC } from 'noteloom';
|
|
674
941
|
|
|
675
942
|
const editor = useEditor({ doc: myDoc });
|
|
676
|
-
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)
|
|
677
948
|
|
|
678
949
|
// later, when the store is no longer in use:
|
|
679
950
|
gc.stop();
|
|
@@ -681,17 +952,18 @@ gc.stop();
|
|
|
681
952
|
|
|
682
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).
|
|
683
954
|
|
|
684
|
-
**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.
|
|
685
956
|
|
|
686
957
|
### Reconnecting reliably
|
|
687
958
|
|
|
688
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:
|
|
689
960
|
|
|
690
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.
|
|
691
|
-
- **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).
|
|
692
963
|
|
|
693
964
|
**Known limitations — read before relying on this in production:**
|
|
694
|
-
|
|
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.
|
|
695
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.
|
|
696
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.
|
|
697
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.
|
|
@@ -737,7 +1009,9 @@ function Editor() {
|
|
|
737
1009
|
{ id: 'root', type: 'page', parentId: null, contentIds: ['p1'], props: {} },
|
|
738
1010
|
{ id: 'p1', type: 'paragraph', parentId: 'root', contentIds: ['r1'], props: {} },
|
|
739
1011
|
],
|
|
740
|
-
runs: [
|
|
1012
|
+
runs: [
|
|
1013
|
+
{ id: 'r1', type: 'text', value: 'Hello — try typing "/" for commands.', marks: {} },
|
|
1014
|
+
],
|
|
741
1015
|
}),
|
|
742
1016
|
);
|
|
743
1017
|
return { store, registry, inlineRegistry };
|
|
@@ -748,7 +1022,12 @@ function Editor() {
|
|
|
748
1022
|
useEditorKeyboardShortcuts(containerRef);
|
|
749
1023
|
|
|
750
1024
|
return (
|
|
751
|
-
<EditorProvider
|
|
1025
|
+
<EditorProvider
|
|
1026
|
+
store={store}
|
|
1027
|
+
registry={registry}
|
|
1028
|
+
inlineRegistry={inlineRegistry}
|
|
1029
|
+
history={store}
|
|
1030
|
+
>
|
|
752
1031
|
<div ref={containerRef} onCopy={onCopy} onCut={onCut} onPaste={onPaste}>
|
|
753
1032
|
<BlockChildren parentId="root" />
|
|
754
1033
|
<SlashMenu
|
|
@@ -769,16 +1048,28 @@ See `examples/basic` for a complete working app built this way (run `npm run dev
|
|
|
769
1048
|
|
|
770
1049
|
## Registering a brand-new block/inline type, from scratch
|
|
771
1050
|
|
|
772
|
-
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:
|
|
773
1052
|
|
|
774
1053
|
```js
|
|
775
1054
|
registry.register('myBlock', {
|
|
776
1055
|
component: MyBlockComponent, // receives only { id }
|
|
777
1056
|
isLeaf: true, // true if contentIds holds run ids, false if it holds child block ids
|
|
778
|
-
toHTML(block, ctx) {
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
+
},
|
|
782
1073
|
});
|
|
783
1074
|
```
|
|
784
1075
|
|
|
@@ -797,7 +1088,7 @@ npm run typecheck # tsc --noEmit against src/index.d.ts
|
|
|
797
1088
|
npm run build # library build (dist/, ESM + CJS + index.d.ts)
|
|
798
1089
|
```
|
|
799
1090
|
|
|
800
|
-
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)**.
|
|
801
1092
|
|
|
802
1093
|
## Known limitations
|
|
803
1094
|
|
|
@@ -806,6 +1097,6 @@ See `examples/README.md` for the rest of the runnable examples, and `CONTRIBUTIN
|
|
|
806
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.
|
|
807
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.
|
|
808
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.
|
|
809
|
-
- 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.
|
|
810
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.
|
|
811
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.
|