svedit 0.5.0 → 0.6.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.
Files changed (62) hide show
  1. package/README.md +761 -120
  2. package/dist/AnnotatedTextProperty.svelte +122 -75
  3. package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.d.ts +118 -0
  5. package/dist/Command.svelte.d.ts.map +1 -0
  6. package/dist/Command.svelte.js +314 -0
  7. package/dist/CustomProperty.svelte +31 -42
  8. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  9. package/dist/KeyMapper.svelte.d.ts +36 -0
  10. package/dist/KeyMapper.svelte.d.ts.map +1 -0
  11. package/dist/KeyMapper.svelte.js +141 -0
  12. package/dist/Node.svelte +33 -35
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +40 -37
  15. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  16. package/dist/{Document.svelte.d.ts → Session.svelte.d.ts} +76 -83
  17. package/dist/Session.svelte.d.ts.map +1 -0
  18. package/dist/Session.svelte.js +594 -0
  19. package/dist/Svedit.svelte +1352 -1512
  20. package/dist/Svedit.svelte.d.ts.map +1 -1
  21. package/dist/Transaction.svelte.d.ts +114 -40
  22. package/dist/Transaction.svelte.d.ts.map +1 -1
  23. package/dist/Transaction.svelte.js +845 -661
  24. package/dist/UnknownNode.svelte +6 -6
  25. package/dist/UnknownNode.svelte.d.ts.map +1 -1
  26. package/dist/doc_utils.d.ts +127 -0
  27. package/dist/doc_utils.d.ts.map +1 -0
  28. package/dist/doc_utils.js +482 -0
  29. package/dist/index.d.ts +7 -3
  30. package/dist/index.js +20 -4
  31. package/dist/{commands.svelte.d.ts → transforms.svelte.d.ts} +1 -2
  32. package/dist/transforms.svelte.d.ts.map +1 -0
  33. package/dist/transforms.svelte.js +155 -0
  34. package/dist/types.d.ts +256 -110
  35. package/dist/{util.d.ts → utils.d.ts} +22 -2
  36. package/dist/utils.d.ts.map +1 -0
  37. package/dist/utils.js +338 -0
  38. package/package.json +20 -18
  39. package/src/lib/AnnotatedTextProperty.svelte +122 -75
  40. package/src/lib/Command.svelte.js +314 -0
  41. package/src/lib/CustomProperty.svelte +31 -42
  42. package/src/lib/KeyMapper.svelte.js +141 -0
  43. package/src/lib/Node.svelte +33 -35
  44. package/src/lib/NodeArrayProperty.svelte +40 -37
  45. package/src/lib/Session.svelte.js +594 -0
  46. package/src/lib/Svedit.svelte +1352 -1512
  47. package/src/lib/Transaction.svelte.js +845 -661
  48. package/src/lib/UnknownNode.svelte +6 -6
  49. package/src/lib/doc_utils.js +482 -0
  50. package/src/lib/index.js +20 -4
  51. package/src/lib/transforms.svelte.js +155 -0
  52. package/src/lib/types.d.ts +256 -110
  53. package/src/lib/utils.js +338 -0
  54. package/dist/Document.svelte.d.ts.map +0 -1
  55. package/dist/Document.svelte.js +0 -833
  56. package/dist/commands.svelte.d.ts.map +0 -1
  57. package/dist/commands.svelte.js +0 -266
  58. package/dist/util.d.ts.map +0 -1
  59. package/dist/util.js +0 -269
  60. package/src/lib/Document.svelte.js +0 -833
  61. package/src/lib/commands.svelte.js +0 -266
  62. package/src/lib/util.js +0 -269
package/README.md CHANGED
@@ -1,16 +1,12 @@
1
1
  # Svedit
2
2
 
3
- Svedit (think Svelte Edit) is a a tiny library for building editable websites in Svelte. You can model your content in JSON, render it with custom Svelte components, and edit it directly in the layout.
3
+ Svedit (think Svelte Edit) is a tiny library for building editable websites in Svelte. You can model your content in JSON, render it with custom Svelte components, and (this is the kicker) site owners can **edit their site directly in the layout** — no CMS needed.
4
4
 
