svedit 0.9.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.
Files changed (72) hide show
  1. package/README.md +476 -364
  2. package/dist/AnnotatedTextProperty.svelte +40 -10
  3. package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.d.ts +0 -1
  5. package/dist/Command.svelte.d.ts.map +1 -1
  6. package/dist/Command.svelte.js +48 -6
  7. package/dist/CustomProperty.svelte +20 -9
  8. package/dist/CustomProperty.svelte.d.ts +2 -1
  9. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  10. package/dist/Node.svelte +7 -5
  11. package/dist/Node.svelte.d.ts.map +1 -1
  12. package/dist/NodeArrayProperty.svelte +59 -48
  13. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  14. package/dist/NodeCaret.svelte +0 -7
  15. package/dist/NodeGap.svelte +208 -118
  16. package/dist/NodeGap.svelte.d.ts +0 -2
  17. package/dist/NodeGap.svelte.d.ts.map +1 -1
  18. package/dist/NodeGapMarkers.svelte +251 -84
  19. package/dist/NodeGapMarkers.svelte.d.ts +2 -0
  20. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
  21. package/dist/NodeSelectionMarkers.svelte +4 -3
  22. package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
  23. package/dist/Session.svelte.d.ts +12 -1
  24. package/dist/Session.svelte.d.ts.map +1 -1
  25. package/dist/Session.svelte.js +40 -3
  26. package/dist/Svedit.svelte +496 -221
  27. package/dist/Svedit.svelte.d.ts.map +1 -1
  28. package/dist/Transaction.svelte.d.ts +24 -5
  29. package/dist/Transaction.svelte.d.ts.map +1 -1
  30. package/dist/Transaction.svelte.js +103 -61
  31. package/dist/doc_utils.d.ts +69 -3
  32. package/dist/doc_utils.d.ts.map +1 -1
  33. package/dist/doc_utils.js +276 -10
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.js +6 -1
  36. package/dist/node_visibility.svelte.d.ts +9 -0
  37. package/dist/node_visibility.svelte.d.ts.map +1 -0
  38. package/dist/node_visibility.svelte.js +718 -0
  39. package/dist/paste_utils.d.ts +46 -0
  40. package/dist/paste_utils.d.ts.map +1 -0
  41. package/dist/paste_utils.js +167 -0
  42. package/dist/styles/svedit-animations.css +8 -0
  43. package/dist/styles/svedit-colors.css +1 -0
  44. package/dist/types.d.ts +46 -3
  45. package/dist/utils.d.ts +52 -0
  46. package/dist/utils.d.ts.map +1 -1
  47. package/dist/utils.js +109 -3
  48. package/package.json +3 -2
  49. package/src/lib/AnnotatedTextProperty.svelte +40 -10
  50. package/src/lib/Command.svelte.js +48 -6
  51. package/src/lib/CustomProperty.svelte +20 -9
  52. package/src/lib/Node.svelte +7 -5
  53. package/src/lib/NodeArrayProperty.svelte +59 -48
  54. package/src/lib/NodeCaret.svelte +0 -7
  55. package/src/lib/NodeGap.svelte +208 -118
  56. package/src/lib/NodeGapMarkers.svelte +251 -84
  57. package/src/lib/NodeSelectionMarkers.svelte +4 -3
  58. package/src/lib/Session.svelte.js +40 -3
  59. package/src/lib/Svedit.svelte +496 -221
  60. package/src/lib/Transaction.svelte.js +103 -61
  61. package/src/lib/doc_utils.js +276 -10
  62. package/src/lib/index.js +6 -1
  63. package/src/lib/node_visibility.svelte.js +718 -0
  64. package/src/lib/paste_utils.js +167 -0
  65. package/src/lib/styles/svedit-animations.css +8 -0
  66. package/src/lib/styles/svedit-colors.css +1 -0
  67. package/src/lib/types.d.ts +46 -3
  68. package/src/lib/utils.js +109 -3
  69. package/dist/node_gap_computation.svelte.d.ts +0 -5
  70. package/dist/node_gap_computation.svelte.d.ts.map +0 -1
  71. package/dist/node_gap_computation.svelte.js +0 -621
  72. package/src/lib/node_gap_computation.svelte.js +0 -621
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Svedit
2
2
 
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.
3
+ Svedit (think Svelte Edit) is a tiny library for building editable websites in Svelte. Model content in JSON, render it with custom components, and let site owners **edit directly in the layout** — no CMS needed.
4
4
 
