noteloom 0.1.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/LICENSE ADDED
@@ -0,0 +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.
package/README.md ADDED
@@ -0,0 +1,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
+ 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.