noteloom 0.1.0 → 0.1.2

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Nikhil Vishwakarma
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nikhil Vishwakarma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,219 +1,283 @@
1
- # noteloom
2
-
3
- A React-first, block-based rich text editor with **zero runtime dependencies** — the only things it expects from your app are `react` and `react-dom`. Everything else (undo/redo, clipboard, slash commands, tables, inline widgets) is built from scratch on top of a small normalized document store.
4
-
5
- ## Why this exists
6
-
7
- 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:
8
-
9
- - **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.
10
- - **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).
11
-
12
- ## Install
13
-
14
- ```bash
15
- npm install noteloom react react-dom
16
- ```
17
-
18
- ## Quick start
19
-
20
- ```jsx
21
- import {
22
- EditorStore,
23
- History,
24
- EditorProvider,
25
- BlockChildren,
26
- createBlockRegistry,
27
- registerBuiltInBlocks,
28
- createInlineRegistry,
29
- registerBuiltInInlineTypes,
30
- useClipboardHandlers,
31
- useSlashMenuTrigger,
32
- useEditorKeyboardShortcuts,
33
- SlashMenu,
34
- } from 'noteloom';
35
- import { useMemo, useRef } from 'react';
36
-
37
- function Editor() {
38
- const containerRef = useRef(null);
39
- const { store, registry, inlineRegistry } = useMemo(() => {
40
- const registry = createBlockRegistry();
41
- registerBuiltInBlocks(registry);
42
- const inlineRegistry = createInlineRegistry();
43
- registerBuiltInInlineTypes(inlineRegistry);
44
- const store = new History(
45
- new EditorStore({
46
- rootId: 'root',
47
- blocks: [
48
- { id: 'root', type: 'page', parentId: null, contentIds: ['p1'], props: {} },
49
- { id: 'p1', type: 'paragraph', parentId: 'root', contentIds: ['r1'], props: {} },
50
- ],
51
- runs: [{ id: 'r1', type: 'text', value: 'Hello — try typing "/" for commands.', marks: {} }],
52
- }),
53
- );
54
- return { store, registry, inlineRegistry };
55
- }, []);
56
-
57
- const { onCopy, onCut, onPaste } = useClipboardHandlers();
58
- const slashMenu = useSlashMenuTrigger(containerRef);
59
- useEditorKeyboardShortcuts(containerRef);
60
-
61
- return (
62
- <EditorProvider store={store} registry={registry} inlineRegistry={inlineRegistry} history={store}>
63
- <div ref={containerRef} onCopy={onCopy} onCut={onCut} onPaste={onPaste}>
64
- <BlockChildren parentId="root" />
65
- <SlashMenu
66
- isOpen={slashMenu.isOpen}
67
- rect={slashMenu.rect}
68
- commands={slashMenu.commands}
69
- runId={slashMenu.runId}
70
- onSelect={slashMenu.selectCommand}
71
- onClose={slashMenu.close}
72
- />
73
- </div>
74
- </EditorProvider>
75
- );
76
- }
77
- ```
78
-
79
- See `examples/basic` for a complete working app (run `npm run dev`).
80
-
81
- Note: this package ships **no default CSS** style the block class names (`.be-paragraph`, `.be-heading`, `.be-list-item`, `.be-table`, `.be-inline-select`, etc.) yourself, or copy `examples/basic/src/style.css` as a starting point.
82
-
83
- ## Built-in block types
84
-
85
- `paragraph`, `heading` (h1–h3), `listItem` (bulleted, numbered, and to-do with Notion-style Tab/Shift+Tab nesting and Enter conventions), `table` (with row/column insert/delete), `layout` (multi-column), `divider`.
86
-
87
- ## Picking only the blocks you want
88
-
89
- `registerBuiltInBlocks`/`registerBuiltInInlineTypes` register everything at
90
- once — the fastest way to a fully-featured editor. If you'd rather ship
91
- only what you actually use (TipTap's `extensions: [...]` idea), every
92
- built-in block/inline type is also exported individually, and
93
- `registerBlocks`/`registerInlineTypes` register just the ones you name:
94
-
95
- ```js
96
- import {
97
- createBlockRegistry,
98
- registerBlocks,
99
- paragraphBlockType,
100
- headingBlockType,
101
- TABLE_BLOCKS, // table needs its row/cell types alongside it — spread the whole group
102
- } from 'noteloom';
103
-
104
- const registry = createBlockRegistry();
105
- registerBlocks(registry, {
106
- paragraph: paragraphBlockType,
107
- heading: headingBlockType,
108
- ...TABLE_BLOCKS,
109
- });
110
- // registry now only knows about paragraph/heading/table — nothing else
111
- // (callout, button, embed, ...) shows up in the slash menu or renders at all.
112
- ```
113
-
114
- `registerBuiltInBlocks(registry)` is itself just `registerBlocks(registry, { paragraph: paragraphBlockType, ... })` with every type included — so mixing "give me everything" and "just these few" across different parts of your app is never an either/or choice. `layout` has the same "needs its own group" shape as `table` — see `LAYOUT_BLOCKS`. `TABLE_SELECT_INLINE_TYPES` (inline side) is only needed if you use a table's "select" column type.
115
-
116
- ## Built-in inline types
117
-
118
- 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">`).
119
-
120
- There's no separate hardcoded `mention` type — an `@name` chip is just an ordinary use of `createSelectFieldType` (see the next section), with `triggers: ['slash', 'at']` so it also shows up under a second, dedicated "@" trigger (`useAtMenuTrigger`), alongside "/". See the example app's "Assignee" field type for a full worked example.
121
-
122
- ## Custom select field types (static, or dynamic/API-backed)
123
-
124
- `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:
125
-
126
- ```js
127
- import { createInlineRegistry, createSelectFieldType } from 'noteloom';
128
-
129
- const inlineRegistry = createInlineRegistry();
130
-
131
- inlineRegistry.register(
132
- 'status',
133
- createSelectFieldType({
134
- type: 'status', // must match the key you register it under
135
- label: 'Status', // shown in the "/" menu and as the search box's aria-label
136
- placeholder: 'Set status…',
137
- variant: 'tag', // 'tag' = Notion-style colored pill; 'default' = plain bordered dropdown
138
- options: [
139
- { value: 'todo', label: 'To do', color: { bg: '#e9e9e7', text: '#37352f' } },
140
- { value: 'doing', label: 'In progress', color: { bg: '#fdecc8', text: '#a06400' } },
141
- { value: 'done', label: 'Done', color: { bg: '#dbeddb', text: '#2f7a2f' } },
142
- ],
143
- }),
144
- );
145
- ```
146
-
147
- `options` can also be a **function** instead of a plain array — `(query) => Option[] | Promise<Option[]>` — for a real database/API-backed search (React Select's `loadOptions`, essentially):
148
-
149
- ```js
150
- inlineRegistry.register(
151
- 'assignee',
152
- createSelectFieldType({
153
- type: 'assignee',
154
- label: 'Assignee',
155
- placeholder: 'Assign to…',
156
- variant: 'tag',
157
- triggers: ['slash', 'at'], // reachable via "/assignee" AND by typing "@" directly
158
- options: async (query) => {
159
- const res = await fetch(`/api/users?search=${encodeURIComponent(query)}`);
160
- const users = await res.json();
161
- return users.map((u) => ({ value: u.id, label: u.name }));
162
- },
163
- }),
164
- );
165
- ```
166
-
167
- A few things worth knowing about the dynamic path:
168
-
169
- - 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.
170
- - 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.
171
- - `triggers` (default `['slash']`) decides whether the type shows up under `/`, `@` (via `useAtMenuTrigger`), or both — see the "Assignee" example above. A field that doesn't read naturally after "@" (e.g. "Priority") should usually stay slash-only.
172
-
173
- ### Letting end users create their own field types, in-editor
174
-
175
- 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:
176
-
177
- ```jsx
178
- import { EditorProvider, FieldTypeEditorModal, useFieldTypeEditor } from 'noteloom';
179
-
180
- function NewFieldTypeButton() {
181
- const { openCreate } = useFieldTypeEditor();
182
- return <button onClick={openCreate}>+ New field type</button>;
183
- }
184
-
185
- // Anywhere under <EditorProvider>:
186
- <NewFieldTypeButton />
187
- <FieldTypeEditorModal />
188
- ```
189
-
190
- 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.
191
-
192
- ## Registering your own block/inline types
193
-
194
- ```js
195
- registry.register('myBlock', {
196
- component: MyBlockComponent, // receives only { id }
197
- isLeaf: true, // true if contentIds holds run ids, false if it holds child block ids
198
- toHTML(block, ctx) { /* ... */ },
199
- fromHTML(domNode, ctx) { /* ... or return null if this node isn't yours */ },
200
- toPlainText(block, ctx) { /* ... */ },
201
- slashCommand: { label: 'My Block', keywords: ['my'], run(store, ctx) { /* ... */ } },
202
- });
203
- ```
204
-
205
- ## Development
206
-
207
- ```bash
208
- npm install
209
- npm run dev # examples/basic dev server
210
- npm test # vitest (jsdom + @testing-library/react)
211
- npm run build # library build (dist/, ESM + CJS)
212
- ```
213
-
214
- ## Known limitations
215
-
216
- - No accessibility affordance exists for grouping sibling list items under a shared `role="list"` container (each list item is an independent block, not wrapped in one) — adding `role="listitem"` without that ancestor would be worse than no role at all, so it's deliberately left out pending a bigger structural change.
217
- - 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.
218
- - `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.
219
- - 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), please file an issue with the exact browser/OS and steps.
1
+ # noteloom
2
+
3
+ A React-first, block-based rich text editor with **zero runtime dependencies** — the only things it expects from your app are `react` and `react-dom`. Everything else (undo/redo, clipboard, slash commands, tables, inline widgets) is built from scratch on top of a small normalized document store.
4
+
5
+ ## Why this exists
6
+
7
+ 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:
8
+
9
+ - **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.
10
+ - **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).
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install noteloom react react-dom
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ ```jsx
21
+ import {
22
+ EditorStore,
23
+ History,
24
+ EditorProvider,
25
+ BlockChildren,
26
+ createBlockRegistry,
27
+ registerBuiltInBlocks,
28
+ createInlineRegistry,
29
+ registerBuiltInInlineTypes,
30
+ useClipboardHandlers,
31
+ useSlashMenuTrigger,
32
+ useEditorKeyboardShortcuts,
33
+ SlashMenu,
34
+ } from 'noteloom';
35
+ import { useMemo, useRef } from 'react';
36
+
37
+ function Editor() {
38
+ const containerRef = useRef(null);
39
+ const { store, registry, inlineRegistry } = useMemo(() => {
40
+ const registry = createBlockRegistry();
41
+ registerBuiltInBlocks(registry);
42
+ const inlineRegistry = createInlineRegistry();
43
+ registerBuiltInInlineTypes(inlineRegistry);
44
+ const store = new History(
45
+ new EditorStore({
46
+ rootId: 'root',
47
+ blocks: [
48
+ { id: 'root', type: 'page', parentId: null, contentIds: ['p1'], props: {} },
49
+ { id: 'p1', type: 'paragraph', parentId: 'root', contentIds: ['r1'], props: {} },
50
+ ],
51
+ runs: [{ id: 'r1', type: 'text', value: 'Hello — try typing "/" for commands.', marks: {} }],
52
+ }),
53
+ );
54
+ return { store, registry, inlineRegistry };
55
+ }, []);
56
+
57
+ const { onCopy, onCut, onPaste } = useClipboardHandlers();
58
+ const slashMenu = useSlashMenuTrigger(containerRef);
59
+ useEditorKeyboardShortcuts(containerRef);
60
+
61
+ return (
62
+ <EditorProvider store={store} registry={registry} inlineRegistry={inlineRegistry} history={store}>
63
+ <div ref={containerRef} onCopy={onCopy} onCut={onCut} onPaste={onPaste}>
64
+ <BlockChildren parentId="root" />
65
+ <SlashMenu
66
+ isOpen={slashMenu.isOpen}
67
+ rect={slashMenu.rect}
68
+ commands={slashMenu.commands}
69
+ runId={slashMenu.runId}
70
+ onSelect={slashMenu.selectCommand}
71
+ onClose={slashMenu.close}
72
+ />
73
+ </div>
74
+ </EditorProvider>
75
+ );
76
+ }
77
+ ```
78
+
79
+ See `examples/basic` for a complete working app (run `npm run dev`).
80
+
81
+ ### Stylingzero setup required
82
+
83
+ You don't need to import any CSS. The moment `<EditorProvider>` mounts, it injects a single `<style>` tag with a minimal, clean default theme — no `import 'noteloom/style.css'` line, no build-tool CSS configuration, nothing to wire up. It's idempotent (mounting more than one editor on a page only injects it once) and client-only (a no-op under SSR; hydrate as normal and it injects on mount).
84
+
85
+ **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):
86
+
87
+ ```css
88
+ :root {
89
+ --noteloom-accent: #16a34a; /* swap the indigo accent for green */
90
+ --noteloom-radius-md: 4px; /* sharper corners */
91
+ --noteloom-font: 'Inter', sans-serif;
92
+ }
93
+ ```
94
+
95
+ 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`.
96
+
97
+ **Scope overrides to one editor instance**, or add your own class for full custom CSS, via `<EditorProvider>`'s `className`/`style` props — passing either wraps `children` in one `<div className="be-root ...">`:
98
+
99
+ ```jsx
100
+ <EditorProvider store={store} registry={registry} className="my-editor" style={{ '--noteloom-accent': '#16a34a' }}>
101
+ ...
102
+ </EditorProvider>
103
+ ```
104
+
105
+ No wrapper `<div>` is added unless you pass one of these props, so existing usage is unaffected either way.
106
+
107
+ **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):
108
+
109
+ ```jsx
110
+ <EditorProvider store={store} registry={registry} theme="none">
111
+ ```
112
+
113
+ `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.
114
+
115
+ **Customize individual blocks**, not just the root, via `getBlockClassName`:
116
+
117
+ ```jsx
118
+ <EditorProvider
119
+ store={store}
120
+ registry={registry}
121
+ getBlockClassName={(block) => (block.type === 'callout' ? 'my-callout' : undefined)}
122
+ >
123
+ ```
124
+
125
+ 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.
126
+
127
+ ## Exporting the document (JSON / HTML / plain text)
128
+
129
+ ```js
130
+ import { exportDocumentJSON, exportDocumentHTML, exportDocumentText } from 'noteloom';
131
+
132
+ exportDocumentJSON(store); // { rootId, blocks, runs } — feed straight back into `new EditorStore(...)`
133
+ exportDocumentHTML(store, registry, inlineRegistry);
134
+ exportDocumentText(store, registry, inlineRegistry);
135
+ ```
136
+
137
+ Or mount the ready-made button + modal instead of wiring your own UI:
138
+
139
+ ```jsx
140
+ import { DocumentExportButton } from 'noteloom';
141
+
142
+ <DocumentExportButton label="View source" />
143
+ ```
144
+
145
+ It opens a modal with JSON/HTML/Text tabs (reading live from the store every time it opens) and a Copy button — useful for debugging, or as a starting point for a real "export" feature.
146
+
147
+ ## Built-in block types
148
+
149
+ `paragraph`, `heading` (h1–h3), `listItem` (bulleted, numbered, and to-do — with Notion-style Tab/Shift+Tab nesting and Enter conventions), `table` (with row/column insert/delete), `layout` (multi-column), `divider`.
150
+
151
+ ## Picking only the blocks you want
152
+
153
+ `registerBuiltInBlocks`/`registerBuiltInInlineTypes` register everything at
154
+ once — the fastest way to a fully-featured editor. If you'd rather ship
155
+ only what you actually use (TipTap's `extensions: [...]` idea), every
156
+ built-in block/inline type is also exported individually, and
157
+ `registerBlocks`/`registerInlineTypes` register just the ones you name:
158
+
159
+ ```js
160
+ import {
161
+ createBlockRegistry,
162
+ registerBlocks,
163
+ paragraphBlockType,
164
+ headingBlockType,
165
+ TABLE_BLOCKS, // table needs its row/cell types alongside it — spread the whole group
166
+ } from 'noteloom';
167
+
168
+ const registry = createBlockRegistry();
169
+ registerBlocks(registry, {
170
+ paragraph: paragraphBlockType,
171
+ heading: headingBlockType,
172
+ ...TABLE_BLOCKS,
173
+ });
174
+ // registry now only knows about paragraph/heading/table — nothing else
175
+ // (callout, button, embed, ...) shows up in the slash menu or renders at all.
176
+ ```
177
+
178
+ `registerBuiltInBlocks(registry)` is itself just `registerBlocks(registry, { paragraph: paragraphBlockType, ... })` with every type included — so mixing "give me everything" and "just these few" across different parts of your app is never an either/or choice. `layout` has the same "needs its own group" shape as `table` — see `LAYOUT_BLOCKS`. `TABLE_SELECT_INLINE_TYPES` (inline side) is only needed if you use a table's "select" column type.
179
+
180
+ ## Built-in inline types
181
+
182
+ 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">`).
183
+
184
+ There's no separate hardcoded `mention` type — an `@name` chip is just an ordinary use of `createSelectFieldType` (see the next section), with `triggers: ['slash', 'at']` so it also shows up under a second, dedicated "@" trigger (`useAtMenuTrigger`), alongside "/". See the example app's "Assignee" field type for a full worked example.
185
+
186
+ ## Custom select field types (static, or dynamic/API-backed)
187
+
188
+ `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:
189
+
190
+ ```js
191
+ import { createInlineRegistry, createSelectFieldType } from 'noteloom';
192
+
193
+ const inlineRegistry = createInlineRegistry();
194
+
195
+ inlineRegistry.register(
196
+ 'status',
197
+ createSelectFieldType({
198
+ type: 'status', // must match the key you register it under
199
+ label: 'Status', // shown in the "/" menu and as the search box's aria-label
200
+ placeholder: 'Set status…',
201
+ variant: 'tag', // 'tag' = Notion-style colored pill; 'default' = plain bordered dropdown
202
+ options: [
203
+ { value: 'todo', label: 'To do', color: { bg: '#e9e9e7', text: '#37352f' } },
204
+ { value: 'doing', label: 'In progress', color: { bg: '#fdecc8', text: '#a06400' } },
205
+ { value: 'done', label: 'Done', color: { bg: '#dbeddb', text: '#2f7a2f' } },
206
+ ],
207
+ }),
208
+ );
209
+ ```
210
+
211
+ `options` can also be a **function** instead of a plain array — `(query) => Option[] | Promise<Option[]>` — for a real database/API-backed search (React Select's `loadOptions`, essentially):
212
+
213
+ ```js
214
+ inlineRegistry.register(
215
+ 'assignee',
216
+ createSelectFieldType({
217
+ type: 'assignee',
218
+ label: 'Assignee',
219
+ placeholder: 'Assign to…',
220
+ variant: 'tag',
221
+ triggers: ['slash', 'at'], // reachable via "/assignee" AND by typing "@" directly
222
+ options: async (query) => {
223
+ const res = await fetch(`/api/users?search=${encodeURIComponent(query)}`);
224
+ const users = await res.json();
225
+ return users.map((u) => ({ value: u.id, label: u.name }));
226
+ },
227
+ }),
228
+ );
229
+ ```
230
+
231
+ A few things worth knowing about the dynamic path:
232
+
233
+ - 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.
234
+ - 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.
235
+ - `triggers` (default `['slash']`) decides whether the type shows up under `/`, `@` (via `useAtMenuTrigger`), or both — see the "Assignee" example above. A field that doesn't read naturally after "@" (e.g. "Priority") should usually stay slash-only.
236
+
237
+ ### Letting end users create their own field types, in-editor
238
+
239
+ 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:
240
+
241
+ ```jsx
242
+ import { EditorProvider, FieldTypeEditorModal, useFieldTypeEditor } from 'noteloom';
243
+
244
+ function NewFieldTypeButton() {
245
+ const { openCreate } = useFieldTypeEditor();
246
+ return <button onClick={openCreate}>+ New field type</button>;
247
+ }
248
+
249
+ // Anywhere under <EditorProvider>:
250
+ <NewFieldTypeButton />
251
+ <FieldTypeEditorModal />
252
+ ```
253
+
254
+ 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.
255
+
256
+ ## Registering your own block/inline types
257
+
258
+ ```js
259
+ registry.register('myBlock', {
260
+ component: MyBlockComponent, // receives only { id }
261
+ isLeaf: true, // true if contentIds holds run ids, false if it holds child block ids
262
+ toHTML(block, ctx) { /* ... */ },
263
+ fromHTML(domNode, ctx) { /* ... or return null if this node isn't yours */ },
264
+ toPlainText(block, ctx) { /* ... */ },
265
+ slashCommand: { label: 'My Block', keywords: ['my'], run(store, ctx) { /* ... */ } },
266
+ });
267
+ ```
268
+
269
+ ## Development
270
+
271
+ ```bash
272
+ npm install
273
+ npm run dev # examples/basic dev server
274
+ npm test # vitest (jsdom + @testing-library/react)
275
+ npm run build # library build (dist/, ESM + CJS)
276
+ ```
277
+
278
+ ## Known limitations
279
+
280
+ - No accessibility affordance exists for grouping sibling list items under a shared `role="list"` container (each list item is an independent block, not wrapped in one) — adding `role="listitem"` without that ancestor would be worse than no role at all, so it's deliberately left out pending a bigger structural change.
281
+ - 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.
282
+ - `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.
283
+ - 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), please file an issue with the exact browser/OS and steps.