5
5
  - Try the [Svedit demo](https://svedit.dev) — watch the debug output at the bottom of the page
6
6
  - Try [Editable Website](https://editable.website) — utilizes Svedit to enable CMS-free editable websites
@@ -32,6 +32,8 @@ You can also install Svedit into an existing SvelteKit project with `npm install
32
32
 
33
33
  **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.
34
34
 
35
+ **Native-first:** Svedit favors standardized native browser solutions over custom ones whenever possible — using browser-based rendering and the browser's selection APIs underneath, rather than reimplementing them (e.g. by building on [pretext](https://github.com/chenglou/pretext)). When we hit browser bugs, we try to get them fixed upstream (in collaboration with the W3C editing working group). Workarounds we add are intended to be temporary, until the underlying issue is fixed by browser vendors. And where we believe there's a better way to solve a problem than what's currently specified, we lobby for it to become a new (or improved) web standard.
36
+
35
37
  ## How it works
36
38
 
37
39
  Svedit connects eight key pieces:
@@ -46,9 +48,10 @@ Svedit connects eight key pieces:
46
48
  8. **Commands** - User actions (bold text, insert node, undo/redo) that modify the session
47
49
 
48
50
  **The flow:**
51
+
49
52
  - Define a schema → create a session → provide config → render with `<Svedit>` component
50
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
51
- - Selection state syncs bidirectionally with the DOM
54
+ - Native DOM selections are mapped to Svedit's internal selection model
52
55
 
53
56
  ## Schema
54
57
 
@@ -57,6 +60,7 @@ You can use a simple JSON-compatible schema definition language to enforce const
57
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.
58
61
 
59
62
  Each node has a `kind` that determines its behavior:
63
+
60
64
  - `document`: A top-level node accessible via a route (e.g. a page, event)
61
65
  - `block`: A structured node that contains other nodes or properties
62
66
  - `text`: A node with editable text content (can be split and joined)
@@ -65,6 +69,7 @@ Each node has a `kind` that determines its behavior:
65
69
  ### Choosing between `text` and `block`
66
70
 
67
71
  `kind: 'text'` opts into the split/join system (`break_text_node`, `join_text_node`), which assumes:
72
+
68
73
  - The node has exactly **one** `annotated_text` property named **`content`**
69
74
  - Pressing Enter splits the node into two nodes of the same type
70
75
  - Pressing Backspace at position 0 joins it with the previous node
@@ -106,6 +111,7 @@ flowchart TD
106
111
  </details>
107
112
 
108
113
  Properties of nodes can hold values:
114
+
109
115
  - `string`: A good old JavaScript string
110
116
  - `number`: Just like a number in JavaScript
111
117
  - `integer`: A number for which Number.isInteger(number) returns true
@@ -118,83 +124,83 @@ Properties of nodes can hold values:
118
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).
119
125
 
120
126
  Or references:
127
+
121
128
  - `node`: References a single node (e.g. an image node can reference a global asset node)
122
129
  - `node_array`: References a sequence of nodes (e.g. page.body references paragraph and list nodes)
123
130
 
124
-
125
131
  ```js
126
132
  const document_schema = {
127
- page: {
128
- kind: 'document',
129
- properties: {
130
- body: {
131
- type: 'node_array',
132
- node_types: ['nav', 'paragraph', 'list'],
133
- default_node_type: 'paragraph',
134
- }
135
- }
136
- },
137
- paragraph: {
138
- kind: 'text',
139
- properties: {
140
- content: {
141
- type: 'annotated_text',
142
- node_types: ['strong', 'emphasis', 'link'],
143
- allow_newlines: true
144
- }
145
- }
146
- },
147
- list_item: {
148
- kind: 'text',
149
- properties: {
150
- content: {
151
- type: 'annotated_text',
152
- node_types: ['strong', 'emphasis', 'link'],
153
- allow_newlines: true
154
- },
155
- }
156
- },
157
- list: {
158
- kind: 'block',
159
- properties: {
160
- list_items: {
161
- type: 'node_array',
162
- node_types: ['list_item'],
163
- default_node_type: 'list_item',
164
- }
165
- }
166
- },
167
- nav: {
168
- kind: 'block',
169
- properties: {
170
- nav_items: {
171
- type: 'node_array',
172
- node_types: ['nav_item'],
173
- default_node_type: 'nav_item',
174
- }
175
- }
176
- },
177
- nav_item: {
178
- kind: 'block',
179
- properties: {
180
- url: { type: 'string' },
181
- label: { type: 'string' },
182
- }
183
- },
184
- strong: {
185
- kind: 'annotation',
186
- properties: {}
187
- },
188
- emphasis: {
189
- kind: 'annotation',
190
- properties: {}
191
- },
192
- link: {
193
- kind: 'annotation',
194
- properties: {
195
- href: { type: 'string' }
196
- }
197
- }
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
+ }
198
204
  };
199
205
  ```
200
206
 
@@ -205,6 +211,7 @@ Annotation types are defined as nodes with `kind: 'annotation'`. Simple annotati
205
211
  A document is a plain JavaScript object (POJO) with a `document_id` (the entry point) and a `nodes` object containing all content nodes.
206
212
 
207
213
  Rules:
214
+
208
215
  - All nodes must be reachable from the document node (unreachable nodes are discarded)
209
216
  - No cyclic references allowed
210
217
  - Text content uses `{ text: '', annotations: [] }` format
@@ -213,48 +220,80 @@ Here's an example document:
213
220
 
214
221
  ```js
215
222
  const doc = {
216
- document_id: 'page_1',
217
- nodes: {
218
- nav_item_1: {
219
- id: 'nav_item_1',
220
- type: 'nav_item',
221
- url: '/homepage',
222
- label: 'Home'
223
- },
224
- nav_1: {
225
- id: 'nav_1',
226
- type: 'nav',
227
- nav_items: ['nav_item_1']
228
- },
229
- paragraph_1: {
230
- id: 'paragraph_1',
231
- type: 'paragraph',
232
- content: { text: 'Hello world.', annotations: [] }
233
- },
234
- list_item_1: {
235
- id: 'list_item_1',
236
- type: 'list_item',
237
- content: { text: 'First list item', annotations: [] }
238
- },
239
- list_item_2: {
240
- id: 'list_item_2',
241
- type: 'list_item',
242
- content: { text: 'Second list item', annotations: [] }
243
- },
244
- list_1: {
245
- id: 'list_1',
246
- type: 'list',
247
- list_items: ['list_item_1', 'list_item_2']
248
- },
249
- page_1: {
250
- id: 'page_1',
251
- type: 'page',
252
- body: ['nav_1', 'paragraph_1', 'list_1']
253
- }
254
- }
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
+ }
262
+ };
263
+ ```
264
+
265
+ ### Document schema changes
266
+
267
+ Svedit validates documents against the current schema when you create a `Session`. If you change the schema, you are responsible for migrating existing documents before loading them.
268
+
269
+ For simple additive changes, defaults can help. When you add a new property, you can call `fill_document_defaults` before creating the session:
270
+
271
+ ```js
272
+ import { Session, fill_document_defaults } from 'svedit';
273
+
274
+ const document_schema = {
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
+ }
255
285
  };
