noteloom 0.2.0 → 0.3.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 CHANGED
@@ -243,7 +243,7 @@ User-created types are persisted in the document's own `fieldTypes` collection (
243
243
  ```js
244
244
  import { exportDocumentJSON, exportDocumentHTML, exportDocumentText } from 'noteloom';
245
245
 
246
- exportDocumentJSON(store); // { rootId, blocks, runs } feed straight back into `useEditor({ doc })`
246
+ exportDocumentJSON(store); // a JSON *string* — JSON.parse() it to get { version, rootId, blocks, runs }, usable as useEditor({ doc })
247
247
  exportDocumentHTML(store, registry, inlineRegistry);
248
248
  exportDocumentText(store, registry, inlineRegistry);
249
249
  ```
@@ -296,6 +296,186 @@ One existing, by-design limitation carried over from clipboard paste: an atomic
296
296
 
297
297
  This is purely an additive, alternate *interchange* format — the internal engine format above is unaffected either way, and this is not a replacement for it.
298
298
 
299
+ ## Templates
300
+
301
+ 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.
302
+
303
+ **Block templates — reusable snippets, insertable via "/":**
304
+
305
+ ```js
306
+ import { EditorStore, captureBlockTemplate, registerBlockTemplates, registerBuiltInBlocks } from 'noteloom';
307
+
308
+ // Build once (a throwaway store is fine — only its content is captured):
309
+ const draftStore = new EditorStore({
310
+ rootId: 'root',
311
+ blocks: [
312
+ { id: 'root', type: 'page', parentId: null, contentIds: ['h1', 'li1'], props: {} },
313
+ { id: 'h1', type: 'heading', parentId: 'root', contentIds: ['r1'], props: { level: 2 } },
314
+ { id: 'li1', type: 'listItem', parentId: 'root', contentIds: [], props: { ordered: true, titleRunIds: ['r2'] } },
315
+ ],
316
+ runs: [
317
+ { id: 'r1', type: 'text', value: 'Meeting agenda', marks: {} },
318
+ { id: 'r2', type: 'text', value: 'Review previous action items', marks: {} },
319
+ ],
320
+ });
321
+ const agendaSnippet = captureBlockTemplate(draftStore, ['h1', 'li1']);
322
+
323
+ const editor = useEditor({
324
+ registerBlocks: (registry) => {
325
+ registerBuiltInBlocks(registry);
326
+ registerBlockTemplates(registry, [{ id: 'agenda', label: 'Meeting agenda', keywords: ['agenda'], roots: agendaSnippet.roots }]);
327
+ },
328
+ });
329
+ ```
330
+
331
+ Typing "/agenda" now shows "Meeting agenda" in the slash menu, same as any built-in block — no changes needed to `SlashMenu`/`useSlashMenuTrigger`, since `registerBlockTemplates` registers under the hood exactly the way a real block type does (just one that's never actually rendered — only its *captured content*, which already has real block types, gets inserted). `insertBlockTemplate(store, template, { parentId, index })` does the same insertion directly, if you want a button instead of/alongside "/".
332
+
333
+ **Document templates — starter documents:** no new primitives needed — a document template *is* a `DocumentJSON`, so `useEditor({ doc: someTemplate.doc })` already covers "start a new editor from it." To apply one to an **already-mounted** editor instead, use `applyDocumentTemplate(store, doc)`.
334
+
335
+ **Saving/browsing a library of templates** (either kind), persisted so it survives reload:
336
+
337
+ ```jsx
338
+ import { useEditor, NoteloomEditor, useTemplates, TemplatePicker, saveTemplate, exportDocumentJSON } from 'noteloom';
339
+
340
+ function NewDocumentScreen({ onPick }) {
341
+ const { templates, isLoaded } = useTemplates({ scope: 'document' }); // or 'block', or omit for both
342
+ if (!isLoaded) return <p>Loading…</p>;
343
+ return <TemplatePicker templates={templates} onSelect={(template) => onPick(template.doc)} />;
344
+ }
345
+
346
+ // Saving the current document as a reusable template:
347
+ async function saveCurrentAsTemplate(store, name) {
348
+ await saveTemplate({
349
+ id: crypto.randomUUID(),
350
+ scope: 'document',
351
+ name,
352
+ doc: JSON.parse(exportDocumentJSON(store)), // exportDocumentJSON returns a JSON *string* — parse it first
353
+ });
354
+ }
355
+ ```
356
+
357
+ `TemplatePicker` is deliberately just a plain list (name + description + a "Use" button) — wrap it in the exported `Modal` component yourself, or render it inline, whichever fits; what `onSelect` actually does (apply it, insert it, just read `.doc`) is up to you, since that differs by scope. `saveTemplate`/`loadTemplate`/`deleteTemplate`/`listTemplates` are the raw storage operations `useTemplates` is built on, for anywhere the hook's all-in-one behavior doesn't fit.
358
+
359
+ **Importing a template from a file** — since a stored template is already plain JSON, this needs no new format or function, just `saveTemplate(JSON.parse(fileText))`:
360
+
361
+ ```jsx
362
+ async function handleImport(event) {
363
+ const template = JSON.parse(await event.target.files[0].text());
364
+ await saveTemplate(template);
365
+ }
366
+ ```
367
+
368
+ (Exporting one for sharing is the mirror image — `JSON.stringify(template)`, downloaded as a `.json` file — ordinary front-end code, not something this package needs to provide.)
369
+
370
+ ## Comments
371
+
372
+ 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:
373
+
374
+ ### The built-in UI (zero comment-authoring code of your own)
375
+
376
+ Pass `commentAuthorId` — the current user's id — to `<NoteloomEditor>` and the whole experience just works, Notion/Google Docs-style:
377
+
378
+ ```jsx
379
+ <NoteloomEditor editor={editor} commentAuthorId={currentUser.id} showCommentsPanel />
380
+ ```
381
+
382
+ - 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.
383
+ - 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.
384
+ - `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.
385
+
386
+ 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.
387
+
388
+ 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.
389
+
390
+ ### Full control (bring your own UI)
391
+
392
+ Pass `onComment` instead of `commentAuthorId` — it's called with the selected range and you decide what happens next (open your own modal, pick the author yourself):
393
+
394
+ ```jsx
395
+ import { addComment, replyToComment, resolveComment, deleteComment, useComments, resolveMultiRunSelection } from 'noteloom';
396
+
397
+ <NoteloomEditor
398
+ editor={editor}
399
+ onComment={(range) => {
400
+ const text = window.prompt('Comment text?');
401
+ if (text) addComment(editor.store, range, { authorId: currentUser.id, text });
402
+ }}
403
+ />;
404
+
405
+ // Outside the floating toolbar entirely, resolve the selection yourself:
406
+ function AddCommentButton({ store }) {
407
+ function handleClick() {
408
+ const range = resolveMultiRunSelection(); // { blockId, startRunId, startOffset, endRunId, endOffset }
409
+ if (!range) return; // no non-collapsed selection
410
+ addComment(store, range, { authorId: currentUser.id, text: 'Can we tighten this up?' });
411
+ }
412
+ return <button onClick={handleClick}>Add comment</button>;
413
+ }
414
+
415
+ // A hand-rolled list, using useComments() directly instead of CommentsPanel/CommentThreadCard:
416
+ function CommentsSidebar({ store }) {
417
+ const comments = useComments();
418
+ return (
419
+ <ul>
420
+ {comments.map((thread) => (
421
+ <li key={thread.id}>
422
+ {thread.messages.map((m) => <p key={m.id}>{m.authorId}: {m.text}</p>)}
423
+ <button onClick={() => replyToComment(store, thread.id, { authorId: currentUser.id, text: '...' })}>Reply</button>
424
+ <button onClick={() => resolveComment(store, thread.id, !thread.resolved)}>{thread.resolved ? 'Reopen' : 'Resolve'}</button>
425
+ <button onClick={() => deleteComment(store, thread.id)}>Delete</button>
426
+ </li>
427
+ ))}
428
+ </ul>
429
+ );
430
+ }
431
+ ```
432
+
433
+ `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.
434
+
435
+ A comment thread is `{ id, blockId, anchorRunIds, resolved, messages: [{ id, authorId, text, createdAt }] }`. `CommentThreadCard`/`CommentComposer` (the pieces `CommentPopover`/`CommentsPanel` are built from) are exported too, for reusing the built-in look while customizing the surrounding layout.
436
+
437
+ **Scope, stated plainly:** a thread's own metadata (text, author, replies, resolved flag) is fully collaboration-aware — it broadcasts live to connected peers and undoes/redoes normally. The *highlighted range* it's anchored to is local-only in collaboration for v1: a newly-joining peer sees it correctly (full document snapshots always include it), but an already-connected peer won't see someone else's brand-new highlight appear live until their next resync. This isn't a new gap introduced by comments — every other range-based formatting operation (bold, italic, highlight, ...) already has this exact scope today, since none of them have a CRDT-safe wire representation yet.
438
+
439
+ `thread.anchorRunIds` is a creation-time hint only, meant for jumping to roughly where a comment was made — it is **not** re-validated after a later formatting edit splits or re-mints run ids in that range. To reliably find where a comment's highlight actually lives right now, look at which runs' `marks.commentIds` include it (exactly what `deleteComment` itself does internally via `removeCommentMarkEverywhere`), not `anchorRunIds`.
440
+
441
+ ## Version history
442
+
443
+ Point-in-time document snapshots, stored in IndexedDB (a third object store, alongside `usePersistedDocument`'s `documents` and Templates' `templates`) — periodic, manual, or both. `examples/07-version-history/` is a complete runnable app with a version list and one-click restore.
444
+
445
+ ```jsx
446
+ import { createPeriodicVersionSnapshotter, useDocumentVersions, saveDocumentVersion, applyDocumentTemplate, exportDocumentJSON } from 'noteloom';
447
+
448
+ // Automatic: snapshot every few minutes, keep the most recent 50.
449
+ useEffect(() => {
450
+ const snapshotter = createPeriodicVersionSnapshotter({ store: editor.store, docId, intervalMs: 5 * 60 * 1000 });
451
+ return () => snapshotter.stop();
452
+ }, [editor.store, docId]);
453
+
454
+ // Manual: "save a version now" button.
455
+ async function saveNow(label) {
456
+ const doc = JSON.parse(exportDocumentJSON(editor.store)); // exportDocumentJSON returns a JSON *string* — parse it first
457
+ await saveDocumentVersion({ id: crypto.randomUUID(), docId, timestamp: Date.now(), label, doc });
458
+ }
459
+
460
+ // A version list:
461
+ function VersionList({ docId }) {
462
+ const { versions, isLoaded } = useDocumentVersions(docId); // newest first
463
+ if (!isLoaded) return <p>Loading…</p>;
464
+ return (
465
+ <ul>
466
+ {versions.map((v) => (
467
+ <li key={v.id}>
468
+ {v.label ?? '(untitled)'} — {new Date(v.timestamp).toLocaleString()}
469
+ <button onClick={() => applyDocumentTemplate(editor.store, v.doc)}>Restore</button>
470
+ </li>
471
+ ))}
472
+ </ul>
473
+ );
474
+ }
475
+ ```
476
+
477
+ Restoring needs no new function — it's the exact same `applyDocumentTemplate(store, doc)` Templates already uses to wholesale-replace a live editor's content. `saveDocumentVersion`/`loadDocumentVersion`/`deleteDocumentVersion`/`listDocumentVersions` are the raw storage operations `useDocumentVersions` is built on. `createPeriodicVersionSnapshotter({ store, docId, intervalMs?, label?, maxVersions? })` prunes the oldest version past `maxVersions` (default 50) after each snapshot, so a long-running document doesn't grow the store unbounded.
478
+
299
479
  ## Right-to-left / multi-language text
300
480
 
301
481
  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:
@@ -499,7 +679,7 @@ What presence *contains* is entirely up to you — a cursor position, a display
499
679
  - Concurrent inserts (even at the same position) — both survive, converging to the same order on every peer.
500
680
  - Concurrent delete vs. edit of the same block — the delete wins.
501
681
  - Concurrent type-conversion of the same block ("Turn into") — one type wins deterministically (the same one, on every peer), not two duplicate blocks.
502
- - Concurrent edits to a run's text — whole-value last-write-wins (the newer edit replaces the older one entirely; character-level interleaving is not implemented).
682
+ - 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.
503
683
 
504
684
  ### Tombstone garbage collection
505
685
 
@@ -519,11 +699,18 @@ Or call `store.pruneTombstones({ maxAgeMs })` yourself on whatever schedule you
519
699
 
520
700
  **Why a time-based threshold is safe here specifically:** this only works because of how `CollabSession` reconnects — a peer rejoining after any absence gets a full document *snapshot* (`syncResponse`), never a replay of the ops it missed. That means a peer offline longer than the GC threshold never needs an old tombstone to resolve a stale reference; it just adopts the current state directly. The only residual risk is a single *already-connected* peer somehow stalling for exactly as long as the threshold and then delivering a queued message afterward — implausible for a live, reliable, ordered WebRTC data channel (which disconnects long before that under any real interruption), but not impossible, which is why this is opt-in rather than automatic.
521
701
 
702
+ ### Reconnecting reliably
703
+
704
+ `CollabSession`/`createWebSocketSignaling` deliberately don't retry anything themselves (see the class doc comment) — a dropped connection is a transport-layer concern left to the host app, on purpose, so this stays a small library rather than growing an opinionated retry/backoff policy no two apps would agree on. `examples/lan-collab/` is a complete, runnable reference for the two pieces most apps end up needing on top:
705
+
706
+ - **A watchdog that actually reconnects.** `createWebSocketSignaling` exposes no `close`/`error` event for the relay connection dying silently (a sleeping laptop, a WiFi drop, the relay restarting) — so periodically checking "do I currently have zero live peers, and has it been a while since I last tried" and, if so, tearing down and recreating the whole signaling + session is the only reliable way to notice and recover. Also worth reacting to the browser's own `online` event immediately, rather than waiting for the next timer tick.
707
+ - **Actually catching up, not just resuming.** A reconnecting peer that keeps its existing (non-empty) store — the right default, so a solo editing session isn't wiped by a network blip — never re-triggers `CollabSession`'s adopt-a-snapshot path, since that only fires when a store is genuinely empty (see "A peer joining with their own existing document" below). Left alone, this peer silently misses everything the room changed while it was away. The fix: on a genuine *reconnect* (never the very first connection) where nothing was typed locally in the gap, reset the store back to that same empty shape first — the same field-level reset `usePersistedDocument` uses internally — so the ordinary adopt-on-empty flow does the catching-up. If local edits *were* made while disconnected, keep them as-is; there's no safe way to both preserve them and adopt someone else's snapshot without a real merge (see the next limitation).
708
+
522
709
  **Known limitations — read before relying on this in production:**
523
- - **Undo is local-only, and can overwrite a peer's edit to the same run.** Your undo/redo never touches a peer's changes directly but because text merges as *whole-value* LWW (see above), undoing your own past edit to a run replays an old full-string snapshot, which will clobber anything a peer has since typed into that same run. Avoid undoing text you know a peer may have touched; a true fix requires character-level text merging, which is a deliberately larger, not-yet-built change.
710
+ - **Undo is local-only, and only ever touches your own edits.** Undo/redo of a text edit works by tombstoning/restoring the exact character ids *you* inserted/deleted (not by replaying an old whole-string snapshot), so undoing your own past edit to a run can never remove a peer's concurrent edit to that same run, no matter how they're interleaved. One narrower case remains open: concurrent *formatting* (bold/italic, which splits a run into new runs with new ids) racing a concurrent *edit* of the exact same run is a run-list-level (not character-level) concern this doesn't cover.
524
711
  - **Deleted content isn't garbage-collected automatically, but can be — opt-in.** Tombstones are kept by default (needed so a late-arriving concurrent operation can still resolve correctly), which means unbounded memory growth over a long enough session unless you do something about it. `store.pruneTombstones({ maxAgeMs })` (default 24h) removes tombstones older than that safely — see "Tombstone garbage collection" above. Nothing calls this automatically; wire up `createPeriodicTombstoneGC` (or call it yourself) if you want it handled for you.
525
- - **A peer joining with their own existing (different) document does not merge with yours.** `CollabSession` only adopts a peer's document wholesale when your own side is still empty — the common "open a shared link and get the document" flow. Reconciling two independently-created, already-diverged documents on first contact is a fundamentally harder problem (no shared id space) and isn't attempted.
526
- - **Reconnecting after a dropped connection re-syncs the full document**, not just what was missed — simple and correct, at the cost of O(document size) traffic per reconnect.
712
+ - **A peer joining with their own existing (different) document does not merge with yours.** `CollabSession` only adopts a peer's document wholesale when your own side is still empty — the common "open a shared link and get the document" flow. Reconciling two independently-created, already-diverged documents on first contact is a fundamentally harder problem (no shared id space) and isn't attempted. This is also why the reconnect pattern above only ever resets a store that has no unsynced local edits of its own.
713
+ - **Reconnecting after a dropped connection re-syncs the full document**, not just what was missed — simple and correct, at the cost of O(document size) traffic per reconnect. See "Reconnecting reliably" above for making the reconnect itself actually happen.
527
714
  - 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.
528
715
  - 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.
529
716
 
@@ -637,3 +824,4 @@ See `examples/README.md` for the rest of the runnable examples, and `CONTRIBUTIN
637
824
  - 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.
638
825
  - 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.
639
826
  - 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.
827
+ - 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.
package/dist/index.d.ts CHANGED
@@ -38,11 +38,28 @@ export interface FieldType {
38
38
  [key: string]: unknown;
39
39
  }
40
40
 
41
+ export interface CommentMessage {
42
+ id: string;
43
+ authorId: string;
44
+ text: string;
45
+ createdAt: number;
46
+ }
47
+
48
+ export interface CommentThread {
49
+ id: string;
50
+ blockId: string;
51
+ /** Creation-time hint only, not re-validated after later formatting edits -- see the README's documented limitation. */
52
+ anchorRunIds: string[];
53
+ resolved: boolean;
54
+ messages: CommentMessage[];
55
+ }
56
+
41
57
  export interface DocumentJSON {
42
58
  rootId: string;
43
59
  blocks: Block[];
44
60
  runs: Run[];
45
61
  fieldTypes?: FieldType[];
62
+ comments?: CommentThread[];
46
63
  }
47
64
 
48
65
  export type Operation = { type: string; [key: string]: unknown };
@@ -68,6 +85,11 @@ export const OP: {
68
85
  ADD_FIELD_TYPE: 'addFieldType';
69
86
  UPDATE_FIELD_TYPE: 'updateFieldType';
70
87
  REMOVE_FIELD_TYPE: 'removeFieldType';
88
+ ADD_COMMENT_THREAD: 'addCommentThread';
89
+ REMOVE_COMMENT_THREAD: 'removeCommentThread';
90
+ ADD_COMMENT_REPLY: 'addCommentReply';
91
+ REMOVE_COMMENT_REPLY: 'removeCommentReply';
92
+ RESOLVE_COMMENT: 'resolveComment';
71
93
  };
72
94
 
73
95
  export namespace operations {
@@ -83,6 +105,11 @@ export namespace operations {
83
105
  export function addFieldType(fieldType: FieldType): Operation;
84
106
  export function updateFieldType(id: string, patch: Partial<FieldType>): Operation;
85
107
  export function removeFieldType(id: string): Operation;
108
+ export function addCommentThread(thread: CommentThread): Operation;
109
+ export function removeCommentThread(commentId: string): Operation;
110
+ export function addCommentReply(commentId: string, message: CommentMessage): Operation;
111
+ export function removeCommentReply(commentId: string, messageId: string): Operation;
112
+ export function resolveComment(commentId: string, resolved: boolean): Operation;
86
113
  }
87
114
 
88
115
  // ---------------------------------------------------------------------------
@@ -95,11 +122,16 @@ export class EditorStore {
95
122
  runs: Map<string, Run>;
96
123
  rootId: string | null;
97
124
  fieldTypes: Map<string, FieldType>;
125
+ comments: Map<string, CommentThread>;
98
126
 
99
127
  getBlock(id: string): Block | undefined;
100
128
  getRun(id: string): Run | undefined;
101
129
  getFieldTypes(): FieldType[];
102
130
  getFieldType(id: string): FieldType | undefined;
131
+ getComments(): CommentThread[];
132
+ getComment(id: string): CommentThread | undefined;
133
+ /** Every run id in the whole document, regardless of reachability from the root -- see removeCommentMarkEverywhere. */
134
+ getAllRunIds(): string[];
103
135
  getRootId(): string | null;
104
136
  subscribe(id: string, listener: () => void): () => void;
105
137
  subscribeAll(listener: () => void): () => void;
@@ -145,6 +177,9 @@ export class History {
145
177
  getRootId(): string | null;
146
178
  getFieldTypes(): FieldType[];
147
179
  getFieldType(id: string): FieldType | undefined;
180
+ getComments(): CommentThread[];
181
+ getComment(id: string): CommentThread | undefined;
182
+ getAllRunIds(): string[];
148
183
  subscribe(id: string, listener: () => void): () => void;
149
184
  subscribeAll(listener: () => void): () => void;
150
185
  getTombstoneCount(): number;
@@ -284,6 +319,164 @@ export function createAutoPersistence(options: {
284
319
  onError?: (error: unknown) => void;
285
320
  }): { stop: () => void; flush: () => void };
286
321
 
322
+ // ---------------------------------------------------------------------------
323
+ // templates/ (+ the template half of persistence/)
324
+ // ---------------------------------------------------------------------------
325
+
326
+ /** One captured block-template root — see captureBlockTemplate. */
327
+ export interface BlockTemplate {
328
+ roots: CapturedSubtree[];
329
+ }
330
+
331
+ export interface StoredTemplate {
332
+ id: string;
333
+ scope: 'document' | 'block';
334
+ name: string;
335
+ description?: string;
336
+ /** A full DocumentJSON for scope 'document', or a BlockTemplate ({ roots }) for scope 'block'. */
337
+ doc: DocumentJSON | BlockTemplate;
338
+ }
339
+
340
+ export function saveTemplate(template: StoredTemplate): Promise<void>;
341
+ export function loadTemplate(id: string): Promise<StoredTemplate | null>;
342
+ export function deleteTemplate(id: string): Promise<void>;
343
+ export function listTemplates(): Promise<StoredTemplate[]>;
344
+
345
+ export function captureBlockTemplate(store: EditorStore | History, blockIds: string[]): BlockTemplate;
346
+ export function insertBlockTemplate(
347
+ store: EditorStore | History,
348
+ template: BlockTemplate,
349
+ position: { parentId: string; index: number },
350
+ ): void;
351
+ /** Wholesale-replaces an already-mounted editor's content with a document template. To start a NEW editor from one instead, just pass it as useEditor({ doc }). */
352
+ export function applyDocumentTemplate(store: EditorStore | History, doc: DocumentJSON): void;
353
+
354
+ export interface BlockTemplateDefinition {
355
+ id: string;
356
+ label: string;
357
+ icon?: ComponentType<{ size?: number }>;
358
+ keywords?: string[];
359
+ roots: CapturedSubtree[];
360
+ }
361
+
362
+ /** Registers block templates as slash commands, discoverable/insertable via "/" alongside every built-in block — no SlashMenu/BlockRegistry changes needed. */
363
+ export function registerBlockTemplates(registry: BlockRegistry, templates: BlockTemplateDefinition[]): void;
364
+
365
+ export function useTemplates(options?: { scope?: 'document' | 'block' }): {
366
+ templates: StoredTemplate[];
367
+ isLoaded: boolean;
368
+ refresh: () => Promise<void>;
369
+ };
370
+
371
+ export interface TemplatePickerProps {
372
+ templates: StoredTemplate[];
373
+ onSelect: (template: StoredTemplate) => void;
374
+ emptyLabel?: string;
375
+ }
376
+
377
+ export const TemplatePicker: ComponentType<TemplatePickerProps>;
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // versions/ (+ the version half of persistence/)
381
+ // ---------------------------------------------------------------------------
382
+
383
+ export interface DocumentVersion {
384
+ id: string;
385
+ docId: string;
386
+ timestamp: number;
387
+ label?: string;
388
+ doc: DocumentJSON;
389
+ }
390
+
391
+ export function saveDocumentVersion(version: DocumentVersion): Promise<void>;
392
+ export function loadDocumentVersion(id: string): Promise<DocumentVersion | null>;
393
+ export function deleteDocumentVersion(id: string): Promise<void>;
394
+ /** All versions saved for docId, newest first. */
395
+ export function listDocumentVersions(docId: string): Promise<DocumentVersion[]>;
396
+
397
+ /** Periodically snapshots a live document into the versions store; prunes oldest beyond maxVersions for this docId. Restore with applyDocumentTemplate(store, version.doc). */
398
+ export function createPeriodicVersionSnapshotter(options: {
399
+ store: History | EditorStore;
400
+ docId: string;
401
+ intervalMs?: number;
402
+ label?: string;
403
+ maxVersions?: number;
404
+ onSnapshot?: (version: DocumentVersion) => void;
405
+ onError?: (error: unknown) => void;
406
+ }): { stop: () => void };
407
+
408
+ export function useDocumentVersions(docId: string | null | undefined): {
409
+ versions: DocumentVersion[];
410
+ isLoaded: boolean;
411
+ refresh: () => Promise<void>;
412
+ };
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // comments/
416
+ // ---------------------------------------------------------------------------
417
+
418
+ export interface CommentRange {
419
+ blockId: string;
420
+ startRunId: string;
421
+ startOffset: number;
422
+ endRunId: string;
423
+ endOffset: number;
424
+ }
425
+
426
+ /** Creates a comment thread anchored to `range`, highlighting it and creating the thread as one atomic undo step. Returns the new comment's id. */
427
+ export function addComment(store: EditorStore | History, range: CommentRange, message: { authorId: string; text: string }): string;
428
+ /** Appends a reply to an existing thread. Returns the new message's id. */
429
+ export function replyToComment(store: EditorStore | History, commentId: string, message: { authorId: string; text: string }): string;
430
+ /** Flips a thread's resolved flag (defaults to true). */
431
+ export function resolveComment(store: EditorStore | History, commentId: string, resolved?: boolean): void;
432
+ /** Removes a thread and strips its highlight from every run that still carries it, as one atomic undo step. */
433
+ export function deleteComment(store: EditorStore | History, commentId: string): void;
434
+
435
+ /** Computes (does not apply) the op that highlights `range` with `commentId` -- for advanced use; addComment already calls this. Returns null for a collapsed/unresolvable range. */
436
+ export function addCommentMarkOverRange(store: EditorStore | History, range: CommentRange, commentId: string): Operation | null;
437
+ /** Computes (does not apply) the ops that strip `commentId` from every run in the document that carries it. */
438
+ export function removeCommentMarkEverywhere(store: EditorStore | History, commentId: string): Operation[];
439
+
440
+ export function useComments(): CommentThread[];
441
+
442
+ export interface CommentComposerProps {
443
+ /** Renders the composer's own avatar preview -- does not decide who the message is attributed to (the caller still passes authorId to addComment/replyToComment). */
444
+ authorId?: string;
445
+ placeholder?: string;
446
+ autoFocus?: boolean;
447
+ onSubmit: (text: string) => void;
448
+ /** Renders a Cancel button when given. */
449
+ onCancel?: () => void;
450
+ }
451
+
452
+ /** An avatar + textarea + send button for composing one comment message -- the shared piece CommentThreadCard's reply flow and FloatingToolbar's built-in Comment composer both use. */
453
+ export const CommentComposer: ComponentType<CommentComposerProps>;
454
+
455
+ export interface CommentAvatarProps {
456
+ authorId?: string;
457
+ size?: number;
458
+ }
459
+
460
+ /** A small colored circle with the author's initials, deterministically generated from authorId -- this package has no profile-picture/identity concept of its own. */
461
+ export const CommentAvatar: ComponentType<CommentAvatarProps>;
462
+
463
+ export interface CommentThreadCardProps {
464
+ store: EditorStore | History;
465
+ thread: CommentThread;
466
+ /** Hides the Reply action when not given -- composing a message needs an author. */
467
+ authorId?: string;
468
+ }
469
+
470
+ /** One comment thread -- messages, then Reply/Resolve/Delete. Shared by CommentPopover (click/hover on highlighted text, mounted automatically) and CommentsPanel. */
471
+ export const CommentThreadCard: ComponentType<CommentThreadCardProps>;
472
+
473
+ export interface CommentsPanelProps {
474
+ authorId?: string;
475
+ }
476
+
477
+ /** The opt-in right-side comments panel (Notion/Google Docs-style) -- see NoteloomEditorProps.showCommentsPanel, or render it yourself anywhere under an EditorProvider for the granular API. */
478
+ export const CommentsPanel: ComponentType<CommentsPanelProps>;
479
+
287
480
  // ---------------------------------------------------------------------------
288
481
  // registry/, blocks/, inlineTypes/
289
482
  // ---------------------------------------------------------------------------
@@ -347,6 +540,7 @@ export const codeBlockType: BlockTypeDefinition;
347
540
  export const toggleHeadingBlockType: BlockTypeDefinition;
348
541
  export const buttonBlockType: BlockTypeDefinition;
349
542
  export const embedBlockType: BlockTypeDefinition;
543
+ export const canvasBlockType: BlockTypeDefinition;
350
544
 
351
545
  export function registerBuiltInInlineTypes(inlineRegistry: InlineRegistry): void;
352
546
  export function registerInlineTypes(inlineRegistry: InlineRegistry, types: Record<string, InlineTypeDefinition>): void;
@@ -371,6 +565,8 @@ export interface EditorProviderProps {
371
565
  style?: CSSProperties;
372
566
  theme?: 'default' | 'none';
373
567
  getBlockClassName?: (block: Block) => string | undefined;
568
+ /** Current user's id for authoring comments through the built-in comment UI -- see useCommentAuthorId. */
569
+ commentAuthorId?: string;
374
570
  children?: ReactNode;
375
571
  }
376
572
 
@@ -382,6 +578,8 @@ export function useWholeDocumentSelection(): [boolean, (value: boolean) => void]
382
578
  export function useBlockRangeSelection(): [string[], (ids: string[]) => void];
383
579
  export function useSelectedBlock(): [string | null, (id: string | null) => void];
384
580
  export function usePreviewMode(): [boolean, (value: boolean) => void];
581
+ /** The commentAuthorId passed to EditorProvider/NoteloomEditor, or undefined if not configured -- see NoteloomEditorProps.commentAuthorId. */
582
+ export function useCommentAuthorId(): string | undefined;
385
583
  export function useFieldTypeEditor(): {
386
584
  editingFieldTypeId: string | null;
387
585
  openFieldTypeEditor: (id: string | null) => void;
@@ -453,13 +651,34 @@ export const EditorTrailingSpace: ComponentType<Record<string, unknown>>;
453
651
  // clipboard/
454
652
  // ---------------------------------------------------------------------------
455
653
 
654
+ export interface CapturedSubtree {
655
+ rootId: string;
656
+ blocks: Block[];
657
+ runs: Run[];
658
+ }
659
+
660
+ export interface RemappedSubtree {
661
+ block: Block;
662
+ runs: Run[];
663
+ subtreeBlocks: Block[];
664
+ }
665
+
456
666
  export const APP_MIME: string;
457
- export function serializeBlockRange(...args: unknown[]): unknown;
458
- export function remapSubtreeIds(...args: unknown[]): unknown;
667
+ export function serializeBlockRange(
668
+ store: EditorStore | History,
669
+ registry: BlockRegistry,
670
+ blockIds: string[],
671
+ inlineRegistry?: InlineRegistry,
672
+ ): { html: string; text: string; json: string };
673
+ /** Read-only capture of one block + its descendants, with original ids intact — see also captureBlockTemplate for capturing several sibling roots at once. */
674
+ export function captureSubtree(store: EditorStore | History, rootId: string): CapturedSubtree;
675
+ /** Gives a captured subtree fresh ids, ready to insert elsewhere without colliding with existing content. */
676
+ export function remapSubtreeIds(captured: CapturedSubtree): RemappedSubtree;
459
677
  export function deserializeClipboard(...args: unknown[]): unknown;
460
678
  export function walkDomToBlocks(...args: unknown[]): unknown;
461
679
  export function textToParagraphs(...args: unknown[]): unknown;
462
- export function exportDocumentJSON(store: EditorStore | History): unknown;
680
+ /** Returns a JSON *string* (pretty-printed by default) — parse it (`JSON.parse`) to get back a plain `{ version, rootId, blocks, runs }` object usable as `useEditor({ doc })`. */
681
+ export function exportDocumentJSON(store: EditorStore | History, options?: { pretty?: boolean }): string;
463
682
  export function exportDocumentHTML(store: EditorStore | History, registry: BlockRegistry): string;
464
683
  export function exportDocumentText(store: EditorStore | History, registry: BlockRegistry): string;
465
684
  export function exportDocumentSimpleJSON(store: EditorStore | History, registry: BlockRegistry, inlineRegistry: InlineRegistry): unknown;
@@ -503,6 +722,10 @@ export interface FloatingToolbarProps {
503
722
  crossSelection: unknown;
504
723
  marks: Record<string, unknown>;
505
724
  store: EditorStore | History;
725
+ /** Adds a Comment button (same-block selections only) that calls this with the CommentRange under the current selection. Takes priority over commentAuthorId's built-in composer when both are given. */
726
+ onComment?: (range: CommentRange) => void;
727
+ /** Adds a Comment button using a built-in inline composer (addComment(store, range, {authorId: commentAuthorId, text})) instead of onComment -- see NoteloomEditorProps.commentAuthorId. */
728
+ commentAuthorId?: string;
506
729
  }
507
730
 
508
731
  export const FloatingToolbar: ComponentType<FloatingToolbarProps>;
@@ -613,6 +836,12 @@ export interface NoteloomEditorProps {
613
836
  style?: CSSProperties;
614
837
  theme?: 'default' | 'none';
615
838
  getBlockClassName?: (block: Block) => string | undefined;
839
+ /** Adds a Comment button to the floating format toolbar, fully host-controlled — see FloatingToolbarProps.onComment. */
840
+ onComment?: (range: CommentRange) => void;
841
+ /** Current user's id -- enables the whole built-in comments UI (floating toolbar composer, click/hover popover on existing highlights, CommentsPanel) with no host UI code. Ignored by the floating toolbar's button when onComment is also given. */
842
+ commentAuthorId?: string;
843
+ /** Renders CommentsPanel (right-side, Notion/Google Docs-style thread list) automatically. */
844
+ showCommentsPanel?: boolean;
616
845
  children?: ReactNode;
617
846
  }
618
847