noteloom 0.4.0 → 0.5.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 +162 -1026
- package/dist/canvas.cjs +1 -1
- package/dist/canvas.js +1 -1
- package/dist/comments.cjs +1 -1
- package/dist/comments.js +3 -3
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +19 -19
- package/dist/shared/{CommentThreadCard-Cxf8GwHJ.js → CommentThreadCard-CwKd6fp3.js} +2 -2
- package/dist/shared/{CommentThreadCard-Cxf8GwHJ.js.map → CommentThreadCard-CwKd6fp3.js.map} +1 -1
- package/dist/shared/{CommentThreadCard-CAJ3oPdr.cjs → CommentThreadCard-YziIa8My.cjs} +2 -2
- package/dist/shared/{CommentThreadCard-CAJ3oPdr.cjs.map → CommentThreadCard-YziIa8My.cjs.map} +1 -1
- package/dist/shared/{CommentsPanel-CES4uzye.cjs → CommentsPanel-DezejDdE.cjs} +2 -2
- package/dist/shared/{CommentsPanel-CES4uzye.cjs.map → CommentsPanel-DezejDdE.cjs.map} +1 -1
- package/dist/shared/{CommentsPanel-BwVVyMOC.js → CommentsPanel-dx7IaoqS.js} +3 -3
- package/dist/shared/{CommentsPanel-BwVVyMOC.js.map → CommentsPanel-dx7IaoqS.js.map} +1 -1
- package/dist/shared/{VersionHistory-Bg6fufP5.js → VersionHistory-DhYb8C2e.js} +3 -3
- package/dist/shared/{VersionHistory-Bg6fufP5.js.map → VersionHistory-DhYb8C2e.js.map} +1 -1
- package/dist/shared/{VersionHistory-DELa2Wbq.cjs → VersionHistory-DuSxis_7.cjs} +2 -2
- package/dist/shared/{VersionHistory-DELa2Wbq.cjs.map → VersionHistory-DuSxis_7.cjs.map} +1 -1
- package/dist/shared/{VoiceListeningIndicator-DNX8JlQM.cjs → VoiceListeningIndicator-BXByLZwO.cjs} +2 -2
- package/dist/shared/{VoiceListeningIndicator-DNX8JlQM.cjs.map → VoiceListeningIndicator-BXByLZwO.cjs.map} +1 -1
- package/dist/shared/{VoiceListeningIndicator-BOfVmLK0.js → VoiceListeningIndicator-bv285FSY.js} +4 -4
- package/dist/shared/{VoiceListeningIndicator-BOfVmLK0.js.map → VoiceListeningIndicator-bv285FSY.js.map} +1 -1
- package/dist/shared/{icons-DtCsXuU6.cjs → icons-BCseh2pt.cjs} +3 -3
- package/dist/shared/{icons-DtCsXuU6.cjs.map → icons-BCseh2pt.cjs.map} +1 -1
- package/dist/shared/{icons-C1_s0jKK.js → icons-D5Ctow8R.js} +249 -239
- package/dist/shared/{icons-C1_s0jKK.js.map → icons-D5Ctow8R.js.map} +1 -1
- package/dist/shared/{index-D9WEXpYM.cjs → index-CbIBsFmI.cjs} +2 -2
- package/dist/shared/{index-D9WEXpYM.cjs.map → index-CbIBsFmI.cjs.map} +1 -1
- package/dist/shared/{index-B9jyyL8u.js → index-D_8hJpI7.js} +3 -3
- package/dist/shared/{index-B9jyyL8u.js.map → index-D_8hJpI7.js.map} +1 -1
- package/dist/shared/{leafBlockFactory-Dp382eP8.cjs → leafBlockFactory-CaLWUcfa.cjs} +2 -2
- package/dist/shared/{leafBlockFactory-Dp382eP8.cjs.map → leafBlockFactory-CaLWUcfa.cjs.map} +1 -1
- package/dist/shared/{leafBlockFactory-A9eUhSe-.js → leafBlockFactory-DSa1XCox.js} +2 -2
- package/dist/shared/{leafBlockFactory-A9eUhSe-.js.map → leafBlockFactory-DSa1XCox.js.map} +1 -1
- package/dist/shared/{serialize-T9Odwx24.js → serialize-BqKU9EZy.js} +2 -2
- package/dist/shared/{serialize-T9Odwx24.js.map → serialize-BqKU9EZy.js.map} +1 -1
- package/dist/shared/{serialize-DWEfk0vU.cjs → serialize-CqwX55uK.cjs} +2 -2
- package/dist/shared/{serialize-DWEfk0vU.cjs.map → serialize-CqwX55uK.cjs.map} +1 -1
- package/dist/shared/{starter-kit-lPN-NpJ5.cjs → starter-kit-DyX1U84W.cjs} +2 -2
- package/dist/shared/{starter-kit-lPN-NpJ5.cjs.map → starter-kit-DyX1U84W.cjs.map} +1 -1
- package/dist/shared/{starter-kit-CY5wzYDf.js → starter-kit-mfeNxqFn.js} +7 -7
- package/dist/shared/{starter-kit-CY5wzYDf.js.map → starter-kit-mfeNxqFn.js.map} +1 -1
- package/dist/shared/{usePopoverEdgeClamp-CDG26jBM.cjs → usePopoverEdgeClamp-CPRWQtZV.cjs} +2 -2
- package/dist/shared/{usePopoverEdgeClamp-CDG26jBM.cjs.map → usePopoverEdgeClamp-CPRWQtZV.cjs.map} +1 -1
- package/dist/shared/{usePopoverEdgeClamp-gmFm0CnI.js → usePopoverEdgeClamp-D_hB5t60.js} +2 -2
- package/dist/shared/{usePopoverEdgeClamp-gmFm0CnI.js.map → usePopoverEdgeClamp-D_hB5t60.js.map} +1 -1
- package/dist/starter-kit.cjs +1 -1
- package/dist/starter-kit.js +2 -2
- package/dist/versions.cjs +1 -1
- package/dist/versions.js +1 -1
- package/dist/voice.cjs +1 -1
- package/dist/voice.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,43 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/noteloom)
|
|
4
4
|
[](https://www.npmjs.com/package/noteloom)
|
|
5
|
-
[](
|
|
5
|
+
[](LICENSE)
|
|
6
6
|
[](https://github.com/sponsors/vishwakarmanikhil)
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**A React block editor with zero runtime dependencies.** Nestable blocks, inline
|
|
9
|
+
widgets mid-sentence, slash commands, tables, undo/redo, clipboard — all built on
|
|
10
|
+
a small normalized store. The only things it needs from your app are `react` and
|
|
11
|
+
`react-dom`.
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
## ✨ Highlights
|
|
13
|
-
|
|
14
|
-
- **11 built-in block types** — paragraph, heading, list (bulleted/numbered/to-do/toggle), table, multi-column layout, divider, callout, blockquote, code, toggle heading, button, and embed.
|
|
15
|
-
- **Inline widgets mid-sentence** — select dropdowns, dates, checkboxes, and `@mentions`, spliced directly into running text, not forced onto their own line.
|
|
16
|
-
- **A real default theme**, injected automatically, fully retheme-able via CSS custom properties, or opt out entirely and bring your own.
|
|
17
|
-
- **Mobile/touch-first UI** — a bottom action bar, tap-friendly block picker, and touch-aware popovers, not just a desktop UI that technically renders on a phone.
|
|
18
|
-
- **Voice typing** — continuous dictation plus spoken structural commands ("heading one", "bulleted list", "undo") via the browser's own Speech API, no SDK bundled.
|
|
19
|
-
- **RTL & accessibility built in** — automatic per-block text direction, keyboard-operable menus, live-region announcements, and more.
|
|
20
|
-
- **Two JSON export shapes** — the normalized engine format, or a simpler self-contained shape for storage/API/CRUD use — plus HTML and plain-text export, all with a drop-in "View source" button.
|
|
21
|
-
- **Zero runtime dependencies**, a flat/normalized document model that diffs and stores cleanly, and fine-grained React re-rendering (editing one paragraph in a 500-block doc repaints just that block).
|
|
22
|
-
|
|
23
|
-
## Why this exists
|
|
24
|
-
|
|
25
|
-
Most rich-text editors either bring their own large dependency tree, or force every "special" piece of content (a dropdown, a date, a mention) onto its own line. This one is built around two ideas:
|
|
26
|
-
|
|
27
|
-
- **Inline heterogeneous content is a first-class citizen.** A `select` dropdown, a date picker, or an `@mention` chip can sit in the middle of a sentence, mixed with regular text, in one paragraph — not forced onto a block of its own.
|
|
28
|
-
- **Fine-grained React re-rendering, no virtual-DOM-for-content-editable fights.** Every block subscribes only to its own data via `useSyncExternalStore`; editing one paragraph in a 500-block document doesn't re-render anything else (see `test/performance/largeDocument.test.jsx` for the regression guard on this).
|
|
29
|
-
|
|
30
|
-
---
|
|
31
|
-
|
|
32
|
-
# Getting started
|
|
33
|
-
|
|
34
|
-
## 1. Install
|
|
13
|
+
**[Docs & demo →](https://noteloom.qusere.in)** · **[Playground →](https://noteloom.qusere.in/playground/)** · **[Full guide](docs/guide.md)**
|
|
35
14
|
|
|
36
15
|
```bash
|
|
37
16
|
npm install noteloom react react-dom
|
|
38
17
|
```
|
|
39
18
|
|
|
40
|
-
## 2. Create an editor
|
|
41
|
-
|
|
42
19
|
```jsx
|
|
43
20
|
import { useEditor, NoteloomEditor } from 'noteloom';
|
|
44
21
|
|
|
@@ -48,1055 +25,214 @@ function Editor() {
|
|
|
48
25
|
}
|
|
49
26
|
```
|
|
50
27
|
|
|
51
|
-
That's the whole thing
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
```jsx
|
|
56
|
-
const editor = useEditor({
|
|
57
|
-
doc: myDocumentJSON, // defaults to one empty paragraph
|
|
58
|
-
history: true, // default; false gives a plain EditorStore with no undo/redo
|
|
59
|
-
});
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
## 4. Try it / learn by example
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
npm run dev:quickstart # the exact 3 lines above, runnable
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
Then work through the rest of `examples/` in order — each one adds exactly one new idea on top of the last (a custom block, a custom dropdown field, theming, ...). See **[`examples/README.md`](examples/README.md)** for the full list and what each one teaches.
|
|
69
|
-
|
|
70
|
-
Everything past this point is a reference guide, in two parts:
|
|
71
|
-
|
|
72
|
-
- **[Basic guide](#basic-guide)** — every built-in feature (styling, custom field types, export, collaboration, offline, ...), all built on `useEditor()`/`<NoteloomEditor>` from step 2 above.
|
|
73
|
-
- **[Advanced: the granular API](#advanced-the-granular-api)** — for when you need more control than that gives you (a custom toolbar, a hand-picked subset of blocks, writing a whole new block component). `useEditor()` still hands you the raw pieces (`{ store, registry, inlineRegistry }`) to drop into this API — the two are never an either/or choice.
|
|
74
|
-
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
# Basic guide
|
|
78
|
-
|
|
79
|
-
Every example below uses `editor`/`store`/`registry`/`inlineRegistry` from `useEditor()` (`const { store, registry, inlineRegistry } = editor;`) unless it says otherwise.
|
|
80
|
-
|
|
81
|
-
## 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
|
-
|
|
99
|
-
## Built-in block types
|
|
100
|
-
|
|
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).
|
|
102
|
-
|
|
103
|
-
## Built-in inline types
|
|
104
|
-
|
|
105
|
-
Atomic, non-text content that can be spliced into running text via the slash menu at any cursor position — `select` (with in-editor add/remove-option UI), `date` (native `<input type="date">`), `checkbox`.
|
|
106
|
-
|
|
107
|
-
There's no separate hardcoded `mention` type — an `@name` chip is just an ordinary use of `createSelectFieldType` (see [Custom dropdown / mention field types](#custom-dropdown--mention-field-types-static-or-dynamicapi-backed) below), with `triggers: ['slash', 'at']` so it also shows up under a second, dedicated "@" trigger (`useAtMenuTrigger`), alongside "/".
|
|
108
|
-
|
|
109
|
-
## Styling — zero setup required
|
|
110
|
-
|
|
111
|
-
You don't need to import any CSS. The moment `<NoteloomEditor>` mounts, it injects a single `<style>` tag with a minimal, clean default theme — no `import 'noteloom/style.css'` line, no build-tool CSS configuration, nothing to wire up. It's idempotent (mounting more than one editor on a page only injects it once) and client-only (a no-op under SSR; hydrate as normal and it injects on mount).
|
|
112
|
-
|
|
113
|
-
**Retheme it** by overriding the CSS custom properties it reads from — defined on `:root` (not scoped to a wrapper element, since portaled pieces like the slash menu and Select's popover aren't DOM descendants of the editor itself):
|
|
114
|
-
|
|
115
|
-
```css
|
|
116
|
-
:root {
|
|
117
|
-
--noteloom-accent: #16a34a; /* swap the indigo accent for green */
|
|
118
|
-
--noteloom-radius-md: 4px; /* sharper corners */
|
|
119
|
-
--noteloom-font: 'Inter', sans-serif;
|
|
120
|
-
}
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
Dark mode follows `prefers-color-scheme` automatically; to control it explicitly instead (e.g. a manual light/dark toggle), set `data-theme="dark"` or `data-theme="light"` on any ancestor (typically `<html>`) — see the full variable list in `src/style.css`. (`examples/04-styling/` is a complete runnable version of everything in this section.)
|
|
124
|
-
|
|
125
|
-
**Scope overrides to one editor instance**, or add your own class for full custom CSS, via `className`/`style` — passing either wraps the editor surface in one `<div className="be-root ...">`:
|
|
126
|
-
|
|
127
|
-
```jsx
|
|
128
|
-
<NoteloomEditor editor={editor} className="my-editor" style={{ '--noteloom-accent': '#16a34a' }} />
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
No wrapper `<div>` is added unless you pass one of these props, so existing usage is unaffected either way.
|
|
132
|
-
|
|
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):
|
|
134
|
-
|
|
135
|
-
```jsx
|
|
136
|
-
<NoteloomEditor editor={editor} theme="none" />
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
`examples/basic/src/style.css` shows the extra page-level chrome (fonts, page width, the demo's own toolbar buttons) a host app typically adds around the editor — none of that is part of the default theme itself.
|
|
140
|
-
|
|
141
|
-
**Customize individual blocks**, not just the root, via `getBlockClassName`:
|
|
142
|
-
|
|
143
|
-
```jsx
|
|
144
|
-
<NoteloomEditor
|
|
145
|
-
editor={editor}
|
|
146
|
-
getBlockClassName={(block) => (block.type === 'callout' ? 'my-callout' : undefined)}
|
|
147
|
-
/>
|
|
148
|
-
```
|
|
149
|
-
|
|
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.
|
|
151
|
-
|
|
152
|
-
## Picking only the blocks/inline types you want
|
|
153
|
-
|
|
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:
|
|
155
|
-
|
|
156
|
-
```jsx
|
|
157
|
-
import {
|
|
158
|
-
useEditor,
|
|
159
|
-
NoteloomEditor,
|
|
160
|
-
registerBlocks,
|
|
161
|
-
paragraphBlockType,
|
|
162
|
-
headingBlockType,
|
|
163
|
-
TABLE_BLOCKS,
|
|
164
|
-
} from 'noteloom';
|
|
165
|
-
|
|
166
|
-
function Editor() {
|
|
167
|
-
const editor = useEditor({
|
|
168
|
-
registerBlocks: (registry) =>
|
|
169
|
-
registerBlocks(registry, {
|
|
170
|
-
paragraph: paragraphBlockType,
|
|
171
|
-
heading: headingBlockType,
|
|
172
|
-
...TABLE_BLOCKS,
|
|
173
|
-
}),
|
|
174
|
-
});
|
|
175
|
-
return <NoteloomEditor editor={editor} />;
|
|
176
|
-
}
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
`registerBuiltInBlocks(registry)` (what `useEditor()` calls by default) is itself just `registerBlocks(registry, { paragraph: paragraphBlockType, ... })` with every type included — so mixing "give me everything" and "just these few" across different parts of your app is never an either/or choice. `layout`/`table` each need their own group of related types registered together — see `LAYOUT_BLOCKS`/`TABLE_BLOCKS`. `TABLE_SELECT_INLINE_TYPES` (inline side) is only needed if you use a table's "select" column type.
|
|
28
|
+
That's the whole thing — a working editor with every built-in block/inline type,
|
|
29
|
+
slash + `@` + emoji menus, the formatting toolbar, keyboard shortcuts, clipboard,
|
|
30
|
+
find & replace, and the default theme, all wired up. No CSS import needed.
|
|
180
31
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
```jsx
|
|
184
|
-
import { useEditor, NoteloomEditor, registerBuiltInBlocks } from 'noteloom';
|
|
185
|
-
|
|
186
|
-
function Editor() {
|
|
187
|
-
const editor = useEditor({
|
|
188
|
-
registerBlocks: (registry) => {
|
|
189
|
-
registerBuiltInBlocks(registry); // keep everything built-in...
|
|
190
|
-
registry.register('myCustomType', myBlockTypeEntry); // ...plus your own (see "Advanced" below)
|
|
191
|
-
},
|
|
192
|
-
});
|
|
193
|
-
return <NoteloomEditor editor={editor} />;
|
|
194
|
-
}
|
|
195
|
-
```
|
|
32
|
+
Pass a starting document (`useEditor({ doc })` — the [simple JSON format](#document-format)
|
|
33
|
+
or the internal shape, auto-detected), or `history: false` to drop undo/redo.
|
|
196
34
|
|
|
197
|
-
|
|
35
|
+
## Highlights
|
|
198
36
|
|
|
199
|
-
**
|
|
37
|
+
- **Inline widgets are first-class** — a `select` dropdown, date picker, or
|
|
38
|
+
`@mention` chip sits _in the middle of a sentence_, not on its own line.
|
|
39
|
+
- **Fine-grained rendering** — every block subscribes only to its own data;
|
|
40
|
+
editing one paragraph in a 500-block doc repaints just that block.
|
|
41
|
+
- **13 built-in block types** — paragraph, heading, list (bulleted/numbered/to-do/toggle),
|
|
42
|
+
table, multi-column layout, divider, callout, blockquote, code, toggle heading,
|
|
43
|
+
button, embed, canvas — plus atomic inline types (`select`, `date`, `checkbox`, …).
|
|
44
|
+
- **Typed extension API** — `defineBlock` / `defineInline` / `defineExtension`
|
|
45
|
+
with a stable `ctx` facade; `npx noteloom new block <name>` to scaffold one.
|
|
46
|
+
- **One canonical document format** — self-contained JSON with a published
|
|
47
|
+
[schema](docs/document.schema.json); `editor.toJSON()`.
|
|
48
|
+
- **Opt-in heavy features** — collaboration, persistence, comments, version
|
|
49
|
+
history, voice typing each have their own import so they leave your bundle if
|
|
50
|
+
unused.
|
|
51
|
+
- **Retheme-able**, RTL-aware, keyboard-operable, mobile/touch-first.
|
|
200
52
|
|
|
201
|
-
|
|
53
|
+
## Composing the block set
|
|
202
54
|
|
|
203
|
-
|
|
55
|
+
`useEditor()` registers every built-in type. Pass `extensions` to control the
|
|
56
|
+
set — drop what you don't need, add the freehand-drawing `canvas` block from its
|
|
57
|
+
own (heavier) entry point, and register your own via `defineBlock`:
|
|
204
58
|
|
|
205
59
|
```jsx
|
|
206
|
-
import { useEditor, NoteloomEditor,
|
|
207
|
-
|
|
60
|
+
import { useEditor, NoteloomEditor, starterKit, defineBlock } from 'noteloom';
|
|
61
|
+
import { canvasBlockType } from 'noteloom/canvas';
|
|
62
|
+
import { RatingBlock } from './RatingBlock.jsx'; // a React component, receives { id }
|
|
208
63
|
|
|
64
|
+
// A whole custom block type — no text, no children, value lives in props.
|
|
65
|
+
// examples/02-custom-block/ is the runnable version of this.
|
|
209
66
|
const rating = defineBlock({
|
|
210
|
-
name: 'rating',
|
|
211
|
-
component: RatingBlock,
|
|
212
|
-
contentModel: 'void', // 'blocks'
|
|
67
|
+
name: 'rating',
|
|
68
|
+
component: RatingBlock,
|
|
69
|
+
contentModel: 'void', // 'blocks' | 'runs' | 'void'
|
|
213
70
|
defaultProps: { stars: 0 },
|
|
214
71
|
toHTML: (block) => `<div data-stars="${block.props.stars}"></div>`,
|
|
215
|
-
slashCommand: {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
72
|
+
slashCommand: {
|
|
73
|
+
label: 'Rating',
|
|
74
|
+
keywords: ['stars'],
|
|
75
|
+
run: (store, { blockId }) => {
|
|
76
|
+
/* erase "/rating", insert a { type: 'rating' } block after blockId */
|
|
251
77
|
},
|
|
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
|
-
|
|
259
|
-
## Custom dropdown / mention field types (static, or dynamic/API-backed)
|
|
260
|
-
|
|
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**:
|
|
262
|
-
|
|
263
|
-
```jsx
|
|
264
|
-
import {
|
|
265
|
-
useEditor,
|
|
266
|
-
NoteloomEditor,
|
|
267
|
-
registerBuiltInInlineTypes,
|
|
268
|
-
createSelectFieldType,
|
|
269
|
-
} from 'noteloom';
|
|
270
|
-
|
|
271
|
-
const statusFieldType = createSelectFieldType({
|
|
272
|
-
type: 'status', // must match the key you register it under
|
|
273
|
-
label: 'Status', // shown in the "/" menu and as the search box's aria-label
|
|
274
|
-
placeholder: 'Set status…',
|
|
275
|
-
variant: 'tag', // 'tag' = colored pill; 'default' = plain bordered dropdown
|
|
276
|
-
options: [
|
|
277
|
-
{ value: 'todo', label: 'To do', color: { bg: '#e9e9e7', text: '#37352f' } },
|
|
278
|
-
{ value: 'doing', label: 'In progress', color: { bg: '#fdecc8', text: '#a06400' } },
|
|
279
|
-
{ value: 'done', label: 'Done', color: { bg: '#dbeddb', text: '#2f7a2f' } },
|
|
280
|
-
],
|
|
78
|
+
},
|
|
281
79
|
});
|
|
282
80
|
|
|
283
81
|
function Editor() {
|
|
284
82
|
const editor = useEditor({
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
83
|
+
extensions: [
|
|
84
|
+
...starterKit({ exclude: ['canvas'] }), // every built-in except canvas…
|
|
85
|
+
canvasBlockType, // …then canvas back, explicitly, from noteloom/canvas
|
|
86
|
+
rating, // …plus your own
|
|
87
|
+
],
|
|
289
88
|
});
|
|
290
89
|
return <NoteloomEditor editor={editor} />;
|
|
291
90
|
}
|
|
292
91
|
```
|
|
293
92
|
|
|
294
|
-
`
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
```js
|
|
301
|
-
createSelectFieldType({
|
|
302
|
-
type: 'assignee',
|
|
303
|
-
label: 'Assignee',
|
|
304
|
-
placeholder: 'Assign to…',
|
|
305
|
-
variant: 'tag',
|
|
306
|
-
triggers: ['slash', 'at'], // reachable via "/assignee" AND by typing "@" directly
|
|
307
|
-
options: async (query) => {
|
|
308
|
-
const res = await fetch(`/api/users?search=${encodeURIComponent(query)}`);
|
|
309
|
-
const users = await res.json();
|
|
310
|
-
return users.map((u) => ({ value: u.id, label: u.name }));
|
|
311
|
-
},
|
|
312
|
-
});
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
A few things worth knowing about the dynamic path:
|
|
316
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
322
|
-
|
|
323
|
-
### Letting end users create their own field types, in-editor
|
|
324
|
-
|
|
325
|
-
The above is for types **you** define in code. If you also want a non-technical end user to be able to create new (always static — there's no way to author a fetch function through a UI) select types from inside the editor itself, mount `FieldTypeEditorModal` once and wire a button to it, anywhere inside `<NoteloomEditor>` (as `children`, or in your own chrome around it via `useFieldTypeEditor`):
|
|
326
|
-
|
|
327
|
-
```jsx
|
|
328
|
-
import { NoteloomEditor, FieldTypeEditorModal, useFieldTypeEditor } from 'noteloom';
|
|
329
|
-
|
|
330
|
-
function NewFieldTypeButton() {
|
|
331
|
-
const { openCreate } = useFieldTypeEditor();
|
|
332
|
-
return <button onClick={openCreate}>+ New field type</button>;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
<NoteloomEditor editor={editor}>
|
|
336
|
-
<NewFieldTypeButton />
|
|
337
|
-
<FieldTypeEditorModal />
|
|
338
|
-
</NoteloomEditor>;
|
|
339
|
-
```
|
|
340
|
-
|
|
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.
|
|
342
|
-
|
|
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)
|
|
346
|
-
|
|
347
|
-
```js
|
|
348
|
-
import {
|
|
349
|
-
exportDocumentJSON,
|
|
350
|
-
exportDocumentHTML,
|
|
351
|
-
exportDocumentMarkdown,
|
|
352
|
-
exportDocumentWordHTML,
|
|
353
|
-
exportDocumentText,
|
|
354
|
-
} from 'noteloom';
|
|
355
|
-
|
|
356
|
-
exportDocumentJSON(store); // a JSON *string* — JSON.parse() it to get { version, rootId, blocks, runs }, usable as useEditor({ doc })
|
|
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
|
|
360
|
-
exportDocumentText(store, registry, inlineRegistry);
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
Or mount the ready-made button + modal instead of wiring your own UI:
|
|
364
|
-
|
|
365
|
-
```jsx
|
|
366
|
-
import { DocumentExportButton } from 'noteloom';
|
|
367
|
-
|
|
368
|
-
<DocumentExportButton label="View source" />;
|
|
369
|
-
```
|
|
370
|
-
|
|
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.
|
|
375
|
-
|
|
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
|
-
```
|
|
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:
|
|
394
|
-
|
|
395
|
-
```js
|
|
396
|
-
import { exportDocumentSimpleJSON, importDocumentSimpleJSON } from 'noteloom';
|
|
397
|
-
|
|
398
|
-
const json = exportDocumentSimpleJSON(store, registry, inlineRegistry);
|
|
399
|
-
// {
|
|
400
|
-
// "version": 1,
|
|
401
|
-
// "blocks": [
|
|
402
|
-
// { "id": "p1", "type": "paragraph", "data": { "text": "Hello <strong>world</strong>" } },
|
|
403
|
-
// { "id": "h1", "type": "heading", "data": { "text": "Key features", "level": 3 } },
|
|
404
|
-
// {
|
|
405
|
-
// "id": "li1", "type": "listItem",
|
|
406
|
-
// "data": { "text": "Nested item", "ordered": false, "checked": null },
|
|
407
|
-
// "children": [ /* nested listItem blocks, same shape */ ]
|
|
408
|
-
// },
|
|
409
|
-
// {
|
|
410
|
-
// "id": "t1", "type": "table",
|
|
411
|
-
// "data": { "columns": [{ "id": "c1", "label": "Name" }], "rows": [["Cell text"]] }
|
|
412
|
-
// }
|
|
413
|
-
// ]
|
|
414
|
-
// }
|
|
415
|
-
|
|
416
|
-
// ...later, or on a different machine/process:
|
|
417
|
-
const doc = importDocumentSimpleJSON(json, registry, inlineRegistry); // -> { rootId, blocks, runs }
|
|
418
|
-
const editor2 = useEditor({ doc: JSON.parse(json) }); // useEditor detects the shape; or new EditorStore(doc) outside React
|
|
419
|
-
```
|
|
420
|
-
|
|
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).
|
|
422
|
-
|
|
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.
|
|
424
|
-
|
|
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.
|
|
426
|
-
|
|
427
|
-
## Templates
|
|
428
|
-
|
|
429
|
-
Two kinds — a **document template** seeds a whole new editor (`useEditor({ doc })`), a **block template** is a saved snippet insertable anywhere via "/". Both are developer-definable in code and end-user-creatable/persisted (IndexedDB, alongside `usePersistedDocument`'s own storage but a separate object store — a template isn't tied to any one document). `examples/05-templates/` is a complete runnable app combining every piece below.
|
|
430
|
-
|
|
431
|
-
**Block templates — reusable snippets, insertable via "/":**
|
|
432
|
-
|
|
433
|
-
```js
|
|
434
|
-
import {
|
|
435
|
-
EditorStore,
|
|
436
|
-
captureBlockTemplate,
|
|
437
|
-
registerBlockTemplates,
|
|
438
|
-
registerBuiltInBlocks,
|
|
439
|
-
} from 'noteloom';
|
|
440
|
-
|
|
441
|
-
// Build once (a throwaway store is fine — only its content is captured):
|
|
442
|
-
const draftStore = new EditorStore({
|
|
443
|
-
rootId: 'root',
|
|
444
|
-
blocks: [
|
|
445
|
-
{ id: 'root', type: 'page', parentId: null, contentIds: ['h1', 'li1'], props: {} },
|
|
446
|
-
{ id: 'h1', type: 'heading', parentId: 'root', contentIds: ['r1'], props: { level: 2 } },
|
|
447
|
-
{
|
|
448
|
-
id: 'li1',
|
|
449
|
-
type: 'listItem',
|
|
450
|
-
parentId: 'root',
|
|
451
|
-
contentIds: [],
|
|
452
|
-
props: { ordered: true, titleRunIds: ['r2'] },
|
|
453
|
-
},
|
|
454
|
-
],
|
|
455
|
-
runs: [
|
|
456
|
-
{ id: 'r1', type: 'text', value: 'Meeting agenda', marks: {} },
|
|
457
|
-
{ id: 'r2', type: 'text', value: 'Review previous action items', marks: {} },
|
|
458
|
-
],
|
|
459
|
-
});
|
|
460
|
-
const agendaSnippet = captureBlockTemplate(draftStore, ['h1', 'li1']);
|
|
461
|
-
|
|
462
|
-
const editor = useEditor({
|
|
463
|
-
registerBlocks: (registry) => {
|
|
464
|
-
registerBuiltInBlocks(registry);
|
|
465
|
-
registerBlockTemplates(registry, [
|
|
466
|
-
{ id: 'agenda', label: 'Meeting agenda', keywords: ['agenda'], roots: agendaSnippet.roots },
|
|
467
|
-
]);
|
|
468
|
-
},
|
|
469
|
-
});
|
|
470
|
-
```
|
|
471
|
-
|
|
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 "/".
|
|
473
|
-
|
|
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)`.
|
|
475
|
-
|
|
476
|
-
**Saving/browsing a library of templates** (either kind), persisted so it survives reload:
|
|
93
|
+
`starterKit()` on its own is the full default set (`useEditor()` with no
|
|
94
|
+
`extensions` is identical). `defineExtension` also carries **behavior** —
|
|
95
|
+
`keymap`, `onBeforeInput`, `onPaste`, `setup(ctx)` — and `smartQuotes()` /
|
|
96
|
+
`autoPairBrackets()` are ready-made ones. Full walkthrough:
|
|
97
|
+
[guide → extension API](docs/guide.md#defineblock--extensions--the-newer-way).
|
|
477
98
|
|
|
478
|
-
|
|
479
|
-
import {
|
|
480
|
-
useEditor,
|
|
481
|
-
NoteloomEditor,
|
|
482
|
-
useTemplates,
|
|
483
|
-
TemplatePicker,
|
|
484
|
-
saveTemplate,
|
|
485
|
-
exportDocumentJSON,
|
|
486
|
-
} from 'noteloom';
|
|
487
|
-
|
|
488
|
-
function NewDocumentScreen({ onPick }) {
|
|
489
|
-
const { templates, isLoaded } = useTemplates({ scope: 'document' }); // or 'block', or omit for both
|
|
490
|
-
if (!isLoaded) return <p>Loading…</p>;
|
|
491
|
-
return <TemplatePicker templates={templates} onSelect={(template) => onPick(template.doc)} />;
|
|
492
|
-
}
|
|
99
|
+
## Import map
|
|
493
100
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
await saveTemplate({
|
|
497
|
-
id: crypto.randomUUID(),
|
|
498
|
-
scope: 'document',
|
|
499
|
-
name,
|
|
500
|
-
doc: JSON.parse(exportDocumentJSON(store)), // exportDocumentJSON returns a JSON *string* — parse it first
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
```
|
|
101
|
+
The basic editor is one import. Heavy optional features have their own entry so
|
|
102
|
+
a bundle that doesn't use them drops the code:
|
|
504
103
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
104
|
+
| Import | What's in it |
|
|
105
|
+
| ---------------------- | ------------------------------------------------------------------------------------ |
|
|
106
|
+
| `noteloom` | the editor, every built-in type, menus, clipboard, export, templates, find & replace |
|
|
107
|
+
| `noteloom/theme` | the default stylesheet (also `noteloom/style.css`) |
|
|
108
|
+
| `noteloom/starter-kit` | `starterKit()`, `defineBlock`, `defineInline`, `registerExtensions` |
|
|
109
|
+
| `noteloom/collab` | real-time collaboration (a custom CRDT over WebRTC) |
|
|
110
|
+
| `noteloom/persistence` | IndexedDB auto-save + PWA service-worker hook |
|
|
111
|
+
| `noteloom/comments` | comment threads + the built-in comment UI |
|
|
112
|
+
| `noteloom/versions` | automatic version history + `<VersionHistory>` |
|
|
113
|
+
| `noteloom/voice` | voice typing |
|
|
114
|
+
| `noteloom/canvas` | the freehand-drawing block |
|
|
515
115
|
|
|
516
|
-
|
|
116
|
+
Every name in a feature entry is still exported from `noteloom` too (deprecated;
|
|
117
|
+
see [`docs/migration.md`](docs/migration.md)).
|
|
517
118
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
Select a range, leave a comment on it; click or hover the highlighted text later to view/reply/resolve/delete it — `examples/06-comments/` is a complete runnable app. Two ways to wire it up:
|
|
521
|
-
|
|
522
|
-
### The built-in UI (zero comment-authoring code of your own)
|
|
523
|
-
|
|
524
|
-
Pass `commentAuthorId` — the current user's id — to `<NoteloomEditor>` and the whole experience just works, Notion/Google Docs-style:
|
|
525
|
-
|
|
526
|
-
```jsx
|
|
527
|
-
<NoteloomEditor editor={editor} commentAuthorId={currentUser.id} showCommentsPanel />
|
|
528
|
-
```
|
|
529
|
-
|
|
530
|
-
- The floating format toolbar's Comment button opens a small inline composer (a textarea, matching the rest of the toolbar's minimal chrome) and creates the comment on submit.
|
|
531
|
-
- Clicking (or hovering) any highlighted comment opens a popover right there with the thread's messages and Reply/Resolve/Delete — mirroring how the existing link hover card works, just triggered by click too, not hover alone.
|
|
532
|
-
- `showCommentsPanel` (optional) adds a right-side panel listing every thread in the document, unresolved first — the "extra feature" for apps that want a persistent overview alongside the inline popovers, not instead of them. It's `position: fixed` by default (see `.be-comments-panel` in style.css) so it needs no layout changes on your end; override that rule for a different placement.
|
|
533
|
-
|
|
534
|
-
Every reply/new-comment composed through any of these built-in surfaces is attributed to `commentAuthorId`. Omit it and the toolbar's Comment button disappears, the click/hover popover on existing comments still works (viewing/resolving/deleting need no identity) but hides its Reply composer, and `showCommentsPanel` still lists threads read-only in the same way.
|
|
535
|
-
|
|
536
|
-
For the granular API, render the pieces yourself anywhere under an `<EditorProvider commentAuthorId={currentUser.id}>`: `<FloatingToolbar commentAuthorId={...} .../>` for the toolbar button, `<CommentsPanel authorId={...} />` for the sidebar — the click/hover popover (`CommentPopover`) is mounted automatically inside every block's editable content, same as the link hover card, so there's nothing extra to render for it.
|
|
537
|
-
|
|
538
|
-
### Full control (bring your own UI)
|
|
539
|
-
|
|
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):
|
|
541
|
-
|
|
542
|
-
```jsx
|
|
543
|
-
import {
|
|
544
|
-
addComment,
|
|
545
|
-
replyToComment,
|
|
546
|
-
resolveComment,
|
|
547
|
-
deleteComment,
|
|
548
|
-
useComments,
|
|
549
|
-
resolveMultiRunSelection,
|
|
550
|
-
} from 'noteloom';
|
|
551
|
-
|
|
552
|
-
<NoteloomEditor
|
|
553
|
-
editor={editor}
|
|
554
|
-
onComment={(range) => {
|
|
555
|
-
const text = window.prompt('Comment text?');
|
|
556
|
-
if (text) addComment(editor.store, range, { authorId: currentUser.id, text });
|
|
557
|
-
}}
|
|
558
|
-
/>;
|
|
559
|
-
|
|
560
|
-
// Outside the floating toolbar entirely, resolve the selection yourself:
|
|
561
|
-
function AddCommentButton({ store }) {
|
|
562
|
-
function handleClick() {
|
|
563
|
-
const range = resolveMultiRunSelection(); // { blockId, startRunId, startOffset, endRunId, endOffset }
|
|
564
|
-
if (!range) return; // no non-collapsed selection
|
|
565
|
-
addComment(store, range, { authorId: currentUser.id, text: 'Can we tighten this up?' });
|
|
566
|
-
}
|
|
567
|
-
return <button onClick={handleClick}>Add comment</button>;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// A hand-rolled list, using useComments() directly instead of CommentsPanel/CommentThreadCard:
|
|
571
|
-
function CommentsSidebar({ store }) {
|
|
572
|
-
const comments = useComments();
|
|
573
|
-
return (
|
|
574
|
-
<ul>
|
|
575
|
-
{comments.map((thread) => (
|
|
576
|
-
<li key={thread.id}>
|
|
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>
|
|
592
|
-
<button onClick={() => deleteComment(store, thread.id)}>Delete</button>
|
|
593
|
-
</li>
|
|
594
|
-
))}
|
|
595
|
-
</ul>
|
|
596
|
-
);
|
|
597
|
-
}
|
|
598
|
-
```
|
|
599
|
-
|
|
600
|
-
`onComment` (given to `<NoteloomEditor>` or `<FloatingToolbar>` directly) always takes priority over `commentAuthorId`'s built-in composer, so the two can't fight over the same button. The Comment button only appears for a same-block selection either way — `addCommentMarkOverRange` doesn't support a cross-block range yet, the same single-block scope every mark-toggle command already has for its own splitting logic.
|
|
601
|
-
|
|
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.
|
|
603
|
-
|
|
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.
|
|
605
|
-
|
|
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`.
|
|
607
|
-
|
|
608
|
-
## Version history
|
|
609
|
-
|
|
610
|
-
Google Docs-style — there's no "type a label and save" step. Point-in-time document snapshots (stored in IndexedDB, a third object store alongside `usePersistedDocument`'s `documents` and Templates' `templates`) are captured automatically after each burst of edits settles down, each one attributed to whoever made the changes. `examples/07-version-history/` is a complete runnable app.
|
|
611
|
-
|
|
612
|
-
```jsx
|
|
613
|
-
import { useEditor, NoteloomEditor, VersionHistory } from 'noteloom';
|
|
614
|
-
|
|
615
|
-
const editor = useEditor({ currentUserId: currentUser.id }); // stamps every edit's author, see below
|
|
616
|
-
|
|
617
|
-
<NoteloomEditor editor={editor}>
|
|
618
|
-
<VersionHistory docId={docId} />
|
|
619
|
-
</NoteloomEditor>;
|
|
620
|
-
```
|
|
621
|
-
|
|
622
|
-
That's the whole integration. `<VersionHistory>` is self-contained: it renders the "Version history" button, and for as long as it's mounted it also quietly captures snapshots in the background — no separate wiring needed. Clicking the button opens a drawer (matching the built-in Comments UI's own design language) listing every version grouped by day, each showing an avatar, author, relative time, and a lightweight summary ("3 blocks changed"); clicking one opens it on a **Changes** tab — a word-level diff against the version right before it, insertions highlighted green, deletions struck through in red, Google Docs "show changes"-style — with a **Preview** tab alongside for a plain read-only render, and a "Restore this version" button.
|
|
623
|
-
|
|
624
|
-
**Attribution** — `currentUserId` (passed to `useEditor()`, or `history.setDefaultActorId(id)`/`new History(store, { defaultActorId })` for the granular API) is stamped as every edit's `actorId` automatically; `VersionHistory`/`createAutoVersionHistory` read it straight off the history log, no separate identity plumbing required. Omit it and versions still get created, just with `authorId: null` (shown as "Unknown").
|
|
625
|
-
|
|
626
|
-
**Tuning the capture window** — `<VersionHistory docId idleMs={5 * 60 * 1000} maxVersions={200} />`: `idleMs` (default 5 minutes) is how long edits need to pause before a version is closed and saved (a smaller value in the example app, so you don't have to actually wait); `maxVersions` prunes the oldest versions beyond that count. For the granular API, or to save an explicit snapshot right before some risky action, use `createAutoVersionHistory({ store, docId, idleMs?, maxVersions? })` directly — it returns `{ stop, flush }`; `flush()` closes and saves the current window immediately instead of waiting for the idle gap (e.g. right before navigating away).
|
|
627
|
-
|
|
628
|
-
Restoring needs no new function — it's the exact same `applyDocumentTemplate(store, doc)` Templates already uses to wholesale-replace a live editor's content, which is what the drawer's own Restore button calls. `saveDocumentVersion`/`loadDocumentVersion`/`deleteDocumentVersion`/`listDocumentVersions` are the raw storage operations everything above is built on, for anywhere the built-in UI doesn't fit; `useDocumentVersions(docId)` is the reactive hook if you want to build your own list instead of `<VersionHistory>`; `diffDocumentsHTML(prevDoc, nextDoc)` is the diffing function behind the Changes tab (pass `null` as `prevDoc` to mark everything as newly added), for building a custom diff view instead.
|
|
629
|
-
|
|
630
|
-
## Right-to-left / multi-language text
|
|
631
|
-
|
|
632
|
-
Every block defaults to `dir="auto"` — the browser's own Unicode bidi algorithm detects direction per block from its first strong character, so a document mixing LTR and RTL blocks (an English heading over an Arabic paragraph, say) just works with zero configuration. For the cases `auto` can't infer on its own (most commonly an empty block, which has no text yet to detect a direction from), set an explicit override:
|
|
633
|
-
|
|
634
|
-
```js
|
|
635
|
-
import { operations } from 'noteloom';
|
|
636
|
-
|
|
637
|
-
// Document-wide default:
|
|
638
|
-
store.applyOperation(operations.updateBlockProps(store.getRootId(), { dir: 'rtl' }));
|
|
639
|
-
// Or just one block:
|
|
640
|
-
store.applyOperation(operations.updateBlockProps(blockId, { dir: 'rtl' }));
|
|
641
|
-
```
|
|
642
|
-
|
|
643
|
-
A block's own `dir` wins over the document's; the block gutter menu also has a "Switch to right-to-left"/"left-to-right" item that sets this per-block. Code blocks are always `dir="ltr"` regardless of the surrounding document's default — code syntax (brackets, operators) is structurally LTR no matter what language a comment or string literal happens to be written in.
|
|
644
|
-
|
|
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.
|
|
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
|
-
|
|
685
|
-
## Printing & PDF
|
|
686
|
-
|
|
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.
|
|
688
|
-
|
|
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:
|
|
690
|
-
|
|
691
|
-
```js
|
|
692
|
-
window.print(); // Ctrl+P / Cmd+P works too — "Save as PDF" in the print dialog is your PDF export
|
|
693
|
-
```
|
|
694
|
-
|
|
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.
|
|
698
|
-
|
|
699
|
-
## Voice typing
|
|
700
|
-
|
|
701
|
-
`useVoiceTyping()` wraps the browser's native Web Speech API (`SpeechRecognition`) for continuous dictation mixed with spoken structural commands — say "heading one", "new paragraph", "bulleted list", "quote", "undo", etc. while dictating, and the current block converts (or a new one is inserted) instead of those words being typed as text:
|
|
702
|
-
|
|
703
|
-
```jsx
|
|
704
|
-
import { useVoiceTyping } from 'noteloom';
|
|
705
|
-
|
|
706
|
-
function MicButton() {
|
|
707
|
-
const voice = useVoiceTyping();
|
|
708
|
-
if (!voice.isSupported) return null; // e.g. Firefox — no bundled fallback, degrades to nothing
|
|
709
|
-
return (
|
|
710
|
-
<button onClick={() => (voice.isListening ? voice.stop() : voice.start())}>
|
|
711
|
-
{voice.isListening ? 'Stop dictation' : 'Start dictation'}
|
|
712
|
-
</button>
|
|
713
|
-
);
|
|
714
|
-
}
|
|
715
|
-
```
|
|
716
|
-
|
|
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.
|
|
718
|
-
|
|
719
|
-
## Mobile / touch support
|
|
720
|
-
|
|
721
|
-
Typing "/"/"@" still works on a phone keyboard, but it's not a reliable or discoverable primary path there (autocorrect, awkward key access, nothing to discover it by) — so on a coarse (touch) pointer, `MobileActionBar` takes over as the touch-first equivalent, pinned above the on-screen keyboard. It needs direct access to the same DOM element your editor surface renders into (to track focus/selection inside it), which `<NoteloomEditor>` doesn't expose — so this one piece needs the [granular API](#advanced-the-granular-api):
|
|
722
|
-
|
|
723
|
-
```jsx
|
|
724
|
-
import { MobileActionBar } from 'noteloom';
|
|
725
|
-
|
|
726
|
-
// next to your other trigger hooks/components, same containerRef:
|
|
727
|
-
<MobileActionBar containerRef={containerRef} />;
|
|
728
|
-
```
|
|
729
|
-
|
|
730
|
-
`examples/basic` has this fully wired up (run `npm run dev`, then resize to a narrow viewport or open it on a phone).
|
|
731
|
-
|
|
732
|
-
It renders nothing on a mouse/trackpad, and nothing until focus is actually inside the editor. Its contents swap based on context:
|
|
733
|
-
|
|
734
|
-
- **Block options** (shown whenever the caret/selection is inside any block) → Duplicate/Move up/Move down/Hide-Show/Delete, in `MobileBlockOptionsSheet` — the mobile home for the desktop per-block gutter's own grip-handle menu. The gutter itself is hidden entirely on touch input (no hover state exists to reveal it by, and its desktop position sits in a page margin that doesn't exist on a narrow viewport), so both of its actions ("+" and the options menu) live in this bar instead of the gutter on touch.
|
|
735
|
-
- **Text selected** → formatting actions (bold/italic/underline/link) — the desktop `FloatingToolbar` bubble also disables itself on touch, so this is the single formatting surface either way (both share the same `useTextFormattingActions` hook, not two copies).
|
|
736
|
-
- **Collapsed caret, table cell** → insert row/column.
|
|
737
|
-
- **Collapsed caret, code block** → language picker.
|
|
738
|
-
- **Collapsed caret, callout** → color picker.
|
|
739
|
-
- **Collapsed caret, everywhere else** → "+" (opens `MobileBlockPickerSheet`, a tap-friendly bottom sheet listing every insertable block, same commands "/" already offers), Undo/Redo, dismiss-keyboard.
|
|
740
|
-
|
|
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).
|
|
742
|
-
|
|
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.
|
|
744
|
-
|
|
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.
|
|
746
|
-
|
|
747
|
-
## Accessibility
|
|
119
|
+
---
|
|
748
120
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
121
|
+
> [!TIP]
|
|
122
|
+
>
|
|
123
|
+
> **🛰️ Offline, serverless group editing**
|
|
124
|
+
>
|
|
125
|
+
> `noteloom/collab` is a **custom block-tree CRDT over WebRTC** — peers connect
|
|
126
|
+
> directly, so a group can co-edit one document over a LAN or an offline hotspot
|
|
127
|
+
> with **nothing in the cloud**. Bring any channel to exchange connection setup:
|
|
128
|
+
> a `BroadcastChannel` (same-machine tabs), a tiny WebSocket relay on the LAN
|
|
129
|
+
> (`tools/lan-relay-server/`), or Firebase / Supabase realtime.
|
|
130
|
+
>
|
|
131
|
+
> ```jsx
|
|
132
|
+
> import { CollabSession } from 'noteloom/collab';
|
|
133
|
+
>
|
|
134
|
+
> const session = new CollabSession({ history: editor.store, signaling });
|
|
135
|
+
> session.connect(remotePeerId, { initiator: true });
|
|
136
|
+
> // every edit now syncs to connected peers; incoming edits merge live
|
|
137
|
+
> ```
|
|
138
|
+
>
|
|
139
|
+
> Pair it with `noteloom/persistence` and the doc survives every peer
|
|
140
|
+
> disconnecting. _Experimental_ —
|
|
141
|
+
> [guide → live collaboration](docs/guide.md#live-collaboration-experimental).
|
|
754
142
|
|
|
755
|
-
|
|
143
|
+
---
|
|
756
144
|
|
|
757
|
-
|
|
145
|
+
## Styling
|
|
758
146
|
|
|
759
|
-
|
|
760
|
-
|
|
147
|
+
No CSS import needed — the default theme injects on mount. Retheme via CSS
|
|
148
|
+
custom properties on `:root`:
|
|
761
149
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
return <NoteloomEditor editor={editor} />;
|
|
150
|
+
```css
|
|
151
|
+
:root {
|
|
152
|
+
--noteloom-accent: #16a34a;
|
|
153
|
+
--noteloom-radius-md: 4px;
|
|
154
|
+
--noteloom-font: 'Inter', sans-serif;
|
|
768
155
|
}
|
|
769
156
|
```
|
|
770
157
|
|
|
771
|
-
|
|
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
|
-
|
|
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
|
-
|
|
785
|
-
- `savePersistedDocument(docId, doc)` / `loadPersistedDocument(docId)` / `deletePersistedDocument(docId)` / `listPersistedDocumentIds()` — the raw IndexedDB operations `usePersistedDocument` is built on.
|
|
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).
|
|
787
|
-
|
|
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.
|
|
789
|
-
|
|
790
|
-
### Offline app shell (PWA)
|
|
791
|
-
|
|
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:
|
|
793
|
-
|
|
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`.
|
|
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 —
|
|
796
|
-
|
|
797
|
-
```js
|
|
798
|
-
import { useServiceWorkerUpdate } from 'noteloom';
|
|
799
|
-
|
|
800
|
-
function UpdateBanner() {
|
|
801
|
-
const { updateAvailable, applyUpdate } = useServiceWorkerUpdate();
|
|
802
|
-
if (!updateAvailable) return null;
|
|
803
|
-
return <button onClick={applyUpdate}>Update available — reload</button>;
|
|
804
|
-
}
|
|
805
|
-
```
|
|
806
|
-
|
|
807
|
-
Works with any service worker registration, however it got there — it only observes, it doesn't register one itself.
|
|
808
|
-
|
|
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.
|
|
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`).
|
|
158
|
+
Or `theme="none"` to style every `.be-*` class yourself. Dark mode follows
|
|
159
|
+
`prefers-color-scheme` (or `data-theme="dark"`). Details: [guide → styling](docs/guide.md#styling--zero-setup-required).
|
|
858
160
|
|
|
859
|
-
|
|
161
|
+
> A future major will stop auto-injecting the theme — add `import 'noteloom/theme'`
|
|
162
|
+
> now to keep it, or `theme="none"` if you already style the editor.
|
|
860
163
|
|
|
861
|
-
|
|
164
|
+
## Document format
|
|
862
165
|
|
|
863
166
|
```jsx
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
function App() {
|
|
868
|
-
const editor = useEditor({ doc: myDoc });
|
|
869
|
-
|
|
870
|
-
useEffect(() => {
|
|
871
|
-
// `signaling` is any object shaped like SignalingChannel (src/sync/signaling.js):
|
|
872
|
-
// { localPeerId, send(toPeerId, message), onMessage(cb) }
|
|
873
|
-
const session = new CollabSession({ history: editor.store, signaling });
|
|
874
|
-
session.connect(remotePeerId, { initiator: true }); // `initiator: true` on exactly one side of each pair
|
|
875
|
-
return () => session.destroy();
|
|
876
|
-
}, []);
|
|
877
|
-
|
|
878
|
-
return <NoteloomEditor editor={editor} />;
|
|
879
|
-
}
|
|
880
|
-
```
|
|
881
|
-
|
|
882
|
-
From then on, every edit made via `editor.store` (typing, inserting/moving/deleting blocks, "Turn into" type conversions) is automatically broadcast to connected peers, and incoming changes merge in live.
|
|
883
|
-
|
|
884
|
-
### Signaling options
|
|
885
|
-
|
|
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:
|
|
887
|
-
|
|
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.
|
|
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:
|
|
890
|
-
|
|
891
|
-
```js
|
|
892
|
-
import { createWebSocketSignaling, CollabSession } from 'noteloom';
|
|
893
|
-
|
|
894
|
-
const signaling = createWebSocketSignaling({
|
|
895
|
-
url: 'ws://192.168.1.5:8080', // a relay running on your LAN -- or any host, if you want internet-wide instead
|
|
896
|
-
roomId: 'my-document-id', // anyone using the same roomId ends up in the same room
|
|
897
|
-
peerId: crypto.randomUUID(),
|
|
898
|
-
});
|
|
899
|
-
const session = new CollabSession({ history: editor.store, signaling });
|
|
900
|
-
|
|
901
|
-
signaling.onPeerDiscovered((remotePeerId) => {
|
|
902
|
-
const initiator = signaling.localPeerId > remotePeerId; // deterministic tie-break
|
|
903
|
-
session.connect(remotePeerId, { initiator });
|
|
904
|
-
});
|
|
905
|
-
```
|
|
906
|
-
|
|
907
|
-
A minimal reference relay server (Node, `ws`-based, ~80 lines, **not** part of the npm package) lives in `tools/lan-relay-server/` — see its README for how to run it and the wire protocol. A full runnable example wiring it up is in `examples/lan-collab/` — run `npm run dev:lan-collab` (after starting the relay), open the URL in two tabs, and it works with zero internet connectivity as long as both tabs can reach the relay.
|
|
908
|
-
|
|
909
|
-
### Presence / awareness (live cursors, who's online)
|
|
910
|
-
|
|
911
|
-
`CollabSession` also carries ephemeral "here's where I am" data alongside the document sync — entirely separate from the document CRDT (never persisted, never merge-conflicted, just "whatever the last message said"):
|
|
912
|
-
|
|
913
|
-
```js
|
|
914
|
-
import { usePresence } from 'noteloom';
|
|
915
|
-
|
|
916
|
-
// broadcast your own position (throttled automatically, ~100ms by default)
|
|
917
|
-
session.setLocalPresence({ runId: caret.runId, offset: caret.offset, name: 'Alex' });
|
|
918
|
-
|
|
919
|
-
// react to everyone else's, reactively
|
|
920
|
-
function PeerCursors({ session }) {
|
|
921
|
-
const presence = usePresence(session); // Map<peerId, data>, re-renders on change
|
|
922
|
-
return [...presence.entries()].map(([peerId, data]) => /* render however you like */);
|
|
923
|
-
}
|
|
924
|
-
```
|
|
925
|
-
|
|
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.
|
|
927
|
-
|
|
928
|
-
**How conflicts resolve:**
|
|
929
|
-
|
|
930
|
-
- Concurrent inserts (even at the same position) — both survive, converging to the same order on every peer.
|
|
931
|
-
- Concurrent delete vs. edit of the same block — the delete wins.
|
|
932
|
-
- Concurrent type-conversion of the same block ("Turn into") — one type wins deterministically (the same one, on every peer), not two duplicate blocks.
|
|
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.
|
|
934
|
-
|
|
935
|
-
### Tombstone garbage collection
|
|
936
|
-
|
|
937
|
-
Deleted blocks/runs are kept as "tombstones" rather than actually removed — necessary so a concurrent operation that references a since-deleted item (an insert anchored to it, say) can still resolve correctly no matter when it arrives. Left alone, this grows without bound over a long enough session. To actually reclaim that memory:
|
|
938
|
-
|
|
939
|
-
```js
|
|
940
|
-
import { useEditor, createPeriodicTombstoneGC } from 'noteloom';
|
|
941
|
-
|
|
942
|
-
const editor = useEditor({ doc: myDoc });
|
|
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)
|
|
948
|
-
|
|
949
|
-
// later, when the store is no longer in use:
|
|
950
|
-
gc.stop();
|
|
167
|
+
const doc = editor.toJSON(); // { version: 1, blocks: [{ id, type, data, children? }] }
|
|
168
|
+
const restored = useEditor({ doc }); // loads it back — internal shape works too
|
|
951
169
|
```
|
|
952
170
|
|
|
953
|
-
|
|
171
|
+
Schema: [`docs/document.schema.json`](docs/document.schema.json). The normalized
|
|
172
|
+
engine graph is available via `editor.toJSON({ format: 'internal' })` when you
|
|
173
|
+
need it. HTML / Markdown / Word / plain-text export and a drop-in `View source`
|
|
174
|
+
button: [guide → exporting](docs/guide.md#exporting-the-document-json--html--markdown--word--pdf--plain-text).
|
|
954
175
|
|
|
955
|
-
|
|
176
|
+
## Features (all in the [full guide](docs/guide.md))
|
|
956
177
|
|
|
957
|
-
|
|
178
|
+
| Feature | |
|
|
179
|
+
| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
180
|
+
| [Custom dropdown / mention field types](docs/guide.md#custom-dropdown--mention-field-types-static-or-dynamicapi-backed) | static or API-backed, no component to write |
|
|
181
|
+
| [Templates](docs/guide.md#templates) | reusable documents + insertable block snippets |
|
|
182
|
+
| [Comments](docs/guide.md#comments) | thread on a range; built-in UI or bring your own |
|
|
183
|
+
| [Version history](docs/guide.md#version-history) | Google Docs-style automatic snapshots + diff |
|
|
184
|
+
| [Offline persistence + PWA](docs/guide.md#offline-persistence) | IndexedDB auto-save, offline app shell |
|
|
185
|
+
| [Live collaboration](docs/guide.md#live-collaboration-experimental) | multi-peer over WebRTC, bring-your-own signaling _(experimental)_ |
|
|
186
|
+
| [Voice typing](docs/guide.md#voice-typing) | dictation + spoken commands via the browser's Speech API |
|
|
187
|
+
| [Mobile / touch](docs/guide.md#mobile--touch-support) | bottom action bar, tap-friendly sheets |
|
|
188
|
+
| [Find & replace](docs/guide.md#find--replace) | `Ctrl/Cmd+F`, match case / whole word |
|
|
189
|
+
| [File & image uploads](docs/guide.md#file--image-uploads) | `data:` URL by default, or wire `uploadFile` to S3/etc. |
|
|
190
|
+
| [RTL / multi-language](docs/guide.md#right-to-left--multi-language-text) | automatic per-block direction |
|
|
191
|
+
| [Accessibility](docs/guide.md#accessibility) | keyboard-operable menus, live-region announcements |
|
|
192
|
+
| [The granular API](docs/guide.md#advanced-the-granular-api) | build the editor surface by hand |
|
|
958
193
|
|
|
959
|
-
|
|
194
|
+
## Requirements
|
|
960
195
|
|
|
961
|
-
|
|
962
|
-
|
|
196
|
+
React 18.2+ or 19, and a modern browser (`contentEditable` + `beforeinput`;
|
|
197
|
+
IndexedDB for `noteloom/persistence`; WebRTC for `noteloom/collab`;
|
|
198
|
+
`SpeechRecognition` for `noteloom/voice`). SSR-safe — renders nothing on the
|
|
199
|
+
server and hydrates on mount.
|
|
963
200
|
|
|
964
|
-
|
|
201
|
+
## Status
|
|
965
202
|
|
|
966
|
-
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
- Only structural block changes and field edits (props, type, run text) are collaboration-aware. A few coarse "resync" operations (`setBlockContentIds`, `replaceRunSpan`, `setBlockRuns` — used for DOM-reconciliation escape hatches like paste-into-contentEditable or IME composition) remain local-only for now.
|
|
971
|
-
- Large single messages (e.g. an embedded video/file's `data:` URL, or a full-document `syncResponse` for a big document) are transparently fragmented, flow-controlled against the data channel's own backpressure, and reassembled under the hood — you don't need to do anything for this, but very large embeds mean more individual send calls and somewhat higher latency to fully arrive.
|
|
203
|
+
Pre-1.0 (`0.4.x`). Changes are **additive only** until a deliberate major —
|
|
204
|
+
existing code keeps working, deprecations get a full minor-version notice, and
|
|
205
|
+
the frozen list in [`docs/stability.md`](docs/stability.md) says exactly what
|
|
206
|
+
semver covers. `noteloom/collab` is **experimental**.
|
|
972
207
|
|
|
973
|
-
|
|
208
|
+
## Contributing
|
|
974
209
|
|
|
975
|
-
|
|
210
|
+
Issues and PRs welcome — it's a small, opinionated project.
|
|
976
211
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
import {
|
|
983
|
-
EditorStore,
|
|
984
|
-
History,
|
|
985
|
-
EditorProvider,
|
|
986
|
-
BlockChildren,
|
|
987
|
-
createBlockRegistry,
|
|
988
|
-
registerBuiltInBlocks,
|
|
989
|
-
createInlineRegistry,
|
|
990
|
-
registerBuiltInInlineTypes,
|
|
991
|
-
useClipboardHandlers,
|
|
992
|
-
useSlashMenuTrigger,
|
|
993
|
-
useEditorKeyboardShortcuts,
|
|
994
|
-
SlashMenu,
|
|
995
|
-
} from 'noteloom';
|
|
996
|
-
import { useMemo, useRef } from 'react';
|
|
997
|
-
|
|
998
|
-
function Editor() {
|
|
999
|
-
const containerRef = useRef(null);
|
|
1000
|
-
const { store, registry, inlineRegistry } = useMemo(() => {
|
|
1001
|
-
const registry = createBlockRegistry();
|
|
1002
|
-
registerBuiltInBlocks(registry);
|
|
1003
|
-
const inlineRegistry = createInlineRegistry();
|
|
1004
|
-
registerBuiltInInlineTypes(inlineRegistry);
|
|
1005
|
-
const store = new History(
|
|
1006
|
-
new EditorStore({
|
|
1007
|
-
rootId: 'root',
|
|
1008
|
-
blocks: [
|
|
1009
|
-
{ id: 'root', type: 'page', parentId: null, contentIds: ['p1'], props: {} },
|
|
1010
|
-
{ id: 'p1', type: 'paragraph', parentId: 'root', contentIds: ['r1'], props: {} },
|
|
1011
|
-
],
|
|
1012
|
-
runs: [
|
|
1013
|
-
{ id: 'r1', type: 'text', value: 'Hello — try typing "/" for commands.', marks: {} },
|
|
1014
|
-
],
|
|
1015
|
-
}),
|
|
1016
|
-
);
|
|
1017
|
-
return { store, registry, inlineRegistry };
|
|
1018
|
-
}, []);
|
|
1019
|
-
|
|
1020
|
-
const { onCopy, onCut, onPaste } = useClipboardHandlers();
|
|
1021
|
-
const slashMenu = useSlashMenuTrigger(containerRef);
|
|
1022
|
-
useEditorKeyboardShortcuts(containerRef);
|
|
1023
|
-
|
|
1024
|
-
return (
|
|
1025
|
-
<EditorProvider
|
|
1026
|
-
store={store}
|
|
1027
|
-
registry={registry}
|
|
1028
|
-
inlineRegistry={inlineRegistry}
|
|
1029
|
-
history={store}
|
|
1030
|
-
>
|
|
1031
|
-
<div ref={containerRef} onCopy={onCopy} onCut={onCut} onPaste={onPaste}>
|
|
1032
|
-
<BlockChildren parentId="root" />
|
|
1033
|
-
<SlashMenu
|
|
1034
|
-
isOpen={slashMenu.isOpen}
|
|
1035
|
-
rect={slashMenu.rect}
|
|
1036
|
-
commands={slashMenu.commands}
|
|
1037
|
-
runId={slashMenu.runId}
|
|
1038
|
-
onSelect={slashMenu.selectCommand}
|
|
1039
|
-
onClose={slashMenu.close}
|
|
1040
|
-
/>
|
|
1041
|
-
</div>
|
|
1042
|
-
</EditorProvider>
|
|
1043
|
-
);
|
|
1044
|
-
}
|
|
1045
|
-
```
|
|
1046
|
-
|
|
1047
|
-
See `examples/basic` for a complete working app built this way (run `npm run dev`) — it wires up everything the Basic guide above covers individually (mobile chrome, voice typing, export, field-type management, ...) from these same granular pieces.
|
|
1048
|
-
|
|
1049
|
-
## Registering a brand-new block/inline type, from scratch
|
|
1050
|
-
|
|
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:
|
|
1052
|
-
|
|
1053
|
-
```js
|
|
1054
|
-
registry.register('myBlock', {
|
|
1055
|
-
component: MyBlockComponent, // receives only { id }
|
|
1056
|
-
isLeaf: true, // true if contentIds holds run ids, false if it holds child block ids
|
|
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
|
-
},
|
|
1073
|
-
});
|
|
212
|
+
```bash
|
|
213
|
+
git clone https://github.com/vishwakarmanikhil/noteloom.git
|
|
214
|
+
cd noteloom && npm install
|
|
215
|
+
npm test # vitest (no build step needed)
|
|
216
|
+
npm run dev:quickstart # or dev:custom-block / dev:collab / dev:lan-collab / …
|
|
1074
217
|
```
|
|
1075
218
|
|
|
1076
|
-
|
|
219
|
+
Before a PR:
|
|
1077
220
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
npm run
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
npm run build # library build (dist/, ESM + CJS + index.d.ts)
|
|
1089
|
-
```
|
|
221
|
+
- **`npm test`** and **`npm run lint`** (CI runs the suite on Node 18/20/22;
|
|
222
|
+
errors block, warnings don't).
|
|
223
|
+
- **Add/update tests** — `test/` mirrors `src/`. Touching rendering or export
|
|
224
|
+
output? Refresh the golden snapshots (`npx playwright test golden-document
|
|
225
|
+
--update-snapshots`) in the same commit.
|
|
226
|
+
- **Public API change?** Update the entry file, its `.d.ts`, and the frozen list
|
|
227
|
+
in `test/publicApi.test.js` — the diff is the review signal.
|
|
228
|
+
- **`npm run changeset`** for anything user-facing — it becomes the release note.
|
|
229
|
+
- **Keep the zero-runtime-dependency rule** — nothing in `src/` may add a runtime
|
|
230
|
+
`dependency`.
|
|
1090
231
|
|
|
1091
|
-
|
|
232
|
+
Full detail — code layout, the framework-free-core boundary, sync-layer testing
|
|
233
|
+
advice — in [`CONTRIBUTING.md`](CONTRIBUTING.md). Example apps and what each
|
|
234
|
+
teaches: [`examples/README.md`](examples/README.md).
|
|
1092
235
|
|
|
1093
|
-
##
|
|
236
|
+
## License
|
|
1094
237
|
|
|
1095
|
-
|
|
1096
|
-
- `<NoteloomEditor>` renders `role="document"`/`aria-label` on its own surface element; if you build the surface yourself via the granular API (no library-rendered root element there — see `examples/basic/src/App.jsx`'s `EditorSurface`), add those attributes yourself the same way.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
238
|
+
MIT
|