286
+
287
+ const migrated_doc = fill_document_defaults(existing_doc, document_schema);
288
+ const session = new Session(document_schema, migrated_doc, config);
256
289
  ```
257
290
 
291
+ This only fills properties that are missing. Explicit schema `default` values are used when present; otherwise Svedit uses built-in defaults for value types such as strings, numbers, booleans, arrays, `node_array`, and `annotated_text`. Existing values are preserved, and the original document object is not mutated.
292
+
293
+ Defaults make it safe to add new defaultable properties, but they are not a replacement for real document migrations. If you rename a property, remove a property, split one property into several, change node types, or need to transform existing data, write your own migration first and use `fill_document_defaults` only as a helper where appropriate.
294
+
295
+ `tr.create` and `tr.build` also fill omitted defaults for newly-created nodes. For document migrations, declare explicit defaults whenever the built-in type default is not the value you want. Do not rely on default filling for schema changes that need a real migration.
296
+
258
297
  ## Config
259
298
 
260
299
  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).
@@ -273,13 +312,13 @@ Two optional hooks are especially useful when integrating custom media workflows
273
312
  const session_config = {
274
313
  // ID generator for creating new nodes
275
314
  generate_id: () => nanoid(),
276
-
315
+
277
316
  // User-land overlays and optional system component overrides
278
- system_components: { Overlays },
279
-
317
+ system_components: { overlays: Overlays },
318
+
280
319
  // Map node types to Svelte components
281
- node_components: { Page, Text, Story, List, Button, ... },
282
-
320
+ node_components: { page: Page, text: Text, story: Story, list: List, button: Button, ... },
321
+
283
322
  // Functions that create and insert new nodes
284
323
  inserters: {
285
324
  text: (tr, content = {text: '', annotations: []}) => {
@@ -288,10 +327,10 @@ const session_config = {
288
327
  tr.insert_nodes([text_id]);
289
328
  }
290
329
  },
291
-
330
+
292
331
  // Returns { commands, keymap } for the editor instance
293
332
  create_commands_and_keymap: (context) => { ... },
294
-
333
+
295
334
  // Optional: handle image paste events
296
335
  handle_image_paste: (session, images) => { ... }
297
336
  };
@@ -302,11 +341,12 @@ const session_config = {
302
341
  - **`generate_id`** - Function that generates unique IDs for new nodes
303
342
  - **`node_components`** - Maps each node type from your schema to a Svelte component
304
343
  - **`system_components`** - Optional overrides for internal editor components and a slot for your own overlays:
305
- - `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.
306
- - `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.
307
346
  - **`inserters`** - Functions that create blank nodes of each type and set up the selection
308
347
  - **`create_commands_and_keymap`** - Factory function that creates commands and keybindings for an editor instance
309
348
  - **`handle_image_paste`** - Optional handler for image paste events
349
+ - **`view_classes`** - Viewport visibility classes. Every node element gets `.in-view`, `.seen`, `.fully-in-view`, `.visible-top`, `.visible-bottom` toggled as it scrolls through the viewport. Useful for scrollytelling and reveal animations driven from CSS. **On by default**. Set `view_classes: false` to opt out — saves ~5 classList ops per IO entry (measurable scroll-FPS win at 200+ nodes) and narrows the IntersectionObserver thresholds from `[0, 0.98]` to `[0]` (halves callback frequency during scroll). Disable if your app doesn't read these classes from CSS.
310
350
 
311
351
  The config is accessible throughout your app via `session.config`.
312
352
 
@@ -329,30 +369,42 @@ const session = new Session(schema, doc, config);
329
369
  ### Reading the graph
330
370
 
331
371
  ```js
332
- session.get(['page_1', 'body']) // => ['nav_1', 'paragraph_1', 'list_1']
333
- session.get(['nav_1']) // => { id: 'nav_1', type: 'nav', ... }
334
- session.get('nav_1') // => shorthand for above (single node ID)
335
- session.inspect(['page_1', 'body']) // => { kind: 'property', type: 'node_array', node_types: [...] }
336
- 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'
337
377
  ```
338
378
 
339
379
  ### Selection and state
340
380
 
341
381
  ```js
342
- session.selection // Current selection (text, node, or property)
343
- session.selected_node // The currently selected node (derived)
344
- session.active_annotation('strong') // Check if annotation is active at caret
345
- session.can_insert('paragraph') // Check if node type can be inserted
346
- 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)
347
387
  ```
348
388
 
389
+ #### Native selection as source of truth
390
+
391
+ Every selection that can exist in Svedit must have a DOM representation. This is a core design decision: the browser's native selection determines where the selection is, and `session.selection` is derived from it — not the other way around. If the DOM selection stops being the source of truth, selection mapping becomes ambiguous and Svedit loses the simplicity and determinism that the editor is built on.
392
+
393
+ This applies to all selection types:
394
+
395
+ - Text selections are native selections inside an `annotated_text` property.
396
+ - Node selections are native selections whose anchor and focus land inside the selected node range.
397
+ - Property selections are native selections inside the DOM representation of that property.
398
+
399
+ For example, a multi-node selection is still represented as a native browser selection that starts somewhere inside one node and ends somewhere inside another. Svedit maps that DOM selection to an internal node selection, hides the browser's visual selection, and renders `NodeSelectionMarkers` instead. The native selection remains in the DOM behind the scenes because `contenteditable` still needs it to do its job: pressing an arrow key should move the cursor from the current focus point to the closest valid next position, and Shift+Arrow should expand the selection from that same focus point. Without a native selection rendered at all times, the browser has no reliable starting point for cursor movement, selection expansion, or other editing behavior.
400
+
349
401
  ### Making changes
350
402
 
351
403
  ```js
352
- const tr = session.tr; // Create a transaction
404
+ const tr = session.tr; // Create a transaction
353
405
  tr.set(['nav_1', 'label'], 'Home');
354
406
  tr.insert_nodes(['new_node_id']);
355
- session.apply(tr); // Apply the transaction
407
+ session.apply(tr); // Apply the transaction
356
408
  ```
357
409
 
358
410
  #### Batching history entries
@@ -372,10 +424,10 @@ session.last_batch_started = undefined;
372
424
  ### History
373
425
 
374
426
  ```js
