svedit 0.10.0 → 0.11.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
@@ -48,6 +48,7 @@ Svedit connects eight key pieces:
48
48
  8. **Commands** - User actions (bold text, insert node, undo/redo) that modify the session
49
49
 
50
50
  **The flow:**
51
+
51
52
  - Define a schema → create a session → provide config → render with `<Svedit>` component
52
53
  - User interactions trigger commands → commands create transactions → which run transforms to modify the document → session applies the transaction → Svelte's reactivity updates the UI
53
54
  - Native DOM selections are mapped to Svedit's internal selection model
@@ -59,6 +60,7 @@ You can use a simple JSON-compatible schema definition language to enforce const
59
60
  First off, everything is a node. The page is a node, and so is a paragraph, a list, a list item, a nav and a nav item.
60
61
 
61
62
  Each node has a `kind` that determines its behavior:
63
+
62
64
  - `document`: A top-level node accessible via a route (e.g. a page, event)
63
65
  - `block`: A structured node that contains other nodes or properties
64
66
  - `text`: A node with editable text content (can be split and joined)
@@ -67,6 +69,7 @@ Each node has a `kind` that determines its behavior:
67
69
  ### Choosing between `text` and `block`
68
70
 
69
71
  `kind: 'text'` opts into the split/join system (`break_text_node`, `join_text_node`), which assumes:
72
+
70
73
  - The node has exactly **one** `annotated_text` property named **`content`**
71
74
  - Pressing Enter splits the node into two nodes of the same type
72
75
  - Pressing Backspace at position 0 joins it with the previous node
@@ -108,6 +111,7 @@ flowchart TD
108
111
  </details>
109
112
 
110
113
  Properties of nodes can hold values:
114
+
111
115
  - `string`: A good old JavaScript string
112
116
  - `number`: Just like a number in JavaScript
113
117
  - `integer`: A number for which Number.isInteger(number) returns true
@@ -120,83 +124,83 @@ Properties of nodes can hold values:
120
124
  - `annotated_text`: A plain text string with annotations (bold, italic, link etc.). Set `allow_newlines: true` to let users insert line breaks with Shift+Enter, or `false` to keep content single-line (e.g. for titles).
121
125
 
122
126
  Or references:
127
+
123
128
  - `node`: References a single node (e.g. an image node can reference a global asset node)
124
129
  - `node_array`: References a sequence of nodes (e.g. page.body references paragraph and list nodes)
125
130
 
126
-
127
131
  ```js
128
132
  const document_schema = {
129
- page: {
130
- kind: 'document',
131
- properties: {
132
- body: {
133
- type: 'node_array',
134
- node_types: ['nav', 'paragraph', 'list'],
135
- default_node_type: 'paragraph',
136
- }
137
- }
138
- },
139
- paragraph: {
140
- kind: 'text',
141
- properties: {
142
- content: {
143
- type: 'annotated_text',
144
- node_types: ['strong', 'emphasis', 'link'],
145
- allow_newlines: true
146
- }
147
- }
148
- },
149
- list_item: {
150
- kind: 'text',
151
- properties: {
152
- content: {
153
- type: 'annotated_text',
154
- node_types: ['strong', 'emphasis', 'link'],
155
- allow_newlines: true
156
- },
157
- }
158
- },
159
- list: {
160
- kind: 'block',
161
- properties: {
162
- list_items: {
163
- type: 'node_array',
164
- node_types: ['list_item'],
165
- default_node_type: 'list_item',
166
- }
167
- }
168
- },
169
- nav: {
170
- kind: 'block',
171
- properties: {
172
- nav_items: {
173
- type: 'node_array',
174
- node_types: ['nav_item'],
175
- default_node_type: 'nav_item',
176
- }
177
- }
178
- },
179
- nav_item: {
180
- kind: 'block',
181
- properties: {
182
- url: { type: 'string' },
183
- label: { type: 'string' },
184
- }
185
- },
186
- strong: {
187
- kind: 'annotation',
188
- properties: {}
189
- },
190
- emphasis: {
191
- kind: 'annotation',
192
- properties: {}
193
- },
194
- link: {
195
- kind: 'annotation',
196
- properties: {
197
- href: { type: 'string' }
198
- }
199
- }
133
+ page: {
134
+ kind: 'document',
135
+ properties: {
136
+ body: {
137
+ type: 'node_array',
138
+ node_types: ['nav', 'paragraph', 'list'],
139
+ default_node_type: 'paragraph'
140
+ }
141
+ }
142
+ },
143
+ paragraph: {
144
+ kind: 'text',
145
+ properties: {
146
+ content: {
147
+ type: 'annotated_text',
148
+ node_types: ['strong', 'emphasis', 'link'],
149
+ allow_newlines: true
150
+ }
151
+ }
152
+ },
153
+ list_item: {
154
+ kind: 'text',
155
+ properties: {
156
+ content: {
157
+ type: 'annotated_text',
158
+ node_types: ['strong', 'emphasis', 'link'],
159
+ allow_newlines: true
160
+ }
161
+ }
162
+ },
163
+ list: {
164
+ kind: 'block',
165
+ properties: {
166
+ list_items: {
167
+ type: 'node_array',
168
+ node_types: ['list_item'],
169
+ default_node_type: 'list_item'
170
+ }
171
+ }
172
+ },
173
+ nav: {
174
+ kind: 'block',
175
+ properties: {
176
+ nav_items: {
177
+ type: 'node_array',
178
+ node_types: ['nav_item'],
179
+ default_node_type: 'nav_item'
180
+ }
181
+ }
182
+ },
183
+ nav_item: {
184
+ kind: 'block',
185
+ properties: {
186
+ url: { type: 'string' },
187
+ label: { type: 'string' }
188
+ }
189
+ },
190
+ strong: {
191
+ kind: 'annotation',
192
+ properties: {}
193
+ },
194
+ emphasis: {
195
+ kind: 'annotation',
196
+ properties: {}
197
+ },
198
+ link: {
199
+ kind: 'annotation',
200
+ properties: {
201
+ href: { type: 'string' }
202
+ }
203
+ }
200
204
  };
201
205
  ```
202
206
 
