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 +21 -21
- package/README.md +283 -219
- package/dist/noteloom.cjs +1887 -8
- package/dist/noteloom.cjs.map +1 -1
- package/dist/noteloom.es.js +6077 -3824
- package/dist/noteloom.es.js.map +1 -1
- package/dist/style.css +1879 -0
- package/package.json +67 -63
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
### Styling — zero 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.
|