375
- session.can_undo // Boolean (derived)
376
- session.can_redo // Boolean (derived)
377
- session.undo()
378
- session.redo()
427
+ session.can_undo; // Boolean (derived)
428
+ session.can_redo; // Boolean (derived)
429
+ session.undo();
430
+ session.redo();
379
431
  ```
380
432
 
381
433
  ### Detecting unsaved changes
@@ -385,18 +437,18 @@ Because document state is immutable, you can detect unsaved changes by comparing
385
437
  ```js
386
438
  let last_saved_doc = $state(null);
387
439
  let has_unsaved_changes = $derived.by(() => {
388
- if (!last_saved_doc) {
389
- // No save yet — use undo history as indicator
390
- return session.can_undo;
391
- } else {
392
- // Compare current doc reference against last saved
393
- return last_saved_doc !== session.doc;
394
- }
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
+ }
395
447
  });
396
448
 
397
449
  function save() {
398
- // ... save to server ...
399
- last_saved_doc = session.doc;
450
+ // ... save to server ...
451
+ last_saved_doc = session.doc;
400
452
  }
401
453
  ```
402
454
 
@@ -405,12 +457,12 @@ This works because of Svedit's copy-on-write strategy: only modified parts of th
405
457
  ### Utilities
406
458
 
407
459
  ```js
408
- session.doc.document_id // The document's root ID
409
- session.generate_id() // Generate a new unique ID
410
- session.config // Access the config object
411
- session.validate_doc() // Validate all nodes against schema
412
- session.traverse(node_id) // Get all nodes reachable from a node
413
- 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
414
466
  ```
415
467
 
416
468
  ## Transforms
@@ -426,7 +478,7 @@ import { break_text_node } from 'svedit';
426
478
  const tr = session.tr;
427
479
  const success = break_text_node(tr);
428
480
  if (success) {
429
- session.apply(tr);
481
+ session.apply(tr);
430
482
  }
431
483
  ```
432
484
 
@@ -444,10 +496,10 @@ Transforms are composable. You can build higher-level transforms from lower-leve
444
496
 
445
497
  ```js
446
498
  function custom_transform(tr) {
447
- // Compose multiple transforms
448
- if (!break_text_node(tr)) return false;
449
- if (!insert_default_node(tr)) return false;
450
- 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;
451
503
  }
452
504
  ```
453
505
 
@@ -457,16 +509,16 @@ You're encouraged to write custom transforms for your application's specific nee
457
509
 
458
510
  ```js
459
511
  function insert_heading(tr) {
460
- const selection = tr.selection;
461
-
462
- if (selection?.type !== 'node') return false;
463
-
464
- // Create and insert a heading node
465
- const heading_id = tr.generate_id();
466
- tr.create({ id: heading_id, type: 'heading', content: { text: '', annotations: [] } });
467
- tr.insert_nodes([heading_id]);
468
-
469
- 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;
470
522
  }
471
523
  ```
472
524
 
@@ -477,9 +529,9 @@ Transactions group multiple operations into atomic units that can be applied and
477
529
  ### Basic usage
478
530
 
479
531
  ```js
480
- const tr = session.tr; // Create a new transaction
481
- tr.set(['node_1', 'title'], 'New Title'); // Modify properties
482
- 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
483
535
  ```
484
536
 
485
537
  ### Node operations
@@ -496,7 +548,7 @@ tr.insert_nodes(['paragraph_1', 'list_1']);
496
548
 
497
549
  // Build a subgraph from existing nodes (generates new IDs)