@@ -207,6 +211,7 @@ Annotation types are defined as nodes with `kind: 'annotation'`. Simple annotati
207
211
  A document is a plain JavaScript object (POJO) with a `document_id` (the entry point) and a `nodes` object containing all content nodes.
208
212
 
209
213
  Rules:
214
+
210
215
  - All nodes must be reachable from the document node (unreachable nodes are discarded)
211
216
  - No cyclic references allowed
212
217
  - Text content uses `{ text: '', annotations: [] }` format
@@ -215,45 +220,45 @@ Here's an example document:
215
220
 
216
221
  ```js
217
222
  const doc = {
218
- document_id: 'page_1',
219
- nodes: {
220
- nav_item_1: {
221
- id: 'nav_item_1',
222
- type: 'nav_item',
223
- url: '/homepage',
224
- label: 'Home'
225
- },
226
- nav_1: {
227
- id: 'nav_1',
228
- type: 'nav',
229
- nav_items: ['nav_item_1']
230
- },
231
- paragraph_1: {
232
- id: 'paragraph_1',
233
- type: 'paragraph',
234
- content: { text: 'Hello world.', annotations: [] }
235
- },
236
- list_item_1: {
237
- id: 'list_item_1',
238
- type: 'list_item',
239
- content: { text: 'First list item', annotations: [] }
240
- },
241
- list_item_2: {
242
- id: 'list_item_2',
243
- type: 'list_item',
244
- content: { text: 'Second list item', annotations: [] }
245
- },
246
- list_1: {
247
- id: 'list_1',
248
- type: 'list',
249
- list_items: ['list_item_1', 'list_item_2']
250
- },
251
- page_1: {
252
- id: 'page_1',
253
- type: 'page',
254
- body: ['nav_1', 'paragraph_1', 'list_1']
255
- }
256
- }
223
+ document_id: 'page_1',
224
+ nodes: {
225
+ nav_item_1: {
226
+ id: 'nav_item_1',
227
+ type: 'nav_item',
228
+ url: '/homepage',
229
+ label: 'Home'
230
+ },
231
+ nav_1: {
232
+ id: 'nav_1',
233
+ type: 'nav',
234
+ nav_items: ['nav_item_1']
235
+ },
236
+ paragraph_1: {
237
+ id: 'paragraph_1',
238
+ type: 'paragraph',
239
+ content: { text: 'Hello world.', annotations: [] }
240
+ },
241
+ list_item_1: {
242
+ id: 'list_item_1',
243
+ type: 'list_item',
244
+ content: { text: 'First list item', annotations: [] }
245
+ },
246
+ list_item_2: {
247
+ id: 'list_item_2',
248
+ type: 'list_item',
249
+ content: { text: 'Second list item', annotations: [] }
250
+ },
251
+ list_1: {
252
+ id: 'list_1',
253
+ type: 'list',
254
+ list_items: ['list_item_1', 'list_item_2']
255
+ },
256
+ page_1: {
257
+ id: 'page_1',
258
+ type: 'page',
259
+ body: ['nav_1', 'paragraph_1', 'list_1']
260
+ }
261
+ }
257
262
  };
258
263
  ```
259
264
 
@@ -267,16 +272,16 @@ For simple additive changes, defaults can help. When you add a new property, you
267
272
  import { Session, fill_document_defaults } from 'svedit';
268
273
 
269
274
  const document_schema = {
270
- paragraph: {
271
- kind: 'text',
272
- properties: {
273
- layout: { type: 'integer', default: 1 },
274
- content: {
275
- type: 'annotated_text',
276
- allow_newlines: true
277
- }
278
- }
279
- }
275
+ paragraph: {
276
+ kind: 'text',
277
+ properties: {
278
+ layout: { type: 'integer', default: 1 },
279
+ content: {
280
+ type: 'annotated_text',
281
+ allow_newlines: true
282
+ }
283
+ }
284
+ }
280
285
  };
281
286
 
282
287
  const migrated_doc = fill_document_defaults(existing_doc, document_schema);