5
- Try the [demo](https://svedit.vercel.app).
5
+ Try the [demo](https://svedit.dev).
6
6
 
7
7
  ## Why Svedit?
8
8
 
9
- Because Svelte‘s reactivity system is **the perfect fit** for building super-lightweight content editing experiences.
10
-
11
- In fact, they're so lightweight, you can use them to **make webpages in-place editable**, removing the need for an external Content Management System (CMS).
12
-
13
- Svedit just gives you the gluing pieces around **defining a custom document model** and **mapping DOM selections** to the internal model and vice versa.
9
+ Because Svelte‘s reactivity system is **the perfect fit** for building super-lightweight content editing experiences. In fact, they're so lightweight, **your content is your editor** — no context switching between a backend and the live site. Svedit just gives you the gluing pieces around **defining a custom document model** and **mapping DOM selections** to the internal model and vice versa.
14
10
 
15
11
  ## Getting started
16
12
 
@@ -33,27 +29,45 @@ And run the development server:
33
29
  npm run dev
34
30
  ```
35
31
 
36
- Now make it your own. The next thing you probably want to do is define your own [node types](./src/routes/create_demo_doc.js), add a [Toolbar](./src/routes/components/Toolbar.svelte), and render custom [Overlays](./src/routes/components/Overlays.svelte). For that just get inspired by the [Svedit demo code](./src/routes).
32
+ Now make it your own. The next thing you probably want to do is define your own [node types](./src/routes/create_demo_session.js), add a [Toolbar](./src/routes/components/Toolbar.svelte), and render custom [Overlays](./src/routes/components/Overlays.svelte). For that just get inspired by the [Svedit demo code](./src/routes).
37
33
 
38
34
  ## Principles
39
35
 
40
- **Chromeless canvas:** We keep the canvas chromeless, meaning there's no UI elements like toolbars or menus mingled with the content. You can interact with text directly, but everything else happens via tools are shown in separate overlays or in the fixed toolbar.
41
-
42
- **Convention over configuration:** We use conventions and assumptions to reduce configuration code and limit the number of ways something can go wrong. For instance, we assume that a node with a property named `content` of type `annotated_text` is considered kind `text`, while all other nodes are considered kind `node`. Text nodes have special behavior in the system for editing (e.g. they can be splitted and joined).
36
+ **Simplicity over completeness:** Svedit doesn't guess what your app needs or offer ready-made blocks. Instead, we keep the core lean and provide carefully crafted examples showing how to build anything on top — without compromising flexibility.
43
37
 
44
38
  **White-box library:** We expose the internals of the library to allow you to customize and extend it to your needs. That means a little bit more work upfront, but in return lets you control "everything" — the toolbar, the overlays, or how fast the node cursor blinks.
45
39
 
46
- ## Graph data model
40
+ **Chromeless canvas:** Svedit keeps the editing canvas chromeless, meaning there are no UI elements like toolbars or menus mingled with the content. You can interact with text directly, but everything else happens via tools shown in separate overlays or in the fixed toolbar.
41
+
42
+ ## How it works
43
+
44
+ Svedit connects eight key pieces:
47
45
 
48
- Svedit documents are represented in a simple JSON-based graph data model. There's a globally addressable space, a graph of content nodes if you want. This allows you to share pieces of content not only in the same document, but across multiple documents. E.g. you could share a navigation bar, while still being able to edit it in place (while changes will affect all places they are used).
46
+ 1. **Schema** - Define your content structure (node types, properties, annotations)
47
+ 2. **Document** - An actual document, containing a `document_id` and a flat map of nodes that hold the content
48
+ 3. **Session** - Manages the document, selection state, and history
49
+ 4. **Transaction** - Groups multiple document operations (create, delete, set) into a single atomic unit with undo/redo support
50
+ 5. **Transforms** - Higher-level composable functions that run inside a transaction (e.g., `break_text_node`, `join_text_node`) to modify the document.
51
+ 6. **Config** - Maps node types to components, provides inserters and commands
52
+ 7. **Components** - Render your content using Svelte (one component per node type)
53
+ 8. **Commands** - User actions (bold text, insert node, undo/redo) that modify the session
49
54
 
50
- ## Schema definitions
55
+ **The flow:**
56
+ - Define a schema → create a session → provide config → render with `<Svedit>` component
57
+ - User interactions trigger commands → commands create transactions → which run transforms to modify the document → session applies the transaction → Svelte's reactivity updates the UI
58
+ - Selection state syncs bidirectionally with the DOM
59
+
60
+ ## Schema
51
61
 
52
62
  You can use a simple JSON-compatible schema definition language to enforce constraints on your documents. E.g. to make sure a page node always has a property body with references to nodes that are allowed within a page.
53
63
 
54
64
  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.
55
65
 
56
- A top-level node that is accessible via a route we internally call a `document` (e.g. a page, event, etc.)
66
+ Each node has a `kind` that determines its behavior:
67
+ - `document`: A top-level node accessible via a route (e.g. a page, event)
68
+ - `block`: A structured node that contains other nodes or properties
69
+ - `text`: A node with editable text content (can be split and joined)
70
+ - `annotation`: An inline annotation applied to text (bold, link, etc.)
57
71
 
58
72
  Properties of nodes can hold values:
59
73
  - `string`: A good old JavaScript string
@@ -124,138 +138,622 @@ const document_schema = {
124
138
  };
125
139
  ```
126
140
 
141
+ ## Document
127
142
 
128
- ## Document serialization format (to drive an editing session in the browser)
129
-
130
- A document is just a subsets of nodes, with a few rules:
143
+ A document is a plain JavaScript object (POJO) with a `document_id` (the entry point) and a `nodes` object containing all content nodes.
131
144
 
132
- - there must be a node (the document node) with the id of the document as an entry point (e.g. page_1)
133
- - so the document is a node itself, with references to the underlying content (which live in separate nodes)
134
- - all other nodes need to be traversible from that root node (unlinked nodes will be discarded on a save)
135
- - in the serialization format the nodes need to be ordered, so that nodes that are referenced, are already defined (makes it easier to initialize the document)
136
- - Your document must not contain cyclic references for that reason
145
+ Rules:
146
+ - All nodes must be reachable from the document node (unreachable nodes are discarded)
147
+ - No cyclic references allowed
148
+ - Text content uses `{ text: '', annotations: [] }` format
137
149
 
138
150
  Here's an example document:
151
+
139
152
  ```js
140
- const serialized_doc = [
141
- {
142
- id: 'nav_item_1',
143
- type: 'nav_item',
144
- url: '/homepage',
145
- label: 'Home',
146
- },
147
- {
148
- id: 'nav_1',
149
- type: 'nav',
150
- nav_items: ['nav_item_1'],
151
- },
152
- {
153
- id: 'paragraph_1',
154
- content: ['Hello world.', []],
155
- },
156
- {
157
- id: 'list_item_1',
158
- type: 'list_item',
159
- content: ['first list item', []],
160
- },
161
- {
162
- id: 'list_item_2',
163
- type: 'list_item',
164
- content: ['second list item', []],
165
- },
166
- {
167
- id: 'list_1',
168
- type: 'list',
169
- list_items: ['list_item_1', 'list_item_2'],
170
- },
171
- {
172
- id: 'page_1',
173
- type: 'page',
174
- body: ['nav_1', 'paragraph_1', 'list_1'],
175
- },
176
- ]
153
+ const doc = {
154
+ document_id: 'page_1',
155
+ nodes: {
156
+ nav_item_1: {
157
+ id: 'nav_item_1',
158
+ type: 'nav_item',
159
+ url: '/homepage',
160
+ label: 'Home'
161
+ },
162
+ nav_1: {
163
+ id: 'nav_1',
164
+ type: 'nav',
165
+ nav_items: ['nav_item_1']
166
+ },
167
+ paragraph_1: {
168
+ id: 'paragraph_1',
169
+ type: 'text',
170
+ layout: 1,
171
+ content: { text: 'Hello world.', annotations: [] }
172
+ },
173
+ list_item_1: {
174
+ id: 'list_item_1',
175
+ type: 'list_item',
176
+ content: { text: 'First list item', annotations: [] }
177
+ },
178
+ list_item_2: {
179
+ id: 'list_item_2',
180
+ type: 'list_item',
181
+ content: { text: 'Second list item', annotations: [] }
182
+ },
183
+ list_1: {
184
+ id: 'list_1',
185
+ type: 'list',
186
+ list_items: ['list_item_1', 'list_item_2']
187
+ },
188
+ page_1: {
189
+ id: 'page_1',
190
+ type: 'page',
191
+ body: ['nav_1', 'paragraph_1', 'list_1']
192
+ }
193
+ }
194
+ };
177
195
  ```
178
196
 
179
- ## Configuration
197
+ ## Config
180
198
 
181
- For Svedit to work, you also need to provide an app-specific config object, always available via doc.config for introspection.
199
+ Documents need a config object that tells Svedit how to render and manipulate your content. See the full example in [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js).
182
200
 
183
201
  ```js
184
- const document_config = {
185
- // Provide a custom id generator (ideally a UUID to avoid collisions)
186
- generate_id: function() {
187
- return nanoid();
202
+ const session_config = {
203
+ // ID generator for creating new nodes
204
+ generate_id: () => nanoid(),
205
+
206
+ // System components (NodeCursorTrap, Overlays)
207
+ system_components: { NodeCursorTrap, Overlays },
208
+
209
+ // Map node types to Svelte components
210
+ node_components: { Page, Text, Story, List, Button, ... },
211
+
212
+ // Functions that create and insert new nodes
213
+ inserters: {
214
+ text: (tr, content = {text: '', annotations: []}) => {
215
+ const text_id = nanoid();
216
+ tr.create({ id: text_id, type: 'text', content });
217
+ tr.insert_nodes([text_id]);
218
+ }
188
219
  },
189
- system_components: {
190
- NodeCursorTrap,
191
- Overlay,
220
+
221
+ // Returns { commands, keymap } for the editor instance
222
+ create_commands_and_keymap: (context) => { ... },
223
+
224
+ // Optional: handle image paste events
225
+ handle_image_paste: (session, images) => { ... }
226
+ };
227
+ ```
228
+
229
+ **Key config options:**
230
+
231
+ - **`generate_id`** - Function that generates unique IDs for new nodes
232
+ - **`node_components`** - Maps each node type from your schema to a Svelte component
233
+ - **`system_components`** - Provides custom NodeCursorTrap and Overlays components
234
+ - **`inserters`** - Functions that create blank nodes of each type and set up the selection
235
+ - **`create_commands_and_keymap`** - Factory function that creates commands and keybindings for an editor instance
236
+ - **`handle_image_paste`** - Optional handler for image paste events
237
+
238
+ The config is accessible throughout your app via `session.config`.
239
+
240
+ ## Session
241
+
242
+ The `Session` class manages your content graph, selection state, and history. See [`src/lib/Session.svelte.js`](src/lib/Session.svelte.js) for the full API.
243
+
244
+ ### Immutable state
245
+
246
+ Document content (`session.doc`) and selection (`session.selection`) are **immutable** with **copy-on-write** semantics. When a change is made, only the modified parts are copied — unchanged nodes keep their original references. This avoids the overhead of reactive proxies (using Svelte's `$state.raw`) since state is reassigned rather than mutated. Also, `console.log(session.get(some_node_id))` gives you a readable raw object, not a proxy.
247
+
248
+ ### Creating a session
249
+
250
+ ```js
251
+ import { Session } from 'svedit';
252
+
253
+ const session = new Session(schema, doc, config);
254
+ ```
255
+
256
+ ### Reading the graph
257
+
258
+ ```js
259
+ session.get(['page_1', 'body']) // => ['nav_1', 'paragraph_1', 'list_1']
260
+ session.get(['nav_1']) // => { id: 'nav_1', type: 'nav', ... }
261
+ session.get('nav_1') // => shorthand for above (single node ID)
262
+ session.inspect(['page_1', 'body']) // => { kind: 'property', type: 'node_array', node_types: [...] }
263
+ session.kind(node) // => 'text', 'block', or 'annotation'
264
+ ```
265
+
266
+ ### Selection and state
267
+
268
+ ```js
269
+ session.selection // Current selection (text, node, or property)
270
+ session.selected_node // The currently selected node (derived)
271
+ session.active_annotation('strong') // Check if annotation is active at cursor
272
+ session.can_insert('paragraph') // Check if node type can be inserted
273
+ session.available_annotation_types // Annotation types allowed at current selection (derived)
274
+ ```
275
+
276
+ ### Making changes
277
+
278
+ ```js
279
+ const tr = session.tr; // Create a transaction
280
+ tr.set(['nav_1', 'label'], 'Home');
281
+ tr.insert_nodes(['new_node_id']);
282
+ session.apply(tr); // Apply the transaction
283
+ ```
284
+
285
+ ### History
286
+
287
+ ```js
288
+ session.can_undo // Boolean (derived)
289
+ session.can_redo // Boolean (derived)
290
+ session.undo()
291
+ session.redo()
292
+ ```
293
+
294
+ ### Detecting unsaved changes
295
+
296
+ Because document state is immutable, you can detect unsaved changes by comparing references. When a change is made, `session.doc` gets a new reference — unchanged documents keep the same reference.
297
+
298
+ ```js
299
+ let last_saved_doc = $state(null);
300
+ let has_unsaved_changes = $derived.by(() => {
301
+ if (!last_saved_doc) {
302
+ // No save yet — use undo history as indicator
303
+ return session.can_undo;
304
+ } else {
305
+ // Compare current doc reference against last saved
306
+ return last_saved_doc !== session.doc;
307
+ }
308
+ });
309
+
310
+ function save() {
311
+ // ... save to server ...
312
+ last_saved_doc = session.doc;
313
+ }
314
+ ```
315
+
316
+ This works because of Svedit's copy-on-write strategy: only modified parts of the document are copied, so reference equality is a reliable and efficient way to detect changes. You can use `has_unsaved_changes` to show/hide a save button, display a dirty indicator, or warn before navigating away.
317
+
318
+ ### Utilities
319
+
320
+ ```js
321
+ session.doc.document_id // The document's root ID
322
+ session.generate_id() // Generate a new unique ID
323
+ session.config // Access the config object
324
+ session.validate_doc() // Validate all nodes against schema
325
+ session.traverse(node_id) // Get all nodes reachable from a node
326
+ session.select_parent() // Select parent of current selection
327
+ ```
328
+
329
+ ## Transforms
330
+
331
+ Transforms are pure functions that modify a transaction. They encapsulate common editing operations like breaking text nodes, joining nodes, or inserting new content.
332
+
333
+ Transforms take a transaction (`tr`) as their parameter and return `true` if successful or `false` if the transform cannot be applied (e.g., wrong selection type or invalid state).
334
+
335
+ ```js
336
+ // Example: break a text node at the cursor
337
+ import { break_text_node } from 'svedit';
338
+
339
+ const tr = session.tr;
340
+ const success = break_text_node(tr);
341
+ if (success) {
342
+ session.apply(tr);
343
+ }
344
+ ```
345
+
346
+ ### Built-in transforms
347
+
348
+ Svedit provides several core transforms in [`src/lib/transforms.svelte.js`](src/lib/transforms.svelte.js):
349
+
350
+ - `break_text_node(tr)` - Split a text node at the cursor position
351
+ - `join_text_node(tr)` - Join current text node with the previous one
352
+ - `insert_default_node(tr)` - Insert a new node at the current selection
353
+
354
+ ### Composability
355
+
356
+ Transforms are composable. You can build higher-level transforms from lower-level ones:
357
+
358
+ ```js
359
+ function custom_transform(tr) {
360
+ // Compose multiple transforms
361
+ if (!break_text_node(tr)) return false;
362
+ if (!insert_default_node(tr)) return false;
363
+ return true;
364
+ }
365
+ ```
366
+
367
+ ### Writing your own transforms
368
+
369
+ You're encouraged to write custom transforms for your application's specific needs. Keep them pure functions that operate on the transaction object:
370
+
371
+ ```js
372
+ function insert_heading(tr) {
373
+ const selection = tr.selection;
374
+
375
+ if (selection?.type !== 'node') return false;
376
+
377
+ // Create and insert a heading node
378
+ const heading_id = tr.generate_id();
379
+ tr.create({ id: heading_id, type: 'heading', content: { text: '', annotations: [] } });
380
+ tr.insert_nodes(selection.path, selection.anchor_offset, [heading_id]);
381
+
382
+ return true;
383
+ }
384
+ ```
385
+
386
+ ## Transaction
387
+
388
+ Transactions group multiple operations into atomic units that can be applied and undone as one. They provide the same read API as sessions (`tr.get()`, `tr.inspect()`, `tr.kind()`, `tr.generate_id()`), so transforms can query document state directly. See [`src/lib/Transaction.svelte.js`](src/lib/Transaction.svelte.js) for the full API.
389
+
390
+ ### Basic usage
391
+
392
+ ```js
393
+ const tr = session.tr; // Create a new transaction
394
+ tr.set(['node_1', 'title'], 'New Title'); // Modify properties
395
+ session.apply(tr); // Apply atomically
396
+ ```
397
+
398
+ ### Node operations
399
+
400
+ ```js
401
+ // Create a new node (must include all required properties from schema)
402
+ tr.create({ id: 'paragraph_1', type: 'paragraph', content: { text: '', annotations: [] } });
403
+
404
+ // Delete a node (cascades to unreferenced child nodes)
405
+ tr.delete('paragraph_26');
406
+
407
+ // Insert nodes at current node selection
408
+ tr.insert_nodes(['paragraph_1', 'list_1']);
409
+
410
+ // Build a subgraph from existing nodes (generates new IDs)
411
+ const new_node_id = tr.build('the_list', {
412
+ first_item: {
413
+ id: 'first_item',
414
+ type: 'list_item',
415
+ content: node.content
416
+ }
417
+ the_list: {
418
+ id: 'the_list',
419
+ type: 'list',
420
+ list_items: ['first_item']
421
+ }
422
+ });
423
+ ```
424
+
425
+ ### Text operations
426
+
427
+ ```js
428
+ // Insert text at cursor (replaces selection if expanded)
429
+ tr.insert_text('Hello');
430
+
431
+ // Toggle annotation on selected text
432
+ tr.annotate_text('strong');
433
+ tr.annotate_text('link', { href: 'https://example.com' });
434
+
435
+ // Delete selected text or nodes
436
+ tr.delete_selection();
437
+ ```
438
+
439
+ ### Selection
440
+
441
+ ```js
442
+ // Set the selection after operations
443
+ tr.set_selection({
444
+ type: 'text',
445
+ path: ['node_1', 'content'],
446
+ anchor_offset: 0,
447
+ focus_offset: 5
448
+ });
449
+ ```
450
+
451
+ All transaction methods return `this` for chaining:
452
+
453
+ ```js
454
+ tr.create(node)
455
+ .insert_nodes([node.id])
456
+ .set_selection(new_selection);
457
+ ```
458
+
459
+ ## Commands
460
+
461
+ Commands provide a structured way to implement user actions. Commands are stateful and UI-aware, unlike transforms which are pure functions.
462
+
463
+ There are two types of commands in Svedit:
464
+ - **Document-scoped commands** - Bound to a specific Svedit instance/document and only active when that editor has focus
465
+ - **App-level commands** - Operate at the application level, independent of any specific document
466
+
467
+ Let's start with document-scoped commands, which are the foundation of the editing experience.
468
+
469
+ ### Document-scoped commands
470
+
471
+ Document-scoped commands operate on a specific document and have access to its selection, content, and editing state through a context object.
472
+
473
+ #### Creating a document-scoped command
474
+
475
+ Extend the `Command` base class and implement the `is_enabled()` and `execute()` methods:
476
+
477
+ ```js
478
+ import { Command } from 'svedit';
479
+
480
+ class ToggleStrongCommand extends Command {
481
+ is_enabled() {
482
+ return this.context.editable && this.context.session.selection?.type === 'text';
483
+ }
484
+
485
+ execute() {
486
+ this.context.session.apply(this.context.session.tr.annotate_text('strong'));
487
+ }
488
+ }
489
+ ```
490
+
491
+ #### Document command context
492
+
493
+ Document-scoped commands receive a `context` object with access to the Svedit instance's state:
494
+
495
+ - `context.session` - The current session instance
496
+ - `context.editable` - Whether the editor is in edit mode
497
+ - `context.canvas_el` - The DOM element of the Svedit editor canvas
498
+ - `context.is_composing` - Whether IME composition is currently taking place
499
+
500
+ ### Command lifecycle methods
501
+
502
+ **`is_enabled(): boolean`**
503
+
504
+ Determines if the command can currently be executed. This is automatically evaluated and exposed as the `disabled` derived property, which can be used to disable UI elements.
505
+
506
+ ```js
507
+ is_enabled() {
508
+ return this.context.editable && this.context.session.selection?.type === 'text';
509
+ }
510
+ ```
511
+
512
+ **`execute(): void | Promise<void>`**
513
+
514
+ Executes the command's action. Can be synchronous or asynchronous.
515
+
516
+ ```js
517
+ execute() {
518
+ const tr = this.context.session.tr;
519
+ tr.insert_text('Hello');
520
+ this.context.session.apply(tr);
521
+ }
522
+ ```
523
+
524
+ #### Built-in document commands
525
+
526
+ Svedit provides several [core commands](src/lib/Command.svelte.js) out of the box:
527
+
528
+ - `UndoCommand` - Undo the last change
529
+ - `RedoCommand` - Redo the last undone change
530
+ - `SelectParentCommand` - Select the parent of the current selection
531
+ - `ToggleAnnotationCommand` - Toggle text annotations (bold, italic, etc.)
532
+ - `AddNewLineCommand` - Insert newline character in text
533
+ - `BreakTextNodeCommand` - Split text node at cursor
534
+ - `SelectAllCommand` - Progressively expand selection
535
+ - `InsertDefaultNodeCommand` - Insert a new node at cursor
536
+
537
+ #### Using document commands
538
+
539
+ Commands are created by passing them a context object from the Svedit component. See a complete example in [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js) in the `create_commands_and_keymap` configuration function:
540
+
541
+ ```js
542
+ create_commands_and_keymap: (context) => {
543
+ const commands = {
544
+ undo: new UndoCommand(context),
545
+ redo: new RedoCommand(context),
546
+ toggle_strong: new ToggleAnnotationCommand('strong', context),
547
+ toggle_emphasis: new ToggleAnnotationCommand('emphasis', context),
548
+ // ... more commands
549
+ };
550
+
551
+ const keymap = define_keymap({
552
+ 'meta+z,ctrl+z': [commands.undo],
553
+ 'meta+b,ctrl+b': [commands.toggle_strong],
554
+ // ... more keybindings
555
+ });
556
+
557
+ return { commands, keymap };
558
+ }
559
+ ```
560
+
561
+ Bind commands to UI elements in your components:
562
+
563
+ ```svelte
564
+ <button
565
+ disabled={document_commands.toggle_strong.disabled}
566
+ class:active={document_commands.toggle_strong.active}
567
+ onclick={() => document_commands.toggle_strong.execute()}>
568
+ Bold
569
+ </button>
570
+ ```
571
+
572
+ #### Derived state in commands
573
+
574
+ Commands can have derived state for reactive UI binding. The `active` property in toggle commands is a common pattern:
575
+
576
+ ```js
577
+ class ToggleEmphasisCommand extends Command {
578
+ // Automatically recomputes when annotation state changes
579
+ active = $derived(this.context.session.active_annotation('emphasis'));
580
+
581
+ is_enabled() {
582
+ return this.context.editable && this.context.session.selection?.type === 'text';
583
+ }
584
+
585
+ execute() {
586
+ this.context.session.apply(this.context.session.tr.annotate_text('emphasis'));
587
+ }
588
+ }
589
+ ```
590
+
591
+ The `disabled` property is automatically derived from `is_enabled()` on all commands.
592
+
593
+
594
+ #### DOM access in commands
595
+
596
+ Commands can access the DOM through the context or global APIs:
597
+
598
+ ```js
599
+ class FocusNextSelectableCommand extends Command {
600
+ execute() {
601
+ const selectables = this.context.canvas_el.querySelectorAll('.svedit-selectable');
602
+ const next = selectables[0]; // Find next based on current selection
603
+ const path = next.closest('[data-path]').dataset.path.split('.');
604
+ this.context.session.selection = { type: 'text', path, anchor_offset: 0, focus_offset: 0 };
605
+ }
606
+ }
607
+ ```
608
+
609
+ ### App-level commands and scope hierarchy
610
+
611
+ While document-scoped commands operate on a specific Svedit instance, app-level commands operate at the application level and handle concerns like saving, loading, switching between edit/view modes, or managing multiple documents.
612
+
613
+ #### Understanding the scope stack
614
+
615
+ Svedit uses a scope hierarchy (scope stack) to manage which commands are active at any given time:
616
+
617
+ 1. **App-level scope** (top level) - Commands that are always available, independent of document focus
618
+ 2. **Document-level scope** (per Svedit instance) - Commands bound to a specific document/editor
619
+
620
+ When a Svedit instance gains focus:
621
+ - The previous document's scope is **popped** from the stack (its commands become inactive)
622
+ - The newly focused document's scope is **pushed** onto the stack (its commands become active)
623
+
624
+ This means commands automatically work with the correct document based on focus.
625
+
626
+ #### Creating app-level commands
627
+
628
+ App-level commands have their own context, separate from any specific document:
629
+
630
+ ```js
631
+ import { Command } from 'svedit';
632
+
633
+ class SaveCommand extends Command {
634
+ is_enabled() {
635
+ return this.context.editable;
636
+ }
637
+
638
+ async execute() {
639
+ await this.context.save_all_documents();
640
+ this.context.show_notification('All changes saved');
641
+ }
642
+ }
643
+
644
+ class ToggleEditModeCommand extends Command {
645
+ is_enabled() {
646
+ return !this.context.editable;
647
+ }
648
+
649
+ execute() {
650
+ this.context.editable = true;
651
+ }
652
+ }
653
+ ```
654
+
655
+ #### App-level context
656
+
657
+ The app-level context contains application-wide state and methods:
658
+
659
+ ```js
660
+ const app_context = {
661
+ get editable() {
662
+ return editable; // App-level editable state
192
663
  },
193
- node_components: {
194
- Page,
195
- Button,
196
- Text,
197
- Story,
198
- List,
199
- ListItem,
200
- ImageGrid,
201
- ImageGridItem,
202
- Hero
664
+ set editable(value) {
665
+ editable = value;
203
666
  },
204
- // TEMPORARY: Determines how many layouts are available for each node type
205
- node_layouts: {
206
- text: 4,
207
- list: 5,
208
- list_item: 1,
667
+ get session() {
668
+ return session;
209
669
  },
210
- // Custom functions to insert new "blank" nodes and setting the selection
211
- // depending on the intended behavior.
212
- inserters: {
213
- text: function(tr, content = {text: '', annotations: []}, layout = 1) {
214
- const new_text = {
215
- id: nanoid(),
216
- type: 'text',
217
- layout,
218
- content
219
- };
220
- tr.create(new_text);
221
- tr.insert_nodes([new_text.id]);
222
- },
670
+ get app_el() {
671
+ return app_el;
223
672
  }
224
673
  };
674
+
675
+ const app_commands = {
676
+ save: new SaveCommand(app_context),
677
+ toggle_edit: new ToggleEditCommand(app_context)
678
+ };
225
679
  ```
226
680
 
227
- ## Document API
681
+ ## Scope-aware keyboard shortcuts
228
682
 
229
- The Document API is central to Svedit. First you need to create a Document instance.
683
+ The KeyMapper manages keyboard shortcuts using a scope-based stack system. Scopes are tried from top to bottom (most recent to least recent), allowing more specific keymaps to override general ones.
684
+
685
+ ### Basic usage
230
686
 
231
687
  ```js
232
- const doc = new Document(document_schema, serialized_doc, { config: document_config });
688
+ import { KeyMapper, define_keymap } from 'svedit';
689
+
690
+ const key_mapper = new KeyMapper();
691
+
692
+ // Define a keymap
693
+ const keymap = define_keymap({
694
+ 'meta+z,ctrl+z': [document_commands.undo],
695
+ 'meta+b,ctrl+b': [document_commands.bold],
696
+ 'enter': [document_commands.break_text_node]
697
+ });
698
+
699
+ // Push the keymap onto the scope stack
700
+ key_mapper.push_scope(keymap);
701
+
702
+ // Handle keydown events
703
+ window.addEventListener('keydown', (event) => {
704
+ key_mapper.handle_keydown(event);
705
+ });
233
706
  ```
234
707
 
235
- To read/traverse and write the document graph:
708
+ ### Key syntax
709
+
710
+ - **Multiple modifiers**: `meta+shift+z`, `ctrl+alt+k`
711
+ - **Cross-platform**: `meta+z,ctrl+z` (tries Meta+Z first, then Ctrl+Z)
712
+ - **Modifiers**: `meta`, `ctrl`, `alt`, `shift`
713
+ - **Keys**: Any key name (e.g., `a`, `enter`, `escape`, `arrowup`)
714
+
715
+ ### Command arrays
716
+
717
+ Commands are wrapped in arrays to support fallback behavior:
236
718
 
237
719
  ```js
238
- // get the body (=array of node ids)
239
- const body = doc.get(['page_1', 'body']); // => ['nav_1', 'paragraph_1', 'list_1']
240
- console.log($state.snapshot(body));
241
- const nav = doc.get(['nav_1']) // => { id: 'nav_1', type: 'nav', nav_items: ['document_nav_item_1'] }
720
+ define_keymap({
721
+ 'meta+b,ctrl+b': [
722
+ document_commands.bold, // Try this first
723
+ document_commands.fallback // Use this if first is disabled
724
+ ]
725
+ });
242
726
  ```
243
727
 
244
- ## Transaction API
728
+ ### Scope stack
245
729
 
246
- Documents need to be changed through transactions, which can consist of one or
247
- multiple ops that are applied in a single step and are undo/redo-able.
730
+ Use `push_scope()` and `pop_scope()` to manage different keyboard contexts:
248
731
 
249
732
  ```js
250
- console.log('nav.nav_items before:', $state.snapshot(nav.nav_items));
251
- const tr = doc.tr; // creates a new transaction
252
- const new_nav_items = nav.nav_items.slice(0, -1);
253
- tr.set(['nav_1', 'nav_items'], new_nav_items);
254
- doc.apply(tr); // applies the transaction to the document
255
- console.log('nav.nav_items after:', $state.snapshot(nav.nav_items));
733
+ // App-level keymap (always active)
734
+ const app_keymap = define_keymap({
735
+ 'meta+s,ctrl+s': [app_commands.save],
736
+ 'meta+n,ctrl+n': [app_commands.new_document]
737
+ });
738
+ key_mapper.push_scope(app_keymap);
739
+
740
+ // Document-level keymap (active when editor has focus)
741
+ const doc_keymap = define_keymap({
742
+ 'meta+z,ctrl+z': [document_commands.undo],
743
+ 'meta+b,ctrl+b': [document_commands.bold]
744
+ });
745
+
746
+ // When editor gains focus:
747
+ key_mapper.push_scope(doc_keymap);
748
+
749
+ // When editor loses focus:
750
+ key_mapper.pop_scope();
256
751
  ```
257
752
 
258
- ## Selections
753
+ The KeyMapper tries scopes from top to bottom, so push more specific keymaps last.
754
+
755
+
756
+ ## Selection
259
757
 
260
758
  Selections are at the heart of Svedit. There are just three types of selections:
261
759
 
@@ -295,16 +793,156 @@ Selections are at the heart of Svedit. There are just three types of selections:
295
793
  }
296
794
  ```
297
795
 
298
- You can access the current selection through `doc.selection` anytime. And you can programmatically set the selection using `doc.selection = new_selection`.
796
+ You can access the current selection through `session.selection` anytime. And you can programmatically set the selection using `session.selection = new_selection`.
299
797
 
300
798
  ## Rendering
301
799
 
302
800
  Now you can start making your Svelte pages in-place editable by wrapping your design inside the `<Svedit>` component.
303
801
 
802
+ ```svelte
803
+ <Svedit {session} path={[session.doc.document_id]} editable={true} />
804
+ ```
805
+
806
+ ## Node components
807
+
808
+ Node components are Svelte components that render specific node types in your document. Each node component receives a `path` prop and uses the `<Node>` wrapper component along with property components to render the node's content.
809
+
810
+ ### Basic structure
811
+
812
+ A typical node component follows this pattern:
813
+
814
+ ```svelte
815
+ <script>
816
+ import { Node, AnnotatedTextProperty } from 'svedit';
817
+ let { path } = $props();
818
+ </script>
819
+
820
+ <Node {path}>
821
+ <div class="my-node">
822
+ <AnnotatedTextProperty path={[...path, 'content']} />
823
+ </div>
824
+ </Node>
825
+ ```
826
+
827
+ ### The `<Node>` wrapper
828
+
829
+ Every node component must wrap its content in the `<Node>` component. This wrapper:
830
+ - Registers the node with the editor
831
+ - Handles selection and cursor behavior
832
+ - Provides the foundation for editing interactions
833
+
834
+ ### Property components
835
+
836
+ Svedit provides specialized components for rendering different property types:
837
+
838
+ **`<AnnotatedTextProperty>`** - For editable text content with inline formatting:
839
+
840
+ ```svelte
841
+ <AnnotatedTextProperty
842
+ tag="p"
843
+ class="body"
844
+ path={[...path, 'content']}
845
+ placeholder="Enter text here"
846
+ />
847
+ ```
848
+
849
+ **`<NodeArrayProperty>`** - For container properties that hold multiple nodes:
850
+
851
+ ```svelte
852
+ <NodeArrayProperty
853
+ class="list-items"
854
+ path={[...path, 'list_items']}
855
+ />
856
+ ```
857
+
858
+ **`<CustomProperty>`** - For custom properties like images or other non-text content:
859
+
860
+ ```svelte
861
+ <CustomProperty class="image-wrapper" path={[...path, 'image']}>
862
+ <div contenteditable="false">
863
+ <img src={node.image} alt={node.title.text} />
864
+ </div>
865
+ </CustomProperty>
866
+ ```
867
+
868
+ ### Accessing node data
869
+
870
+ Use the Svedit context to access node data:
871
+
872
+ ```svelte
873
+ <script>
874
+ import { getContext } from 'svelte';
875
+ const svedit = getContext('svedit');
876
+
877
+ let { path } = $props();
878
+ let node = $derived(svedit.session.get(path));
879
+ let layout = $derived(node.layout || 1);
880
+ </script>
881
+ ```
882
+
883
+ ### Example: Text component
884
+
885
+ Here's a complete example of a text node component that supports multiple layouts:
886
+
887
+ ```svelte
888
+ <script>
889
+ import { getContext } from 'svelte';
890
+ import { Node, AnnotatedTextProperty } from 'svedit';
891
+
892
+ const svedit = getContext('svedit');
893
+ let { path } = $props();
894
+ let node = $derived(svedit.session.get(path));
895
+ let layout = $derived(node.layout || 1);
896
+ let tag = $derived(layout === 1 ? 'p' : `h${layout - 1}`);
897
+ </script>
898
+
899
+ <Node {path}>
900
+ <div class="text layout-{layout}">
901
+ <AnnotatedTextProperty
902
+ {tag}
903
+ class="body"
904
+ path={[...path, 'content']}
905
+ placeholder="Enter text"
906
+ />
907
+ </div>
908
+ </Node>
909
+ ```
910
+
911
+ ### Example: List component
912
+
913
+ A simple list component that renders child items:
914
+
915
+ ```svelte
916
+ <script>
917
+ import { Node, NodeArrayProperty } from 'svedit';
918
+ let { path } = $props();
919
+ </script>
920
+
921
+ <Node {path}>
922
+ <div class="list">
923
+ <NodeArrayProperty path={[...path, 'list_items']} />
924
+ </div>
925
+ </Node>
926
+ ```
927
+
928
+ ### Registering node components
929
+
930
+ Node components are registered in the document config's `node_components` map:
931
+
304
932
  ```js
305
- <Svedit {doc} path={[doc.document_id]} editable={true} />
933
+ const session_config = {
934
+ node_components: {
935
+ Text,
936
+ Story,
937
+ List,
938
+ ListItem,
939
+ // ... other components
940
+ }
941
+ }
306
942
  ```
307
943
 
944
+ The key in this map corresponds to the node's `type` property in the schema. Note that the component name should match the node type name. For example, a node with `type: "list_item"` will look for a component registered as `ListItem` in the `node_components` map.
945
+
308
946
  ## Mastering contenteditable
309
947
 
310
948
  Svedit relies on the contenteditable attribute to make elements editable. The below example shows you
@@ -319,6 +957,9 @@ a simplified version of the markup of `<NodeCursorTrap>` and why it is implement
319
957
  Adding a &ZeroWidthSpace; (or any character) here will lead to 2 cursor
320
958
  positions (one before, and one after the character)
321
959
 
960
+ Using <wbr> will make it only addressable for ArrowLeft and ArrowRight, but not ArrowUp and ArrowDown.
961
+ And using <span></span> will not make it addressable at all.
962
+
322
963
  Svedit uses this behavior for node-cursor-traps, and when an
323
964
  <AnnotatedTextProperty> is empty.
324
965
  -->
@@ -341,11 +982,11 @@ Further things to consider:
341
982
  - If you make a sub-tree `contenteditable="false"`, be aware that you can't create a `contenteditable="true"` segment somewhere inside it. Svedit can only work reliably when there's one contenteditable="true" at root (it's set by `<Svedit`>)
342
983
  - `<AnnotatedTextProperty>` and `<CustomProperty>` must not be wrapped in `contenteditable="false"` to work properly.
343
984
  - Never apply `position: relative` to the direct parent of `<AnnotatedTextProperty>`, it will cause a [weird Safari bug](https://bsky.app/profile/michaelaufreiter.com/post/3lxvdqyxc622s) to destroy the DOM.
344
- - Never use an `<a>` tag inside a `contenteditable="true"` element, as it will cause unexpected behavior. Make it a `<div>` while editing, and an `<a>` in read-only mode.
985
+ - Never use an `<a>` tag inside a `contenteditable="true"` element, as it will cause unexpected behavior. Make it a `<div>` while editing, and an `<a>` in read-only mode (when `svedit.editable` is `false` ).
345
986
 
346
987
  ## Full API docs?
347
988
 
348
- Not yet. Please just [read the code](./src) for now. It's only a couple of files with less than 3000 LOC in total. The files in `routes` are considered example code (copy them and adapt them to your needs), while files in `lib` are considered library code. Read them to understand the API and what's happening behind the scences.
989
+ Not yet. Please just [read the code](./src) for now. It's only a couple of files with less than 3000 LOC in total. The files in `routes` are considered example code (copy them and adapt them to your needs), while files in `lib` are considered library code. Read them to understand the API and what's happening behind the scenes.
349
990
 
350
991
  ## Developing Svedit
351
992
 
@@ -373,7 +1014,7 @@ Find my contact details [here](https://editable.website).
373
1014
 
374
1015
  ## Beta version
375
1016
 
376
- It's early. Expect bugs. Expect missing features. Expect the need for more work on your part to make this work for your use case.
1017
+ It's still early. Expect bugs. Expect missing features. Expect the need for more work on your part to make this fit for your use case.
377
1018
 
378
1019
  ## Credits
379
1020