498
550
  const new_node_id = tr.build('the_list', {
499
- first_item: {
551
+ first_item: {
500
552
  id: 'first_item',
501
553
  type: 'list_item',
502
554
  content: node.content
@@ -528,19 +580,17 @@ tr.delete_selection();
528
580
  ```js
529
581
  // Set the selection after operations
530
582
  tr.set_selection({
531
- type: 'text',
532
- path: ['node_1', 'content'],
533
- anchor_offset: 0,
534
- focus_offset: 5
583
+ type: 'text',
584
+ path: ['node_1', 'content'],
585
+ anchor_offset: 0,
586
+ focus_offset: 5
535
587
  });
536
588
  ```
537
589
 
538
590
  All transaction methods return `this` for chaining:
539
591
 
540
592
  ```js
541
- tr.create(node)
542
- .insert_nodes([node.id])
543
- .set_selection(new_selection);
593
+ tr.create(node).insert_nodes([node.id]).set_selection(new_selection);
544
594
  ```
545
595
 
546
596
  ## Commands
@@ -548,6 +598,7 @@ tr.create(node)
548
598
  Commands provide a structured way to implement user actions. Commands are stateful and UI-aware, unlike transforms which are pure functions.
549
599
 
550
600
  There are two types of commands in Svedit:
601
+
551
602
  - **Document-scoped commands** - Bound to a specific Svedit instance/document and only active when that editor has focus
552
603
  - **App-level commands** - Operate at the application level, independent of any specific document
553
604
 
@@ -565,13 +616,13 @@ Extend the `Command` base class and implement the `is_enabled()` and `execute()`
565
616
  import { Command } from 'svedit';
566
617
 
567
618
  class ToggleStrongCommand extends Command {
568
- is_enabled() {
569
- return this.context.editable && this.context.session.selection?.type === 'text';
570
- }
619
+ is_enabled() {
620
+ return this.context.editable && this.context.session.selection?.type === 'text';
621
+ }
571
622
 
572
- execute() {
573
- this.context.session.apply(this.context.session.tr.annotate_text('strong'));
574
- }
623
+ execute() {
624
+ this.context.session.apply(this.context.session.tr.annotate_text('strong'));
625
+ }
575
626
  }
576
627
  ```
577
628
 
@@ -627,32 +678,33 @@ Commands are created by passing them a context object from the Svedit component.
627
678
 
628
679
  ```js
629
680
  create_commands_and_keymap: (context) => {
630
- const commands = {
631
- undo: new UndoCommand(context),
632
- redo: new RedoCommand(context),
633
- toggle_strong: new ToggleAnnotationCommand('strong', context),
634
- toggle_emphasis: new ToggleAnnotationCommand('emphasis', context),
635
- // ... more commands
636
- };
637
-
638
- const keymap = define_keymap({
639
- 'meta+z,ctrl+z': [commands.undo],
640
- 'meta+b,ctrl+b': [commands.toggle_strong],
641
- // ... more keybindings
642
- });
643
-
644
- return { commands, keymap };
645
- }
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
+ };
646
697
  ```
647
698
 
648
699
  Bind commands to UI elements in your components:
649
700
 
650
701
  ```svelte
651
- <button
652
- disabled={document_commands.toggle_strong.disabled}
653
- class:active={document_commands.toggle_strong.active}
654
- onclick={() => document_commands.toggle_strong.execute()}>
655
- 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
656
708
  </button>
657
709
  ```
658
710
 
@@ -662,34 +714,33 @@ Commands can have derived state for reactive UI binding. The `active` property i
662
714
 
663
715
  ```js
664
716
  class ToggleEmphasisCommand extends Command {
665
- // Automatically recomputes when annotation state changes
666
- active = $derived(this.context.session.active_annotation('emphasis'));
717
+ // Automatically recomputes when annotation state changes
718
+ active = $derived(this.context.session.active_annotation('emphasis'));
667
719
 
668
- is_enabled() {
669
- return this.context.editable && this.context.session.selection?.type === 'text';
670
- }
720
+ is_enabled() {
721
+ return this.context.editable && this.context.session.selection?.type === 'text';
722
+ }
671
723
 
672
- execute() {
673
- this.context.session.apply(this.context.session.tr.annotate_text('emphasis'));
674
- }
724
+ execute() {
725
+ this.context.session.apply(this.context.session.tr.annotate_text('emphasis'));
726
+ }
675
727
  }
676
728
  ```
677
729
 
678
730
  The `disabled` property is automatically derived from `is_enabled()` on all commands.
679
731
 
680
-
681
732
  #### DOM access in commands
682
733
 
683
734
  Commands can access the DOM through the context or global APIs:
684
735
 
685
736
  ```js
686
737
  class FocusNextSelectableCommand extends Command {
687
- execute() {
688
- const selectables = this.context.canvas_el.querySelectorAll('.svedit-selectable');
689
- const next = selectables[0]; // Find next based on current selection
690
- const path = next.closest('[data-path]').dataset.path.split('.');
691
- this.context.session.selection = { type: 'text', path, anchor_offset: 0, focus_offset: 0 };
692
- }
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
+ }
693
744
  }
694
745
  ```
695
746
 
@@ -705,6 +756,7 @@ Svedit uses a scope hierarchy (scope stack) to manage which commands are active
705
756
  2. **Document-level scope** (per Svedit instance) - Commands bound to a specific document/editor
706
757
 
707
758
  When a Svedit instance gains focus:
759
+
708
760
  - The previous document's scope is **popped** from the stack (its commands become inactive)
709
761
  - The newly focused document's scope is **pushed** onto the stack (its commands become active)
710
762
 
@@ -718,24 +770,24 @@ App-level commands have their own context, separate from any specific document:
718
770
  import { Command } from 'svedit';
719
771
 
720
772
  class SaveCommand extends Command {
721
- is_enabled() {
722
- return this.context.editable;
723
- }
724
-
725
- async execute() {
726
- await this.context.save_all_documents();
727
- this.context.show_notification('All changes saved');
728
- }
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
+ }
729
781
  }
730
782
 
731
783
  class ToggleEditModeCommand extends Command {
732
- is_enabled() {
733
- return !this.context.editable;
734
- }
784
+ is_enabled() {
785
+ return !this.context.editable;
786
+ }
735
787
 
736
- execute() {
737
- this.context.editable = true;
738
- }
788
+ execute() {
789
+ this.context.editable = true;
790
+ }
739
791
  }
740
792
  ```
741
793
 
@@ -745,23 +797,23 @@ The app-level context contains application-wide state and methods:
745
797
 
746
798
  ```js
747
799
  const app_context = {
748
- get editable() {
749
- return editable; // App-level editable state
750
- },
751
- set editable(value) {
752
- editable = value;
753
- },
754
- get session() {
755
- return session;
756
- },
757
- get app_el() {
758
- return app_el;
759
- }
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
+ }
760
812
  };
761
813
 
762
814
  const app_commands = {
763
- save: new SaveCommand(app_context),
764
- toggle_edit: new ToggleEditCommand(app_context)
815
+ save: new SaveCommand(app_context),
816
+ toggle_edit: new ToggleEditCommand(app_context)
765
817
  };
766
818
  ```
767
819
 
@@ -778,9 +830,9 @@ const key_mapper = new KeyMapper();
778
830
 
779
831
  // Define a keymap
780
832
  const keymap = define_keymap({
781
- 'meta+z,ctrl+z': [document_commands.undo],
782
- 'meta+b,ctrl+b': [document_commands.bold],
783
- '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]
784
836
  });
785
837
 
786
838
  // Push the keymap onto the scope stack
@@ -788,7 +840,7 @@ key_mapper.push_scope(keymap);
788
840
 
789
841
  // Handle keydown events
790
842
  window.addEventListener('keydown', (event) => {
791
- key_mapper.handle_keydown(event);
843
+ key_mapper.handle_keydown(event);
792
844
  });
793
845
  ```
794
846
 
@@ -805,10 +857,10 @@ Commands are wrapped in arrays to support fallback behavior:
805
857
 
806
858
  ```js
807
859
  define_keymap({
808
- 'meta+b,ctrl+b': [
809
- document_commands.bold, // Try this first
810
- document_commands.fallback // Use this if first is disabled
811
- ]
860
+ 'meta+b,ctrl+b': [
861
+ document_commands.bold, // Try this first
862
+ document_commands.fallback // Use this if first is disabled
863
+ ]
812
864
  });
813
865
  ```
814
866
 
@@ -819,15 +871,15 @@ Use `push_scope()` and `pop_scope()` to manage different keyboard contexts:
819
871
  ```js
820
872
  // App-level keymap (always active)
821
873
  const app_keymap = define_keymap({
822
- 'meta+s,ctrl+s': [app_commands.save],
823
- '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]
824
876
  });
825
877
  key_mapper.push_scope(app_keymap);
826
878
 
827
879
  // Document-level keymap (active when editor has focus)
828
880
  const doc_keymap = define_keymap({
829
- 'meta+z,ctrl+z': [document_commands.undo],
830
- 'meta+b,ctrl+b': [document_commands.bold]
881
+ 'meta+z,ctrl+z': [document_commands.undo],
882
+ 'meta+b,ctrl+b': [document_commands.bold]
831
883
  });
832
884
 
833
885
  // When editor gains focus:
@@ -839,7 +891,6 @@ key_mapper.pop_scope();
839
891
 
840
892
  The KeyMapper tries scopes from top to bottom, so push more specific keymaps last.
841
893
 
842
-
843
894
  ## Selection
844
895
 
845
896
  Selections are at the heart of Svedit. There are just three types of selections:
@@ -852,39 +903,39 @@ Selections are at the heart of Svedit. There are just three types of selections:
852
903
 
853
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'.
854
905
 
855
- ```js
856
- {
857
- type: 'text',
858
- path: ['page_1234', 'body', 0, 'content'],
859
- anchor_offset: 1,
860
- focus_offset: 1
861
- }
862
- ```
906
+ ```js
907
+ {
908
+ type: 'text',
909
+ path: ['page_1234', 'body', 0, 'content'],
910
+ anchor_offset: 1,
911
+ focus_offset: 1
912
+ }
913
+ ```
863
914
 
864
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.
865
916
 
866
- ```js
867
- {
868
- type: 'node',
869
- path: ['page_1234', 'body'],
870
- anchor_offset: 2,
871
- focus_offset: 4
872
- }
873
- ```
917
+ ```js
918
+ {
919
+ type: 'node',
920
+ path: ['page_1234', 'body'],
921
+ anchor_offset: 2,
922
+ focus_offset: 4
923
+ }
924
+ ```
874
925
 
875
926
  3. **Property Selection**: A property selection addresses one particular property of a node.
876
927
 
877
- ```js
878
- {
879
- type: "property",
880
- path: [
881
- "page_1",
882
- "body",
883
- 11,
884
- "image"
885
- ]
886
- }
887
- ```
928
+ ```js
929
+ {
930
+ type: "property",
931
+ path: [
932
+ "page_1",
933
+ "body",
934
+ 11,
935
+ "image"
936
+ ]
937
+ }
938
+ ```
888
939
 
889
940
  You can access the current selection through `session.selection` anytime. And you can programmatically set the selection using `session.selection = new_selection`.
890
941
 
@@ -906,20 +957,21 @@ A typical node component follows this pattern:
906
957
 
907
958
  ```svelte
908
959
  <script>
909
- import { Node, AnnotatedTextProperty } from 'svedit';
910
- let { path } = $props();
960
+ import { Node, AnnotatedTextProperty } from 'svedit';
961
+ let { path } = $props();
911
962
  </script>
912
963
 
913
964
  <Node {path}>
914
- <div class="my-node">
915
- <AnnotatedTextProperty path={[...path, 'content']} />
916
- </div>
965
+ <div class="my-node">
966
+ <AnnotatedTextProperty path={[...path, 'content']} />
967
+ </div>
917
968
  </Node>
918
969
  ```
919
970
 
920
971
  ### The `<Node>` wrapper
921
972
 
922
973
  Every node component must wrap its content in the `<Node>` component. This wrapper:
974
+
923
975
  - Registers the node with the editor
924
976
  - Handles selection and caret behavior
925
977
  - Provides the foundation for editing interactions
@@ -932,29 +984,36 @@ Svedit provides specialized components for rendering different property types:
932
984
 
933
985
  ```svelte
934
986
  <AnnotatedTextProperty
935
- tag="p"
936
- class="body"
937
- path={[...path, 'content']}
938
- placeholder="Enter text here"
987
+ tag="p"
988
+ class="body"
989
+ path={[...path, 'content']}
990
+ placeholder="Enter text here"
939
991
  />
940
992
  ```
941
993
 
942
994
  **`<NodeArrayProperty>`** - For container properties that hold multiple nodes:
943
995
 
944
996
  ```svelte
945
- <NodeArrayProperty
946
- class="list-items"
947
- path={[...path, 'list_items']}
948
- />
997
+ <NodeArrayProperty class="list-items" path={[...path, 'list_items']} />
949
998
  ```
950
999
 
1000
+ > **One path = one DOM mount.** Within a single Svedit document, each node path
1001
+ > may be mounted exactly once. Mounting the same path twice (e.g. rendering the
1002
+ > same `nav_items` array in both header and footer) breaks anchor positioning,
1003
+ > intersection tracking, id uniqueness, and selection mapping.
1004
+ > Svedit logs an error if it detects a duplicate mount.
1005
+ >
1006
+ > If you need to render shared content in multiple places, model it as distinct
1007
+ > node_arrays (`header_nav`, `footer_nav`) in the same document, or render it
1008
+ > from a separate Svedit instance.
1009
+
951
1010
  **`<CustomProperty>`** - For custom properties like images or other non-text content:
952
1011
 
953
1012
  ```svelte
954
1013
  <CustomProperty class="image-wrapper" path={[...path, 'image']}>
955
- <div contenteditable="false">
956
- <img src={node.image} alt={node.title.text} />
957
- </div>
1014
+ <div contenteditable="false">
1015
+ <img src={node.image} alt={node.title.text} />
1016
+ </div>
958
1017
  </CustomProperty>
959
1018
  ```
960
1019
 
@@ -964,12 +1023,12 @@ Use the Svedit context to access node data:
964
1023
 
965
1024
  ```svelte
966
1025
  <script>
967
- import { getContext } from 'svelte';
968
- const svedit = getContext('svedit');
969
-
970
- let { path } = $props();
971
- let node = $derived(svedit.session.get(path));
972
- 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);
973
1032
  </script>
974
1033
  ```
975
1034
 
@@ -979,25 +1038,25 @@ Here's a complete example of a text node component that supports multiple layout
979
1038
 
980
1039
  ```svelte
981
1040
  <script>
982
- import { getContext } from 'svelte';
983
- import { Node, AnnotatedTextProperty } from 'svedit';
984
-
985
- const svedit = getContext('svedit');
986
- let { path } = $props();
987
- let node = $derived(svedit.session.get(path));
988
- let layout = $derived(node.layout || 1);
989
- 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}`);
990
1049
  </script>
991
1050
 
992
1051
  <Node {path}>
993
- <div class="text layout-{layout}">
994
- <AnnotatedTextProperty
995
- {tag}
996
- class="body"
997
- path={[...path, 'content']}
998
- placeholder="Enter text"
999
- />
1000
- </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>
1001
1060
  </Node>
1002
1061
  ```
1003
1062
 
@@ -1007,14 +1066,14 @@ A simple list component that renders child items:
1007
1066
 
1008
1067
  ```svelte
1009
1068
  <script>
1010
- import { Node, NodeArrayProperty } from 'svedit';
1011
- let { path } = $props();
1069
+ import { Node, NodeArrayProperty } from 'svedit';
1070
+ let { path } = $props();
1012
1071
  </script>
1013
1072
 
1014
1073
  <Node {path}>
1015
- <div class="list">
1016
- <NodeArrayProperty path={[...path, 'list_items']} />
1017
- </div>
1074
+ <div class="list">
1075
+ <NodeArrayProperty path={[...path, 'list_items']} />
1076
+ </div>
1018
1077
  </Node>
1019
1078
  ```
1020
1079
 
@@ -1024,17 +1083,17 @@ Node components are registered in the document config's `node_components` map:
1024
1083
 
1025
1084
  ```js
1026
1085
  const session_config = {
1027
- node_components: {
1028
- Text,
1029
- Story,
1030
- List,
1031
- ListItem,
1032
- // ... other components
1033
- }
1034
- }
1086
+ node_components: {
1087
+ text: Text,
1088
+ story: Story,
1089
+ list: List,
1090
+ list_item: ListItem
1091
+ // ... other components
1092
+ }
1093
+ };
1035
1094
  ```
1036
1095
 
1037
- 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`.
1038
1097
 