@@ -307,13 +312,13 @@ Two optional hooks are especially useful when integrating custom media workflows
307
312
  const session_config = {
308
313
  // ID generator for creating new nodes
309
314
  generate_id: () => nanoid(),
310
-
315
+
311
316
  // User-land overlays and optional system component overrides
312
- system_components: { Overlays },
313
-
317
+ system_components: { overlays: Overlays },
318
+
314
319
  // Map node types to Svelte components
315
- node_components: { Page, Text, Story, List, Button, ... },
316
-
320
+ node_components: { page: Page, text: Text, story: Story, list: List, button: Button, ... },
321
+
317
322
  // Functions that create and insert new nodes
318
323
  inserters: {
319
324
  text: (tr, content = {text: '', annotations: []}) => {
@@ -322,10 +327,10 @@ const session_config = {
322
327
  tr.insert_nodes([text_id]);
323
328
  }
324
329
  },
325
-
330
+
326
331
  // Returns { commands, keymap } for the editor instance
327
332
  create_commands_and_keymap: (context) => { ... },
328
-
333
+
329
334
  // Optional: handle image paste events
330
335
  handle_image_paste: (session, images) => { ... }
331
336
  };
@@ -336,8 +341,8 @@ const session_config = {
336
341
  - **`generate_id`** - Function that generates unique IDs for new nodes
337
342
  - **`node_components`** - Maps each node type from your schema to a Svelte component
338
343
  - **`system_components`** - Optional overrides for internal editor components and a slot for your own overlays:
339
- - `Overlays` — A Svelte component rendered inside `<Svedit>` but outside the content canvas. Use it to add floating UI like link editors, image toolbars, or annotation popovers that appear near the current selection. See [`src/routes/components/Overlays.svelte`](src/routes/components/Overlays.svelte) for an example.
340
- - `NodeGap`, `NodeGapMarkers`, `NodeSelectionMarkers` — Override the default system components if you need custom visuals for node gaps or selection indicators.
344
+ - `overlays` — A Svelte component rendered inside `<Svedit>` but outside the content canvas. Use it to add floating UI like link editors, image toolbars, or annotation popovers that appear near the current selection. See [`src/routes/components/Overlays.svelte`](src/routes/components/Overlays.svelte) for an example.
345
+ - `node_gap`, `node_gap_markers`, `node_selection_markers` — Override the default system components if you need custom visuals for node gaps or selection indicators.
341
346
  - **`inserters`** - Functions that create blank nodes of each type and set up the selection
342
347
  - **`create_commands_and_keymap`** - Factory function that creates commands and keybindings for an editor instance
343
348
  - **`handle_image_paste`** - Optional handler for image paste events
@@ -364,21 +369,21 @@ const session = new Session(schema, doc, config);
364
369
  ### Reading the graph
365
370
 
366
371
  ```js
367
- session.get(['page_1', 'body']) // => ['nav_1', 'paragraph_1', 'list_1']
368
- session.get(['nav_1']) // => { id: 'nav_1', type: 'nav', ... }
369
- session.get('nav_1') // => shorthand for above (single node ID)
370
- session.inspect(['page_1', 'body']) // => { kind: 'property', type: 'node_array', node_types: [...] }
371
- session.kind(node) // => 'text', 'block', or 'annotation'
372
+ session.get(['page_1', 'body']); // => ['nav_1', 'paragraph_1', 'list_1']
373
+ session.get(['nav_1']); // => { id: 'nav_1', type: 'nav', ... }
374
+ session.get('nav_1'); // => shorthand for above (single node ID)
375
+ session.inspect(['page_1', 'body']); // => { kind: 'property', type: 'node_array', node_types: [...] }
376
+ session.kind(node); // => 'text', 'block', or 'annotation'
372
377
  ```
373
378
 
374
379
  ### Selection and state
375
380
 
376
381
  ```js
377
- session.selection // Current selection (text, node, or property)
378
- session.selected_node // The currently selected node (derived)
379
- session.active_annotation('strong') // Check if annotation is active at caret
380
- session.can_insert('paragraph') // Check if node type can be inserted
381
- session.available_annotation_types // Annotation types allowed at current selection (derived)
382
+ session.selection; // Current selection (text, node, or property)
383
+ session.selected_node; // The currently selected node (derived)
384
+ session.active_annotation('strong'); // Check if annotation is active at caret
385
+ session.can_insert('paragraph'); // Check if node type can be inserted
386
+ session.available_annotation_types; // Annotation types allowed at current selection (derived)
382
387
  ```
383
388
 
384
389
  #### Native selection as source of truth
@@ -396,10 +401,10 @@ For example, a multi-node selection is still represented as a native browser sel
396
401
  ### Making changes
397
402
 
398
403
  ```js
399
- const tr = session.tr; // Create a transaction
404
+ const tr = session.tr; // Create a transaction
400
405
  tr.set(['nav_1', 'label'], 'Home');
401
406
  tr.insert_nodes(['new_node_id']);
402
- session.apply(tr); // Apply the transaction
407
+ session.apply(tr); // Apply the transaction
403
408
  ```
404
409
 
405
410
  #### Batching history entries
@@ -419,10 +424,10 @@ session.last_batch_started = undefined;
419
424
  ### History
420
425
 
421
426
  ```js
422
- session.can_undo // Boolean (derived)
423
- session.can_redo // Boolean (derived)
424
- session.undo()
425
- session.redo()
427
+ session.can_undo; // Boolean (derived)
428
+ session.can_redo; // Boolean (derived)
429
+ session.undo();
430
+ session.redo();
426
431
  ```
427
432
 
428
433
  ### Detecting unsaved changes
@@ -432,18 +437,18 @@ Because document state is immutable, you can detect unsaved changes by comparing
432
437
  ```js
433
438
  let last_saved_doc = $state(null);
434
439
  let has_unsaved_changes = $derived.by(() => {
435
- if (!last_saved_doc) {
436
- // No save yet — use undo history as indicator
437
- return session.can_undo;
438
- } else {
439
- // Compare current doc reference against last saved
440
- return last_saved_doc !== session.doc;
441
- }
440
+ if (!last_saved_doc) {
441
+ // No save yet — use undo history as indicator
442
+ return session.can_undo;
443
+ } else {
444
+ // Compare current doc reference against last saved
445
+ return last_saved_doc !== session.doc;
446
+ }
442
447
  });
443
448
 
444
449
  function save() {
445
- // ... save to server ...
446
- last_saved_doc = session.doc;
450
+ // ... save to server ...
451
+ last_saved_doc = session.doc;
447
452
  }
448
453
  ```
449
454
 
@@ -452,12 +457,12 @@ This works because of Svedit's copy-on-write strategy: only modified parts of th
452
457
  ### Utilities
453
458
 
454
459
  ```js
455
- session.doc.document_id // The document's root ID
456
- session.generate_id() // Generate a new unique ID
457
- session.config // Access the config object
458
- session.validate_doc() // Validate all nodes against schema
459
- session.traverse(node_id) // Get all nodes reachable from a node
460
- session.select_parent() // Select parent of current selection
460
+ session.doc.document_id; // The document's root ID
461
+ session.generate_id(); // Generate a new unique ID
462
+ session.config; // Access the config object
463
+ session.validate_doc(); // Validate all nodes against schema
464
+ session.traverse(node_id); // Get all nodes reachable from a node
465
+ session.select_parent(); // Select parent of current selection
461
466
  ```
462
467
 
463
468
  ## Transforms
@@ -473,7 +478,7 @@ import { break_text_node } from 'svedit';
473
478
  const tr = session.tr;
474
479
  const success = break_text_node(tr);
475
480
  if (success) {
476
- session.apply(tr);
481
+ session.apply(tr);
477
482
  }
478
483
  ```
479
484
 
@@ -491,10 +496,10 @@ Transforms are composable. You can build higher-level transforms from lower-leve
491
496
 
492
497
  ```js
493
498
  function custom_transform(tr) {
494
- // Compose multiple transforms
495
- if (!break_text_node(tr)) return false;
496
- if (!insert_default_node(tr)) return false;
497
- return true;
499
+ // Compose multiple transforms
500
+ if (!break_text_node(tr)) return false;
501
+ if (!insert_default_node(tr)) return false;
502
+ return true;
498
503
  }
499
504
  ```
500
505
 
@@ -504,16 +509,16 @@ You're encouraged to write custom transforms for your application's specific nee
504
509
 
505
510
  ```js
506
511
  function insert_heading(tr) {
507
- const selection = tr.selection;
508
-
509
- if (selection?.type !== 'node') return false;
510
-
511
- // Create and insert a heading node
512
- const heading_id = tr.generate_id();
513
- tr.create({ id: heading_id, type: 'heading', content: { text: '', annotations: [] } });
514
- tr.insert_nodes([heading_id]);
515
-
516
- return true;
512
+ const selection = tr.selection;
513
+
514
+ if (selection?.type !== 'node') return false;
515
+
516
+ // Create and insert a heading node
517
+ const heading_id = tr.generate_id();
518
+ tr.create({ id: heading_id, type: 'heading', content: { text: '', annotations: [] } });
519
+ tr.insert_nodes([heading_id]);
520
+
521
+ return true;
517
522
  }
518
523
  ```
519
524
 
@@ -524,9 +529,9 @@ Transactions group multiple operations into atomic units that can be applied and
524
529
  ### Basic usage
525
530
 
526
531
  ```js
527
- const tr = session.tr; // Create a new transaction
528
- tr.set(['node_1', 'title'], 'New Title'); // Modify properties
529
- session.apply(tr); // Apply atomically
532
+ const tr = session.tr; // Create a new transaction
533
+ tr.set(['node_1', 'title'], 'New Title'); // Modify properties
534
+ session.apply(tr); // Apply atomically
530
535
  ```
531
536
 
532
537
  ### Node operations
@@ -543,7 +548,7 @@ tr.insert_nodes(['paragraph_1', 'list_1']);
543
548
 
544
549
  // Build a subgraph from existing nodes (generates new IDs)
545
550
  const new_node_id = tr.build('the_list', {
546
- first_item: {
551
+ first_item: {
547
552
  id: 'first_item',
548
553
  type: 'list_item',
549
554
  content: node.content
@@ -575,19 +580,17 @@ tr.delete_selection();
575
580
  ```js
576
581
  // Set the selection after operations
577
582
  tr.set_selection({
578
- type: 'text',
579
- path: ['node_1', 'content'],
580
- anchor_offset: 0,
581
- focus_offset: 5
583
+ type: 'text',
584
+ path: ['node_1', 'content'],
585
+ anchor_offset: 0,
586
+ focus_offset: 5
582
587
  });
583
588
  ```
584
589
 
585
590
  All transaction methods return `this` for chaining:
586
591
 
587
592
  ```js
588
- tr.create(node)
589
- .insert_nodes([node.id])
590
- .set_selection(new_selection);
593
+ tr.create(node).insert_nodes([node.id]).set_selection(new_selection);
591
594
  ```
592
595
 
593
596
  ## Commands
@@ -595,6 +598,7 @@ tr.create(node)
595
598
  Commands provide a structured way to implement user actions. Commands are stateful and UI-aware, unlike transforms which are pure functions.
596
599
 
597
600
  There are two types of commands in Svedit:
601
+
598
602
  - **Document-scoped commands** - Bound to a specific Svedit instance/document and only active when that editor has focus
599
603
  - **App-level commands** - Operate at the application level, independent of any specific document
600
604
 
@@ -612,13 +616,13 @@ Extend the `Command` base class and implement the `is_enabled()` and `execute()`
612
616
  import { Command } from 'svedit';
613
617
 
614
618
  class ToggleStrongCommand extends Command {
615
- is_enabled() {
616
- return this.context.editable && this.context.session.selection?.type === 'text';
617
- }
619
+ is_enabled() {
620
+ return this.context.editable && this.context.session.selection?.type === 'text';
621
+ }
618
622
 
619
- execute() {
620
- this.context.session.apply(this.context.session.tr.annotate_text('strong'));
621
- }
623
+ execute() {
624
+ this.context.session.apply(this.context.session.tr.annotate_text('strong'));
625
+ }
622
626
  }
623
627
  ```
624
628
 
@@ -674,32 +678,33 @@ Commands are created by passing them a context object from the Svedit component.
674
678
 
675
679
  ```js
676
680
  create_commands_and_keymap: (context) => {
677
- const commands = {
678
- undo: new UndoCommand(context),
679
- redo: new RedoCommand(context),
680
- toggle_strong: new ToggleAnnotationCommand('strong', context),
681
- toggle_emphasis: new ToggleAnnotationCommand('emphasis', context),
682
- // ... more commands
683
- };
684
-
685
- const keymap = define_keymap({
686
- 'meta+z,ctrl+z': [commands.undo],
687
- 'meta+b,ctrl+b': [commands.toggle_strong],
688
- // ... more keybindings
689
- });
690
-
691
- return { commands, keymap };
692
- }
681
+ const commands = {
682
+ undo: new UndoCommand(context),
683
+ redo: new RedoCommand(context),
684
+ toggle_strong: new ToggleAnnotationCommand('strong', context),
685
+ toggle_emphasis: new ToggleAnnotationCommand('emphasis', context)
686
+ // ... more commands
687
+ };
688
+
689
+ const keymap = define_keymap({
690
+ 'meta+z,ctrl+z': [commands.undo],
691
+ 'meta+b,ctrl+b': [commands.toggle_strong]
692
+ // ... more keybindings
693
+ });
694
+
695
+ return { commands, keymap };
696
+ };
693
697
  ```
694
698
 
695
699
  Bind commands to UI elements in your components:
696
700
 
697
701
  ```svelte
698
- <button
699
- disabled={document_commands.toggle_strong.disabled}
700
- class:active={document_commands.toggle_strong.active}
701
- onclick={() => document_commands.toggle_strong.execute()}>
702
- Bold
702
+ <button
703
+ disabled={document_commands.toggle_strong.disabled}
704
+ class:active={document_commands.toggle_strong.active}
705
+ onclick={() => document_commands.toggle_strong.execute()}
706
+ >
707
+ Bold
703
708
  </button>
704
709
  ```
705
710
 
@@ -709,34 +714,33 @@ Commands can have derived state for reactive UI binding. The `active` property i
709
714
 
710
715
  ```js
711
716
  class ToggleEmphasisCommand extends Command {
712
- // Automatically recomputes when annotation state changes
713
- active = $derived(this.context.session.active_annotation('emphasis'));
717
+ // Automatically recomputes when annotation state changes
718
+ active = $derived(this.context.session.active_annotation('emphasis'));
714
719
 
715
- is_enabled() {
716
- return this.context.editable && this.context.session.selection?.type === 'text';
717
- }
720
+ is_enabled() {
721
+ return this.context.editable && this.context.session.selection?.type === 'text';
722
+ }
718
723
 
719
- execute() {
720
- this.context.session.apply(this.context.session.tr.annotate_text('emphasis'));
721
- }
724
+ execute() {
725
+ this.context.session.apply(this.context.session.tr.annotate_text('emphasis'));
726
+ }
722
727
  }
723
728
  ```
724
729
 
725
730
  The `disabled` property is automatically derived from `is_enabled()` on all commands.
726
731
 
727
-
728
732
  #### DOM access in commands
729
733
 
730
734
  Commands can access the DOM through the context or global APIs:
731
735
 
732
736
  ```js
733
737
  class FocusNextSelectableCommand extends Command {
734
- execute() {
735
- const selectables = this.context.canvas_el.querySelectorAll('.svedit-selectable');
736
- const next = selectables[0]; // Find next based on current selection
737
- const path = next.closest('[data-path]').dataset.path.split('.');
738
- this.context.session.selection = { type: 'text', path, anchor_offset: 0, focus_offset: 0 };
739
- }
738
+ execute() {
739
+ const selectables = this.context.canvas_el.querySelectorAll('.svedit-selectable');
740
+ const next = selectables[0]; // Find next based on current selection
741
+ const path = next.closest('[data-path]').dataset.path.split('.');
742
+ this.context.session.selection = { type: 'text', path, anchor_offset: 0, focus_offset: 0 };
743
+ }
740
744
  }
741
745
  ```
742
746
 
@@ -752,6 +756,7 @@ Svedit uses a scope hierarchy (scope stack) to manage which commands are active
752
756
  2. **Document-level scope** (per Svedit instance) - Commands bound to a specific document/editor
753
757
 
754
758
  When a Svedit instance gains focus:
759
+
755
760
  - The previous document's scope is **popped** from the stack (its commands become inactive)
756
761
  - The newly focused document's scope is **pushed** onto the stack (its commands become active)
757
762
 
@@ -765,24 +770,24 @@ App-level commands have their own context, separate from any specific document:
765
770
  import { Command } from 'svedit';
766
771
 
767
772
  class SaveCommand extends Command {
768
- is_enabled() {
769
- return this.context.editable;
770
- }
771
-
772
- async execute() {
773
- await this.context.save_all_documents();
774
- this.context.show_notification('All changes saved');
775
- }
773
+ is_enabled() {
774
+ return this.context.editable;
775
+ }
776
+
777
+ async execute() {
778
+ await this.context.save_all_documents();
779
+ this.context.show_notification('All changes saved');
780
+ }
776
781
  }
777
782
 
778
783
  class ToggleEditModeCommand extends Command {
779
- is_enabled() {
780
- return !this.context.editable;
781
- }
784
+ is_enabled() {
785
+ return !this.context.editable;
786
+ }
782
787
 
783
- execute() {
784
- this.context.editable = true;
785
- }
788
+ execute() {
789
+ this.context.editable = true;
790
+ }
786
791
  }
787
792
  ```
788
793
 
@@ -792,23 +797,23 @@ The app-level context contains application-wide state and methods:
792
797
 
793
798
  ```js
794
799
  const app_context = {
795
- get editable() {
796
- return editable; // App-level editable state
797
- },
798
- set editable(value) {
799
- editable = value;
800
- },
801
- get session() {
802
- return session;
803
- },
804
- get app_el() {
805
- return app_el;
806
- }
800
+ get editable() {
801
+ return editable; // App-level editable state
802
+ },
803
+ set editable(value) {
804
+ editable = value;
805
+ },
806
+ get session() {
807
+ return session;
808
+ },
809
+ get app_el() {
810
+ return app_el;
811
+ }
807
812
  };
808
813
 
809
814
  const app_commands = {
810
- save: new SaveCommand(app_context),
811
- toggle_edit: new ToggleEditCommand(app_context)
815
+ save: new SaveCommand(app_context),
816
+ toggle_edit: new ToggleEditCommand(app_context)
812
817
  };
813
818
  ```
814
819
 
@@ -825,9 +830,9 @@ const key_mapper = new KeyMapper();
825
830
 
826
831
  // Define a keymap
827
832
  const keymap = define_keymap({
828
- 'meta+z,ctrl+z': [document_commands.undo],
829
- 'meta+b,ctrl+b': [document_commands.bold],
830
- 'enter': [document_commands.break_text_node]
833
+ 'meta+z,ctrl+z': [document_commands.undo],
834
+ 'meta+b,ctrl+b': [document_commands.bold],
835
+ enter: [document_commands.break_text_node]
831
836
  });
832
837
 
833
838
  // Push the keymap onto the scope stack
@@ -835,7 +840,7 @@ key_mapper.push_scope(keymap);
835
840
 
836
841
  // Handle keydown events
837
842
  window.addEventListener('keydown', (event) => {
838
- key_mapper.handle_keydown(event);
843
+ key_mapper.handle_keydown(event);
839
844
  });
840
845
  ```
841
846
 
@@ -852,10 +857,10 @@ Commands are wrapped in arrays to support fallback behavior:
852
857
 
853
858
  ```js
854
859
  define_keymap({
855
- 'meta+b,ctrl+b': [
856
- document_commands.bold, // Try this first
857
- document_commands.fallback // Use this if first is disabled
858
- ]
860
+ 'meta+b,ctrl+b': [
861
+ document_commands.bold, // Try this first
862
+ document_commands.fallback // Use this if first is disabled
863
+ ]
859
864
  });
860
865
  ```
861
866
 
@@ -866,15 +871,15 @@ Use `push_scope()` and `pop_scope()` to manage different keyboard contexts:
866
871
  ```js
867
872
  // App-level keymap (always active)
868
873
  const app_keymap = define_keymap({
869
- 'meta+s,ctrl+s': [app_commands.save],
870
- 'meta+n,ctrl+n': [app_commands.new_document]
874
+ 'meta+s,ctrl+s': [app_commands.save],
875
+ 'meta+n,ctrl+n': [app_commands.new_document]
871
876
  });
872
877
  key_mapper.push_scope(app_keymap);
873
878
 
874
879
  // Document-level keymap (active when editor has focus)
875
880
  const doc_keymap = define_keymap({
876
- 'meta+z,ctrl+z': [document_commands.undo],
877
- 'meta+b,ctrl+b': [document_commands.bold]
881
+ 'meta+z,ctrl+z': [document_commands.undo],
882
+ 'meta+b,ctrl+b': [document_commands.bold]
878
883
  });
879
884
 
880
885
  // When editor gains focus:
@@ -886,7 +891,6 @@ key_mapper.pop_scope();
886
891
 
887
892
  The KeyMapper tries scopes from top to bottom, so push more specific keymaps last.
888
893
 
889
-
890
894
  ## Selection
891
895
 
892
896
  Selections are at the heart of Svedit. There are just three types of selections:
@@ -899,39 +903,39 @@ Selections are at the heart of Svedit. There are just three types of selections:
899
903
 
900
904
  1. **Text Selection**: A text selection spans across a range of characters in a string. E.g. the below example has a collapsed caret at position 1 in a text property 'content'.
901
905
 
902
- ```js
903
- {
904
- type: 'text',
905
- path: ['page_1234', 'body', 0, 'content'],
906
- anchor_offset: 1,
907
- focus_offset: 1
908
- }
909
- ```
906
+ ```js
907
+ {
908
+ type: 'text',
909
+ path: ['page_1234', 'body', 0, 'content'],
910
+ anchor_offset: 1,
911
+ focus_offset: 1
912
+ }
913
+ ```
910
914
 
911
915
  2. **Node Selection**: A node selection spans across a range of nodes inside a node_array. The below example selects the nodes at index 3 and 4.
912
916
 
913
- ```js
914
- {
915
- type: 'node',
916
- path: ['page_1234', 'body'],
917
- anchor_offset: 2,
918
- focus_offset: 4
919
- }
920
- ```
917
+ ```js
918
+ {
919
+ type: 'node',
920
+ path: ['page_1234', 'body'],
921
+ anchor_offset: 2,
922
+ focus_offset: 4
923
+ }
924
+ ```
921
925
 
922
926
  3. **Property Selection**: A property selection addresses one particular property of a node.
923
927
 
924
- ```js
925
- {
926
- type: "property",
927
- path: [
928
- "page_1",
929
- "body",
930
- 11,
931
- "image"
932
- ]
933
- }
934
- ```
928
+ ```js
929
+ {
930
+ type: "property",
931
+ path: [
932
+ "page_1",
933
+ "body",
934
+ 11,
935
+ "image"
936
+ ]
937
+ }
938
+ ```
935
939
 
936
940
  You can access the current selection through `session.selection` anytime. And you can programmatically set the selection using `session.selection = new_selection`.
937
941
 
@@ -953,20 +957,21 @@ A typical node component follows this pattern:
953
957
 
954
958
  ```svelte
955
959
  <script>
956
- import { Node, AnnotatedTextProperty } from 'svedit';
957
- let { path } = $props();
960
+ import { Node, AnnotatedTextProperty } from 'svedit';
961
+ let { path } = $props();
958
962
  </script>
959
963
 
960
964
  <Node {path}>
961
- <div class="my-node">
962
- <AnnotatedTextProperty path={[...path, 'content']} />
963
- </div>
965
+ <div class="my-node">
966
+ <AnnotatedTextProperty path={[...path, 'content']} />
967
+ </div>
964
968
  </Node>
965
969
  ```
966
970
 
967
971
  ### The `<Node>` wrapper
968
972
 
969
973
  Every node component must wrap its content in the `<Node>` component. This wrapper:
974
+
970
975
  - Registers the node with the editor
971
976
  - Handles selection and caret behavior
972
977
  - Provides the foundation for editing interactions
@@ -979,20 +984,17 @@ Svedit provides specialized components for rendering different property types:
979
984
 
980
985
  ```svelte
981
986
  <AnnotatedTextProperty
982
- tag="p"
983
- class="body"
984
- path={[...path, 'content']}
985
- placeholder="Enter text here"
987
+ tag="p"
988
+ class="body"
989
+ path={[...path, 'content']}
990
+ placeholder="Enter text here"
986
991
  />
987
992
  ```
988
993
 
989
994
  **`<NodeArrayProperty>`** - For container properties that hold multiple nodes:
990
995
 
991
996
  ```svelte
992
- <NodeArrayProperty
993
- class="list-items"
994
- path={[...path, 'list_items']}
995
- />
997
+ <NodeArrayProperty class="list-items" path={[...path, 'list_items']} />
996
998
  ```
997
999
 
998
1000
  > **One path = one DOM mount.** Within a single Svedit document, each node path
@@ -1009,9 +1011,9 @@ Svedit provides specialized components for rendering different property types:
1009
1011
 
1010
1012
  ```svelte
1011
1013
  <CustomProperty class="image-wrapper" path={[...path, 'image']}>
1012
- <div contenteditable="false">
1013
- <img src={node.image} alt={node.title.text} />
1014
- </div>
1014
+ <div contenteditable="false">
1015
+ <img src={node.image} alt={node.title.text} />
1016
+ </div>
1015
1017
  </CustomProperty>
1016
1018
  ```
1017
1019
 
@@ -1021,12 +1023,12 @@ Use the Svedit context to access node data:
1021
1023
 
1022
1024
  ```svelte
1023
1025
  <script>
1024
- import { getContext } from 'svelte';
1025
- const svedit = getContext('svedit');
1026
-
1027
- let { path } = $props();
1028
- let node = $derived(svedit.session.get(path));
1029
- let layout = $derived(node.layout || 1);
1026
+ import { getContext } from 'svelte';
1027
+ const svedit = getContext('svedit');
1028
+
1029
+ let { path } = $props();
1030
+ let node = $derived(svedit.session.get(path));
1031
+ let layout = $derived(node.layout || 1);
1030
1032
  </script>
1031
1033
  ```
1032
1034
 
@@ -1036,25 +1038,25 @@ Here's a complete example of a text node component that supports multiple layout
1036
1038
 
1037
1039
  ```svelte
1038
1040
  <script>
1039
- import { getContext } from 'svelte';
1040
- import { Node, AnnotatedTextProperty } from 'svedit';
1041
-
1042
- const svedit = getContext('svedit');
1043
- let { path } = $props();
1044
- let node = $derived(svedit.session.get(path));
1045
- let layout = $derived(node.layout || 1);
1046
- let tag = $derived(layout === 1 ? 'p' : `h${layout - 1}`);
1041
+ import { getContext } from 'svelte';
1042
+ import { Node, AnnotatedTextProperty } from 'svedit';
1043
+
1044
+ const svedit = getContext('svedit');
1045
+ let { path } = $props();
1046
+ let node = $derived(svedit.session.get(path));
1047
+ let layout = $derived(node.layout || 1);
1048
+ let tag = $derived(layout === 1 ? 'p' : `h${layout - 1}`);
1047
1049
  </script>
1048
1050
 
1049
1051
  <Node {path}>
1050
- <div class="text layout-{layout}">
1051
- <AnnotatedTextProperty
1052
- {tag}
1053
- class="body"
1054
- path={[...path, 'content']}
1055
- placeholder="Enter text"
1056
- />
1057
- </div>
1052
+ <div class="text layout-{layout}">
1053
+ <AnnotatedTextProperty
1054
+ {tag}
1055
+ class="body"
1056
+ path={[...path, 'content']}
1057
+ placeholder="Enter text"
1058
+ />
1059
+ </div>
1058
1060
  </Node>
1059
1061
  ```
1060
1062
 
@@ -1064,14 +1066,14 @@ A simple list component that renders child items:
1064
1066
 
1065
1067
  ```svelte
1066
1068
  <script>
1067
- import { Node, NodeArrayProperty } from 'svedit';
1068
- let { path } = $props();
1069
+ import { Node, NodeArrayProperty } from 'svedit';
1070
+ let { path } = $props();
1069
1071
  </script>
1070
1072
 
1071
1073
  <Node {path}>
1072
- <div class="list">
1073
- <NodeArrayProperty path={[...path, 'list_items']} />
1074
- </div>
1074
+ <div class="list">
1075
+ <NodeArrayProperty path={[...path, 'list_items']} />
1076
+ </div>
1075
1077
  </Node>
1076
1078
  ```
1077
1079
 
@@ -1081,17 +1083,17 @@ Node components are registered in the document config's `node_components` map:
1081
1083
 
1082
1084
  ```js
1083
1085
  const session_config = {
1084
- node_components: {
1085
- Text,
1086
- Story,
1087
- List,
1088
- ListItem,
1089
- // ... other components
1090
- }
1091
- }
1086
+ node_components: {
1087
+ text: Text,
1088
+ story: Story,
1089
+ list: List,
1090
+ list_item: ListItem
1091
+ // ... other components
1092
+ }
1093
+ };
1092
1094
  ```
1093
1095
 
1094
- The keys must be PascalCase versions of the snake_case node types in your schema. Svedit converts node types automatically (e.g. `list_item` → `ListItem`, `image_grid` → `ImageGrid`), so a node with `type: "list_item"` will look for a component registered as `ListItem`.
1096
+ The keys must be the snake_case node types in your schema. Svedit looks up node components directly by node type, so a node with `type: "list_item"` will look for a component registered as `list_item`.
1095
1097
 
1096
1098
  ## Mastering contenteditable
1097
1099
 
@@ -1100,8 +1102,8 @@ a simplified version of the markup of `<NodeGap>` and why it is implemented the
1100
1102
 
1101
1103
  ```html
1102
1104
  <div contenteditable="true">
1103
- <div class="some-wrapper">
1104
- <!--
1105
+ <div class="some-wrapper">
1106
+ <!--
1105
1107
  Putting a <br> tag into a div gives you a single addressable caret position.
1106
1108
 
1107
1109
  Adding a &ZeroWidthSpace; (or any character) here will lead to 2 caret
@@ -1113,8 +1115,8 @@ a simplified version of the markup of `<NodeGap>` and why it is implemented the
1113
1115
  Svedit uses this behavior for node gaps, and when an
1114
1116
  <AnnotatedTextProperty> is empty.
1115
1117
  -->
1116
- <div class="node-gap"><br></div>
1117
- <!--
1118
+ <div class="node-gap"><br /></div>
1119
+ <!--
1118
1120
  If you create a contenteditable="false" island, there needs to be some content in it,
1119
1121
  otherwise it will create two additional caret positions. One before, and another one
1120
1122
  after the island.
@@ -1122,8 +1124,8 @@ a simplified version of the markup of `<NodeGap>` and why it is implemented the
1122
1124
  The Svedit demo uses this technique in `<NodeGap>` to create a node-caret
1123
1125
  visualization, that doesn't mess with the contenteditable caret positions.
1124
1126
  -->
1125
- <div contenteditable="false" class="node-caret">&ZeroWidthSpace;</div>
1126
- </div>
1127
+ <div contenteditable="false" class="node-caret">&ZeroWidthSpace;</div>
1128
+ </div>
1127
1129
  </div>
1128
1130
  ```
1129
1131
 
@@ -1145,12 +1147,14 @@ This means the DOM structure is identical in both modes:
1145
1147
  ```html
1146
1148
  <!-- Same structure in edit and read-only mode -->
1147
1149
  <div data-type="node_array">
1148
- <div class="node-gap">...</div><!-- First element is a node-gap -->
1149
- <div data-type="node"><!-- first node --></div>
1150
- <div class="node-gap">...</div>
1151
- <div data-type="node"><!-- second node --></div>
1152
- ...
1153
- <div class="node-gap">...</div><!-- Last element is a node-gap -->
1150
+ <div class="node-gap">...</div>
1151
+ <!-- First element is a node-gap -->
1152
+ <div data-type="node"><!-- first node --></div>
1153
+ <div class="node-gap">...</div>
1154
+ <div data-type="node"><!-- second node --></div>
1155
+ ...
1156
+ <div class="node-gap">...</div>
1157
+ <!-- Last element is a node-gap -->
1154
1158
  </div>
1155
1159
  ```
1156
1160
 
@@ -1163,7 +1167,7 @@ CSS sibling selectors that target node adjacency only need one form:
1163
1167
  ```css
1164
1168
  /* Works in both edit and read-only mode */
1165
1169
  .node-text + .node-gap + .node-media {
1166
- margin-block-start: 1rem;
1170
+ margin-block-start: 1rem;
1167
1171
  }
1168
1172
  ```
1169
1173
 
@@ -1171,18 +1175,18 @@ For `:nth-child` selectors, use the `of <selector>` syntax to skip over `.node-g
1171
1175
 
1172
1176
  ```css
1173
1177
  /* Even nodes */
1174
- .list-node-array > :nth-child(2n of [data-type="node"]) {
1175
- background: var(--zebra-stripe);
1178
+ .list-node-array > :nth-child(2n of [data-type='node']) {
1179
+ background: var(--zebra-stripe);
1176
1180
  }
1177
1181
 
1178
1182
  /* Odd nodes */
1179
- .list-node-array > :nth-child(2n-1 of [data-type="node"]) {
1180
- background: var(--zebra-stripe);
1183
+ .list-node-array > :nth-child(2n-1 of [data-type='node']) {
1184
+ background: var(--zebra-stripe);
1181
1185
  }
1182
1186
 
1183
1187
  /* Last node */
1184
- .list-node-array > :nth-last-child(1 of [data-type="node"]) {
1185
- border-bottom: none;
1188
+ .list-node-array > :nth-last-child(1 of [data-type='node']) {
1189
+ border-bottom: none;
1186
1190
  }
1187
1191
  ```
1188
1192
 
@@ -1196,9 +1200,9 @@ Without the `of` filter, plain `:nth-child(even)` would count `.node-gap` divs a
1196
1200
 
1197
1201
  ```css
1198
1202
  .my-horizontal-layout :global(.grid-items) {
1199
- --row: 1;
1200
- display: flex;
1201
- flex-wrap: wrap;
1203
+ --row: 1;
1204
+ display: flex;
1205
+ flex-wrap: wrap;
1202
1206
  }
1203
1207
  ```
1204
1208
 
@@ -1207,11 +1211,11 @@ Without the `of` filter, plain `:nth-child(even)` would count `.node-gap` divs a
1207
1211
  ```
1208
1212
  Without --node-caret-boundary With --node-caret-boundary
1209
1213
 
1210
- : . . . . . . . . . . . . . . . :
1214
+ : . . . . . . . . . . . . . . . :
1211
1215
  : +---------------------------+ : +-----------------------------+
1212
1216
  : | Toolbar / UI | : ← overlap | Toolbar / UI |
1213
1217
  : +---------------------------+ : +-----------------------------+
1214
- : :
1218
+ : :
1215
1219
  : edge gap (unbounded) : boudary → +-----------------------------+
1216
1220
  : . . . . . . . . . . . . . . . : | : . . . . . . . . . . . . : |
1217
1221
  | : edge gap (clamped) : |
@@ -1227,22 +1231,22 @@ Without --node-caret-boundary With --node-caret-boundary
1227
1231
  | : edge gap (clamped) : |
1228
1232
  : . . . . . . . . . . . . . . . : | : . . . . . . . . . . . . : |
1229
1233
  : edge gap (unbounded) : boudary → +-----------------------------+
1230
- : :
1234
+ : :
1231
1235
  : +---------------------------+ : +-----------------------------+
1232
1236
  : | Footer / UI | : ← overlap | Footer / UI |
1233
1237
  : +---------------------------+ : +-----------------------------+
1234
- : . . . . . . . . . . . . . . . :
1238
+ : . . . . . . . . . . . . . . . :
1235
1239
  ```
1236
1240
 
1237
1241
  Set `--node-caret-boundary` to the `anchor-name` of a parent element to clamp edge gaps to that element's edges:
1238
1242
 
1239
1243
  ```css
1240
1244
  .editor-wrapper {
1241
- anchor-name: --editor-boundary;
1242
- padding: 24px;
1245
+ anchor-name: --editor-boundary;
1246
+ padding: 24px;
1243
1247
  }
1244
- .editor-wrapper [data-type="node_array"] {
1245
- --node-caret-boundary: --editor-boundary;
1248
+ .editor-wrapper [data-type='node_array'] {
1249
+ --node-caret-boundary: --editor-boundary;
1246
1250
  }
1247
1251
  ```
1248
1252
 
@@ -1251,20 +1255,19 @@ When set, edge gaps clamp to the boundary element's edges instead. When not set,
1251
1255
  Since `--node-caret-boundary` inherits to nested node arrays, you may need to unset it on inner containers that should not be clamped:
1252
1256
 
1253
1257
  ```css
1254
- .inner-container [data-type="node_array"] {
1255
- --node-caret-boundary: initial;
1258
+ .inner-container [data-type='node_array'] {
1259
+ --node-caret-boundary: initial;
1256
1260
  }
1257
1261
  ```
1258
1262
 
1259
1263
  For per-axis control, use `--node-caret-boundary-x` (left/right) and `--node-caret-boundary-y` (top/bottom). They take precedence over `--node-caret-boundary` when set:
1260
1264
 
1261
1265
  ```css
1262
- .editor-wrapper [data-type="node_array"] {
1263
- --node-caret-boundary-x: --editor-boundary;
1266
+ .editor-wrapper [data-type='node_array'] {
1267
+ --node-caret-boundary-x: --editor-boundary;
1264
1268
  }
1265
1269
  ```
1266
1270
 
1267
-
1268
1271
  ## Beyond the README
1269
1272
 
1270
1273
  The source code is compact and readable — less than 3000 LOC across a handful of files. We encourage you to explore it. The files in [`src/lib`](./src/lib) are the library code, while the files in [`src/routes`](./src/routes) are example code you can copy and adapt to your needs.