1039
1098
  ## Mastering contenteditable
1040
1099
 
@@ -1043,8 +1102,8 @@ a simplified version of the markup of `<NodeGap>` and why it is implemented the
1043
1102
 
1044
1103
  ```html
1045
1104
  <div contenteditable="true">
1046
- <div class="some-wrapper">
1047
- <!--
1105
+ <div class="some-wrapper">
1106
+ <!--
1048
1107
  Putting a <br> tag into a div gives you a single addressable caret position.
1049
1108
 
1050
1109
  Adding a &ZeroWidthSpace; (or any character) here will lead to 2 caret
@@ -1056,8 +1115,8 @@ a simplified version of the markup of `<NodeGap>` and why it is implemented the
1056
1115
  Svedit uses this behavior for node gaps, and when an
1057
1116
  <AnnotatedTextProperty> is empty.
1058
1117
  -->
1059
- <div class="node-gap"><br></div>
1060
- <!--
1118
+ <div class="node-gap"><br /></div>
1119
+ <!--
1061
1120
  If you create a contenteditable="false" island, there needs to be some content in it,
1062
1121
  otherwise it will create two additional caret positions. One before, and another one
1063
1122
  after the island.
@@ -1065,8 +1124,8 @@ a simplified version of the markup of `<NodeGap>` and why it is implemented the
1065
1124
  The Svedit demo uses this technique in `<NodeGap>` to create a node-caret
1066
1125
  visualization, that doesn't mess with the contenteditable caret positions.
1067
1126
  -->
1068
- <div contenteditable="false" class="node-caret">&ZeroWidthSpace;</div>
1069
- </div>
1127
+ <div contenteditable="false" class="node-caret">&ZeroWidthSpace;</div>
1128
+ </div>
1070
1129
  </div>
1071
1130
  ```
1072
1131
 
@@ -1077,8 +1136,61 @@ Further things to consider:
1077
1136
  - 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.
1078
1137
  - Never apply `position: relative`, `position: absolute`, `position: fixed` to `<Node.svelte>` (data-type="node") in edit mode. Only `position: static` is permitted to allow css anchor positioning queries to resolve correctly.
1079
1138
  - 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` ).
1080
- - Avoid CSS selectors like `:last-child` or `:first-child` on nodes (e.g., `[data-type="node"]:last-child`) because `<NodeGap>` elements are inserted in edit mode and will become the actual first or last child. This can cause unexpected layout shifts (e.g., if you have `.paragraph-node:last-child { margin-bottom: 10px }`, the margin won't apply as expected).
1081
- - Avoid adding css `margin` to nodes inside node arrays when using flex or grid layouts. Use `gap` on the container instead. This ensure `NodeGap` and `NodeGapMarkers` render consistently. If you need to add a margin, add it to child element of Node.
1139
+ - Avoid adding css `margin` to nodes inside node arrays when using flex or grid layouts. Use `gap` on the container instead. This ensures `NodeGap` and `NodeGapMarkers` render consistently. If you need to add a margin, add it to a child element of Node.
1140
+
1141
+ ### Consistent DOM structure across modes
1142
+
1143
+ `<NodeArrayProperty>` always renders a `.node-gap` element before each node, regardless of whether the editor is in edit or read-only mode. In edit mode, this is the full `<NodeGap>` component with anchor positioning, hit areas, and caret support. In read-only mode, it's a plain `<div class="node-gap"></div>`.
1144
+
1145
+ This means the DOM structure is identical in both modes:
1146
+
1147
+ ```html
1148
+ <!-- Same structure in edit and read-only mode -->
1149
+ <div data-type="node_array">
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 -->
1158
+ </div>
1159
+ ```
1160
+
1161
+ The `.node-gap` element uses `display: contents`, so it has no layout impact — it doesn't generate a box, doesn't affect flex/grid item counts, and doesn't consume space. It only serves as a DOM placeholder for consistent selector targeting.
1162
+
1163
+ Because `.node-gap` is always the actual first and last child, do not use `:first-child` or `:last-child` on nodes — they will never match. Use `:nth-child(1 of [data-type="node"])` and `:nth-last-child(1 of [data-type="node"])` instead.
1164
+
1165
+ CSS sibling selectors that target node adjacency only need one form:
1166
+
1167
+ ```css
1168
+ /* Works in both edit and read-only mode */
1169
+ .node-text + .node-gap + .node-media {
1170
+ margin-block-start: 1rem;
1171
+ }
1172
+ ```
1173
+
1174
+ For `:nth-child` selectors, use the `of <selector>` syntax to skip over `.node-gap` elements and count only nodes:
1175
+
1176
+ ```css
1177
+ /* Even nodes */
1178
+ .list-node-array > :nth-child(2n of [data-type='node']) {
1179
+ background: var(--zebra-stripe);
1180
+ }
1181
+
1182
+ /* Odd nodes */
1183
+ .list-node-array > :nth-child(2n-1 of [data-type='node']) {
1184
+ background: var(--zebra-stripe);
1185
+ }
1186
+
1187
+ /* Last node */
1188
+ .list-node-array > :nth-last-child(1 of [data-type='node']) {
1189
+ border-bottom: none;
1190
+ }
1191
+ ```
1192
+
1193
+ Without the `of` filter, plain `:nth-child(even)` would count `.node-gap` divs and produce incorrect results. The [`of <selector>`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:nth-child#syntax) form is Baseline 2023 and supported in all modern browsers.
1082
1194
 
1083
1195
  ### Node array CSS tokens
1084
1196
 
@@ -1088,9 +1200,9 @@ Further things to consider:
1088
1200
 
1089
1201
  ```css
1090
1202
  .my-horizontal-layout :global(.grid-items) {
1091
- --row: 1;
1092
- display: flex;
1093
- flex-wrap: wrap;
1203
+ --row: 1;
1204
+ display: flex;
1205
+ flex-wrap: wrap;
1094
1206
  }
1095
1207
  ```
1096
1208
 
@@ -1099,11 +1211,11 @@ Further things to consider:
1099
1211
  ```
1100
1212
  Without --node-caret-boundary With --node-caret-boundary
1101
1213
 
1102
- : . . . . . . . . . . . . . . . :
1214
+ : . . . . . . . . . . . . . . . :
1103
1215
  : +---------------------------+ : +-----------------------------+
1104
1216
  : | Toolbar / UI | : ← overlap | Toolbar / UI |
1105
1217
  : +---------------------------+ : +-----------------------------+
1106
- : :
1218
+ : :
1107
1219
  : edge gap (unbounded) : boudary → +-----------------------------+
1108
1220
  : . . . . . . . . . . . . . . . : | : . . . . . . . . . . . . : |
1109
1221
  | : edge gap (clamped) : |
@@ -1119,22 +1231,22 @@ Without --node-caret-boundary With --node-caret-boundary
1119
1231
  | : edge gap (clamped) : |
1120
1232
  : . . . . . . . . . . . . . . . : | : . . . . . . . . . . . . : |
1121
1233
  : edge gap (unbounded) : boudary → +-----------------------------+
1122
- : :
1234
+ : :
1123
1235
  : +---------------------------+ : +-----------------------------+
1124
1236
  : | Footer / UI | : ← overlap | Footer / UI |
1125
1237
  : +---------------------------+ : +-----------------------------+
1126
- : . . . . . . . . . . . . . . . :
1238
+ : . . . . . . . . . . . . . . . :
1127
1239
  ```
1128
1240
 
1129
1241
  Set `--node-caret-boundary` to the `anchor-name` of a parent element to clamp edge gaps to that element's edges:
1130
1242
 
1131
1243
  ```css
1132
1244
  .editor-wrapper {
1133
- anchor-name: --editor-boundary;
1134
- padding: 24px;
1245
+ anchor-name: --editor-boundary;
1246
+ padding: 24px;
1135
1247
  }
1136
- .editor-wrapper [data-type="node_array"] {
1137
- --node-caret-boundary: --editor-boundary;
1248
+ .editor-wrapper [data-type='node_array'] {
1249
+ --node-caret-boundary: --editor-boundary;
1138
1250
  }
1139
1251
  ```
1140
1252
 
@@ -1143,16 +1255,16 @@ When set, edge gaps clamp to the boundary element's edges instead. When not set,
1143
1255
  Since `--node-caret-boundary` inherits to nested node arrays, you may need to unset it on inner containers that should not be clamped:
1144
1256
 
1145
1257
  ```css
1146
- .inner-container [data-type="node_array"] {
1147
- --node-caret-boundary: initial;
1258
+ .inner-container [data-type='node_array'] {
1259
+ --node-caret-boundary: initial;
1148
1260
  }
1149
1261
  ```
1150
1262
 
1151
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:
1152
1264
 
1153
1265
  ```css
1154
- .editor-wrapper [data-type="node_array"] {
1155
- --node-caret-boundary-x: --editor-boundary;
1266
+ .editor-wrapper [data-type='node_array'] {
1267
+ --node-caret-boundary-x: --editor-boundary;
1156
1268
  }
1157
1269
  ```
1158
1270