svedit 0.11.0 → 0.12.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 (59) hide show
  1. package/README.md +230 -64
  2. package/dist/Command.svelte.d.ts +21 -5
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +86 -30
  5. package/dist/KeyMapper.svelte.d.ts.map +1 -1
  6. package/dist/KeyMapper.svelte.js +18 -6
  7. package/dist/Node.svelte +29 -1
  8. package/dist/Node.svelte.d.ts.map +1 -1
  9. package/dist/NodeArrayProperty.svelte +119 -12
  10. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  11. package/dist/NodeGap.svelte +48 -6
  12. package/dist/NodeGap.svelte.d.ts.map +1 -1
  13. package/dist/Session.svelte.d.ts +31 -14
  14. package/dist/Session.svelte.d.ts.map +1 -1
  15. package/dist/Session.svelte.js +155 -77
  16. package/dist/Svedit.svelte +110 -89
  17. package/dist/Svedit.svelte.d.ts.map +1 -1
  18. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  19. package/dist/TextProperty.svelte.d.ts +7 -0
  20. package/dist/TextProperty.svelte.d.ts.map +1 -0
  21. package/dist/Transaction.svelte.d.ts +78 -40
  22. package/dist/Transaction.svelte.d.ts.map +1 -1
  23. package/dist/Transaction.svelte.js +412 -234
  24. package/dist/doc_utils.d.ts +96 -21
  25. package/dist/doc_utils.d.ts.map +1 -1
  26. package/dist/doc_utils.js +440 -168
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/node_visibility.svelte.d.ts +30 -4
  30. package/dist/node_visibility.svelte.d.ts.map +1 -1
  31. package/dist/node_visibility.svelte.js +327 -363
  32. package/dist/paste_utils.d.ts +3 -2
  33. package/dist/paste_utils.d.ts.map +1 -1
  34. package/dist/paste_utils.js +16 -13
  35. package/dist/transforms.svelte.d.ts.map +1 -1
  36. package/dist/transforms.svelte.js +5 -6
  37. package/dist/types.d.ts +112 -42
  38. package/dist/utils.d.ts +89 -13
  39. package/dist/utils.d.ts.map +1 -1
  40. package/dist/utils.js +320 -85
  41. package/package.json +4 -1
  42. package/src/lib/Command.svelte.js +86 -30
  43. package/src/lib/KeyMapper.svelte.js +18 -6
  44. package/src/lib/Node.svelte +29 -1
  45. package/src/lib/NodeArrayProperty.svelte +119 -12
  46. package/src/lib/NodeGap.svelte +48 -6
  47. package/src/lib/Session.svelte.js +155 -77
  48. package/src/lib/Svedit.svelte +110 -89
  49. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  50. package/src/lib/Transaction.svelte.js +412 -234
  51. package/src/lib/doc_utils.js +440 -168
  52. package/src/lib/index.js +1 -1
  53. package/src/lib/node_visibility.svelte.js +327 -363
  54. package/src/lib/paste_utils.js +16 -13
  55. package/src/lib/transforms.svelte.js +5 -6
  56. package/src/lib/types.d.ts +112 -42
  57. package/src/lib/utils.js +320 -85
  58. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  59. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
package/README.md CHANGED
@@ -38,7 +38,7 @@ You can also install Svedit into an existing SvelteKit project with `npm install
38
38
 
39
39
  Svedit connects eight key pieces:
40
40
 
41
- 1. **Schema** - Define your content structure (node types, properties, annotations)
41
+ 1. **Schema** - Define your content structure (node types, properties, marks, annotations)
42
42
  2. **Document** - An actual document, containing a `document_id` and a flat map of nodes that hold the content
43
43
  3. **Session** - Manages the document, selection state, and history
44
44
  4. **Transaction** - Groups multiple document operations (create, delete, set) into a single atomic unit with undo/redo support
@@ -64,17 +64,18 @@ Each node has a `kind` that determines its behavior:
64
64
  - `document`: A top-level node accessible via a route (e.g. a page, event)
65
65
  - `block`: A structured node that contains other nodes or properties
66
66
  - `text`: A node with editable text content (can be split and joined)
67
- - `annotation`: An inline annotation applied to text (bold, link, etc.)
67
+ - `mark`: A content-level range applied to text or a node array (bold, link, section, etc.). Marks are mutually exclusive and render in-place.
68
+ - `annotation`: A metadata/overlay range applied to text or a node array (comments, markers, etc.). Annotations may overlap and are data-only.
68
69
 
69
70
  ### Choosing between `text` and `block`
70
71
 
71
72
  `kind: 'text'` opts into the split/join system (`break_text_node`, `join_text_node`), which assumes:
72
73
 
73
- - The node has exactly **one** `annotated_text` property named **`content`**
74
+ - The node has exactly **one** `text` property named **`content`**
74
75
  - Pressing Enter splits the node into two nodes of the same type
75
76
  - Pressing Backspace at position 0 joins it with the previous node
76
77
 
77
- If **any** of those assumptions don't hold, use `kind: 'block'`. Blocks can still have `annotated_text` properties with full editing support (typing, formatting, selection) — they just don't participate in split/join.
78
+ If **any** of those assumptions don't hold, use `kind: 'block'`. Blocks can still have `text` properties with full editing support (typing, formatting, selection) — they just don't participate in split/join.
78
79
 
79
80
  > **Common mistake:** A quote node with `content` + `author` properties might seem like `kind: 'text'` because both fields are editable text. But splitting a quote into two half-quotes doesn't make sense, and `join_text_node` hard-codes `node.content` — so Backspace in the `author` field would join the wrong property with the previous block and drop the author text. The correct kind is `'block'`.
80
81
 
@@ -84,19 +85,23 @@ If **any** of those assumptions don't hold, use `kind: 'block'`. Blocks can stil
84
85
  ```mermaid
85
86
  flowchart TD
86
87
  Start(["I need a new node type"])
87
- Q1{"Is it inline formatting applied within text?"}
88
+ Q1{"Is it a range applied within text or over child nodes?"}
89
+ Q1a{"Is it part of the content (formatting) rather than metadata?"}
88
90
  Q2{"Is it a top-level routable entry point?"}
89
91
  Q3{"Is text the node's entire purpose?"}
90
- Q4{"Does it have exactly one annotated_text property?"}
92
+ Q4{"Does it have exactly one text property named content?"}
91
93
  Q5{"Does pressing Enter to split into two nodes make sense?"}
92
94
 
93
- KindAnnotation["kind: annotation e.g. bold, italic, link"]
95
+ KindMark["kind: mark e.g. bold, italic, link, section"]
96
+ KindAnnotation["kind: annotation e.g. comment, marker"]
94
97
  KindDocument["kind: document e.g. page, article, event"]
95
98
  KindBlock["kind: block e.g. image+caption, quote+author, list, nav, embed"]
96
99
  KindText["kind: text e.g. paragraph, heading, list_item"]
97
100
 
98
101
  Start --> Q1
99
- Q1 -->|Yes| KindAnnotation
102
+ Q1 -->|Yes| Q1a
103
+ Q1a -->|Yes| KindMark
104
+ Q1a -->|"No — it's metadata that may overlap"| KindAnnotation
100
105
  Q1 -->|No| Q2
101
106
  Q2 -->|Yes| KindDocument
102
107
  Q2 -->|No| Q3
@@ -121,13 +126,15 @@ Properties of nodes can hold values:
121
126
  - `number_array`: An array of numbers
122
127
  - `integer_array`: An array of integers
123
128
  - `boolean_array`: An array of booleans
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).
129
+ - `text`: Plain text content with marks (bold, italic, link etc.) and annotations (comments etc.). Text values use `{ content: '', marks: [], annotations: [] }`. Set `allow_newlines: true` to let users insert line breaks with Shift+Enter, or `false` to keep content single-line (e.g. for titles).
125
130
 
126
131
  Or references:
127
132
 
128
133
  - `node`: References a single node (e.g. an image node can reference a global asset node)
129
134
  - `node_array`: References a sequence of nodes (e.g. page.body references paragraph and list nodes)
130
135
 
136
+ `node_array` properties use `node_types` for the child node types they can contain, plus optional `mark_types` for marks that can wrap ranges of child nodes and optional `annotation_types` for overlay annotations.
137
+
131
138
  ```js
132
139
  const document_schema = {
133
140
  page: {
@@ -136,6 +143,8 @@ const document_schema = {
136
143
  body: {
137
144
  type: 'node_array',
138
145
  node_types: ['nav', 'paragraph', 'list'],
146
+ mark_types: ['section'],
147
+ annotation_types: ['comment'],
139
148
  default_node_type: 'paragraph'
140
149
  }
141
150
  }
@@ -144,8 +153,9 @@ const document_schema = {
144
153
  kind: 'text',
145
154
  properties: {
146
155
  content: {
147
- type: 'annotated_text',
148
- node_types: ['strong', 'emphasis', 'link'],
156
+ type: 'text',
157
+ mark_types: ['strong', 'emphasis', 'link'],
158
+ annotation_types: ['comment'],
149
159
  allow_newlines: true
150
160
  }
151
161
  }
@@ -154,8 +164,8 @@ const document_schema = {
154
164
  kind: 'text',
155
165
  properties: {
156
166
  content: {
157
- type: 'annotated_text',
158
- node_types: ['strong', 'emphasis', 'link'],
167
+ type: 'text',
168
+ mark_types: ['strong', 'emphasis', 'link'],
159
169
  allow_newlines: true
160
170
  }
161
171
  }
@@ -188,23 +198,31 @@ const document_schema = {
188
198
  }
189
199
  },
190
200
  strong: {
191
- kind: 'annotation',
201
+ kind: 'mark',
192
202
  properties: {}
193
203
  },
194
204
  emphasis: {
195
- kind: 'annotation',
205
+ kind: 'mark',
196
206
  properties: {}
197
207
  },
198
208
  link: {
199
- kind: 'annotation',
209
+ kind: 'mark',
200
210
  properties: {
201
211
  href: { type: 'string' }
202
212
  }
213
+ },
214
+ section: {
215
+ kind: 'mark',
216
+ properties: {}
217
+ },
218
+ comment: {
219
+ kind: 'annotation',
220
+ properties: {}
203
221
  }
204
222
  };
205
223
  ```
206
224
 
207
- Annotation types are defined as nodes with `kind: 'annotation'`. Simple annotations like `strong` and `emphasis` have no properties, while annotations like `link` can carry data (e.g. `href`). Each `annotated_text` property specifies which annotation types are allowed via `node_types` — this lets you control formatting per property (e.g. allow bold and links in body text, but only emphasis in titles).
225
+ Mark types are defined as nodes with `kind: 'mark'`, annotation types as nodes with `kind: 'annotation'`. Simple marks like `strong` and `emphasis` have no properties, while marks like `link` can carry data (e.g. `href`). Each `text` or `node_array` property specifies which types are allowed via `mark_types` and `annotation_types` — this lets you control formatting per property (e.g. allow bold and links in body text, but only emphasis in titles). `mark_types` may only reference `kind: 'mark'` node types and `annotation_types` only `kind: 'annotation'` node types.
208
226
 
209
227
  ## Document
210
228
 
@@ -214,7 +232,60 @@ Rules:
214
232
 
215
233
  - All nodes must be reachable from the document node (unreachable nodes are discarded)
216
234
  - No cyclic references allowed
217
- - Text content uses `{ text: '', annotations: [] }` format
235
+ - Text properties use `{ content: '', marks: [], annotations: [] }`
236
+ - Node array properties use `{ nodes: [], marks: [], annotations: [] }`
237
+
238
+ ### Node IDs
239
+
240
+ Node IDs uniquely identify nodes inside a document. They are also used whenever one node references another node, for example in `node`, `node_array.nodes`, and mark or annotation `node_id` values.
241
+
242
+ Because Svedit also uses these IDs in HTML ids, document paths, and CSS selectors, IDs need to follow a simple, safe format:
243
+
244
+ - It must be a string.
245
+ - It must not be empty.
246
+ - It must start with a letter (`A-Z`, `a-z`) or underscore (`_`).
247
+ - After the first character, it may contain letters, numbers, underscores, or dashes.
248
+ - It must not contain Svedit's path separator `__`.
249
+
250
+ Valid examples:
251
+
252
+ ```js
253
+ 'page_1';
254
+ 'story-hero';
255
+ '_temporary_node';
256
+ ```
257
+
258
+ Invalid examples:
259
+
260
+ | ID | Why invalid |
261
+ | ----------- | --------------------------------------------------- |
262
+ | `1` | Not a string |
263
+ | `'1'` | Serialized paths would treat this as an array index |
264
+ | `'1_page'` | Starts with a number |
265
+ | `'page.1'` | `.` is not allowed |
266
+ | `'page__1'` | `__` is reserved as Svedit's path separator |
267
+
268
+ The `doc.nodes` map key must also match the node's own `id`:
269
+
270
+ ```js
271
+ nodes: {
272
+ page_1: {
273
+ id: 'page_1',
274
+ type: 'page'
275
+ // ...
276
+ }
277
+ }
278
+ ```
279
+
280
+ If you provide `config.generate_id`, every generated ID must follow the same rules. Svedit validates generated IDs immediately and throws if the provider returns an invalid value:
281
+
282
+ ```js
283
+ const session_config = {
284
+ generate_id: () => `node_${crypto.randomUUID()}`
285
+ };
286
+ ```
287
+
288
+ Provide your own generator: use short custom-alphabet `nanoid` IDs for local documents, monotonic ULIDs (`ulid`) for sortable/synced IDs, or prefixed UUIDs for globally unique IDs.
218
289
 
219
290
  Here's an example document:
220
291
 
@@ -222,6 +293,14 @@ Here's an example document:
222
293
  const doc = {
223
294
  document_id: 'page_1',
224
295
  nodes: {
296
+ strong_1: {
297
+ id: 'strong_1',
298
+ type: 'strong'
299
+ },
300
+ section_1: {
301
+ id: 'section_1',
302
+ type: 'section'
303
+ },
225
304
  nav_item_1: {
226
305
  id: 'nav_item_1',
227
306
  type: 'nav_item',
@@ -231,37 +310,92 @@ const doc = {
231
310
  nav_1: {
232
311
  id: 'nav_1',
233
312
  type: 'nav',
234
- nav_items: ['nav_item_1']
313
+ nav_items: { nodes: ['nav_item_1'], marks: [], annotations: [] }
235
314
  },
236
315
  paragraph_1: {
237
316
  id: 'paragraph_1',
238
317
  type: 'paragraph',
239
- content: { text: 'Hello world.', annotations: [] }
318
+ content: {
319
+ content: 'Hello world.',
320
+ marks: [{ start_offset: 0, end_offset: 5, node_id: 'strong_1' }],
321
+ annotations: []
322
+ }
240
323
  },
241
324
  list_item_1: {
242
325
  id: 'list_item_1',
243
326
  type: 'list_item',
244
- content: { text: 'First list item', annotations: [] }
327
+ content: { content: 'First list item', marks: [], annotations: [] }
245
328
  },
246
329
  list_item_2: {
247
330
  id: 'list_item_2',
248
331
  type: 'list_item',
249
- content: { text: 'Second list item', annotations: [] }
332
+ content: { content: 'Second list item', marks: [], annotations: [] }
250
333
  },
251
334
  list_1: {
252
335
  id: 'list_1',
253
336
  type: 'list',
254
- list_items: ['list_item_1', 'list_item_2']
337
+ list_items: { nodes: ['list_item_1', 'list_item_2'], marks: [], annotations: [] }
255
338
  },
256
339
  page_1: {
257
340
  id: 'page_1',
258
341
  type: 'page',
259
- body: ['nav_1', 'paragraph_1', 'list_1']
342
+ body: {
343
+ nodes: ['nav_1', 'paragraph_1', 'list_1'],
344
+ marks: [{ start_offset: 1, end_offset: 3, node_id: 'section_1' }],
345
+ annotations: []
346
+ }
260
347
  }
261
348
  }
262
349
  };
263
350
  ```
264
351
 
352
+ ### Marks and annotations
353
+
354
+ Marks and annotations are regular nodes attached to a range in a property value. Both use the same attachment shape:
355
+
356
+ ```js
357
+ {
358
+ start_offset: 0,
359
+ end_offset: 5,
360
+ node_id: 'strong_1'
361
+ }
362
+ ```
363
+
364
+ They differ in semantics:
365
+
366
+ - **Marks** are part of the content (bold, italic, link, section). They are mutually exclusive within a property and are rendered in-place by `<TextProperty>`/`<NodeArrayProperty>`.
367
+ - **Annotations** are metadata layered over the content (comments, markers). They may overlap marks and each other, and are data-only: Svedit keeps and transforms the data (and adds CSS classes to covered node wrappers), but your app is responsible for interpreting it. Annotation types must not have registered components.
368
+
369
+ For `text` properties, offsets address character positions in the `content` string:
370
+
371
+ ```js
372
+ {
373
+ id: 'paragraph_1',
374
+ type: 'paragraph',
375
+ content: {
376
+ content: 'Hello world.',
377
+ marks: [{ start_offset: 0, end_offset: 5, node_id: 'strong_1' }],
378
+ annotations: [{ start_offset: 3, end_offset: 8, node_id: 'comment_1' }]
379
+ }
380
+ }
381
+ ```
382
+
383
+ For `node_array` properties, offsets address node positions in the `nodes` array. This lets you wrap a contiguous group of child nodes with the same range model:
384
+
385
+ ```js
386
+ {
387
+ id: 'page_1',
388
+ type: 'page',
389
+ body: {
390
+ nodes: ['paragraph_1', 'paragraph_2', 'paragraph_3'],
391
+ marks: [{ start_offset: 1, end_offset: 3, node_id: 'section_1' }],
392
+ annotations: []
393
+ }
394
+ }
395
+ ```
396
+
397
+ The ranges are half-open: `start_offset` is included, `end_offset` is excluded. Marks must not overlap; annotations may overlap (including same-type annotations created through lower-level APIs).
398
+
265
399
  ### Document schema changes
266
400
 
267
401
  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.
@@ -277,7 +411,7 @@ const document_schema = {
277
411
  properties: {
278
412
  layout: { type: 'integer', default: 1 },
279
413
  content: {
280
- type: 'annotated_text',
414
+ type: 'text',
281
415
  allow_newlines: true
282
416
  }
283
417
  }
@@ -288,7 +422,7 @@ const migrated_doc = fill_document_defaults(existing_doc, document_schema);
288
422
  const session = new Session(document_schema, migrated_doc, config);
289
423
  ```
290
424
 
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.
425
+ 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 `text`. Existing values are preserved, and the original document object is not mutated.
292
426
 
293
427
  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
428
 
@@ -321,7 +455,7 @@ const session_config = {
321
455
 
322
456
  // Functions that create and insert new nodes
323
457
  inserters: {
324
- text: (tr, content = {text: '', annotations: []}) => {
458
+ text: (tr, content = { content: '', marks: [], annotations: [] }) => {
325
459
  const text_id = nanoid();
326
460
  tr.create({ id: text_id, type: 'text', content });
327
461
  tr.insert_nodes([text_id]);
@@ -369,11 +503,11 @@ const session = new Session(schema, doc, config);
369
503
  ### Reading the graph
370
504
 
371
505
  ```js
372
- session.get(['page_1', 'body']); // => ['nav_1', 'paragraph_1', 'list_1']
506
+ session.get(['page_1', 'body']); // => { nodes: ['nav_1', 'paragraph_1', 'list_1'], marks: [], annotations: [] }
373
507
  session.get(['nav_1']); // => { id: 'nav_1', type: 'nav', ... }
374
508
  session.get('nav_1'); // => shorthand for above (single node ID)
375
509
  session.inspect(['page_1', 'body']); // => { kind: 'property', type: 'node_array', node_types: [...] }
376
- session.kind(node); // => 'text', 'block', or 'annotation'
510
+ session.kind(node); // => 'text', 'block', 'mark', or 'annotation'
377
511
  ```
378
512
 
379
513
  ### Selection and state
@@ -381,8 +515,12 @@ session.kind(node); // => 'text', 'block', or 'annotation'
381
515
  ```js
382
516
  session.selection; // Current selection (text, node, or property)
383
517
  session.selected_node; // The currently selected node (derived)
384
- session.active_annotation('strong'); // Check if annotation is active at caret
518
+ session.selected_marks; // Mark records touched by the current selection (derived)
519
+ session.active_mark; // The selected mark record when exactly one mark is touched, otherwise null
520
+ session.selected_annotations; // Annotation records touched by the current selection (derived)
521
+ session.active_annotation; // The selected annotation record when exactly one annotation is touched, otherwise null
385
522
  session.can_insert('paragraph'); // Check if node type can be inserted
523
+ session.available_mark_types; // Mark types allowed at current selection (derived)
386
524
  session.available_annotation_types; // Annotation types allowed at current selection (derived)
387
525
  ```
388
526
 
@@ -392,7 +530,7 @@ Every selection that can exist in Svedit must have a DOM representation. This is
392
530
 
393
531
  This applies to all selection types:
394
532
 
395
- - Text selections are native selections inside an `annotated_text` property.
533
+ - Text selections are native selections inside a `text` property.
396
534
  - Node selections are native selections whose anchor and focus land inside the selected node range.
397
535
  - Property selections are native selections inside the DOM representation of that property.
398
536
 
@@ -515,7 +653,7 @@ function insert_heading(tr) {
515
653
 
516
654
  // Create and insert a heading node
517
655
  const heading_id = tr.generate_id();
518
- tr.create({ id: heading_id, type: 'heading', content: { text: '', annotations: [] } });
656
+ tr.create({ id: heading_id, type: 'heading', content: { content: '', marks: [], annotations: [] } });
519
657
  tr.insert_nodes([heading_id]);
520
658
 
521
659
  return true;
@@ -538,7 +676,7 @@ session.apply(tr); // Apply atomically
538
676
 
539
677
  ```js
540
678
  // Create a new node (must include all required properties from schema)
541
- tr.create({ id: 'paragraph_1', type: 'paragraph', content: { text: '', annotations: [] } });
679
+ tr.create({ id: 'paragraph_1', type: 'paragraph', content: { content: '', marks: [], annotations: [] } });
542
680
 
543
681
  // Delete a node (cascades to unreferenced child nodes)
544
682
  tr.delete('paragraph_26');
@@ -556,7 +694,7 @@ const new_node_id = tr.build('the_list', {
556
694
  the_list: {
557
695
  id: 'the_list',
558
696
  type: 'list',
559
- list_items: ['first_item']
697
+ list_items: { nodes: ['first_item'], marks: [], annotations: [] }
560
698
  }
561
699
  });
562
700
  ```
@@ -567,9 +705,12 @@ const new_node_id = tr.build('the_list', {
567
705
  // Insert text at caret (replaces selection if expanded)
568
706
  tr.insert_text('Hello');
569
707
 
570
- // Toggle annotation on selected text
571
- tr.annotate_text('strong');
572
- tr.annotate_text('link', { href: 'https://example.com' });
708
+ // Toggle mark on selected text (marks are mutually exclusive)
709
+ tr.toggle_mark('strong');
710
+ tr.toggle_mark('link', { href: 'https://example.com' });
711
+
712
+ // Toggle annotation on selected text (competes only with same-type annotations)
713
+ tr.toggle_annotation('comment');
573
714
 
574
715
  // Delete selected text or nodes
575
716
  tr.delete_selection();
@@ -621,7 +762,7 @@ class ToggleStrongCommand extends Command {
621
762
  }
622
763
 
623
764
  execute() {
624
- this.context.session.apply(this.context.session.tr.annotate_text('strong'));
765
+ this.context.session.apply(this.context.session.tr.toggle_mark('strong'));
625
766
  }
626
767
  }
627
768
  ```
@@ -666,7 +807,8 @@ Svedit provides several [core commands](src/lib/Command.svelte.js) out of the bo
666
807
  - `UndoCommand` - Undo the last change
667
808
  - `RedoCommand` - Redo the last undone change
668
809
  - `SelectParentCommand` - Select the parent of the current selection
669
- - `ToggleAnnotationCommand` - Toggle text annotations (bold, italic, etc.)
810
+ - `ToggleMarkCommand` - Toggle marks on text or node-array selections
811
+ - `ToggleAnnotationCommand` - Toggle annotations on text or node-array selections
670
812
  - `AddNewLineCommand` - Insert newline character in text
671
813
  - `BreakTextNodeCommand` - Split text node at caret
672
814
  - `SelectAllCommand` - Progressively expand selection
@@ -681,8 +823,8 @@ create_commands_and_keymap: (context) => {
681
823
  const commands = {
682
824
  undo: new UndoCommand(context),
683
825
  redo: new RedoCommand(context),
684
- toggle_strong: new ToggleAnnotationCommand('strong', context),
685
- toggle_emphasis: new ToggleAnnotationCommand('emphasis', context)
826
+ toggle_strong: new ToggleMarkCommand('strong', context),
827
+ toggle_emphasis: new ToggleMarkCommand('emphasis', context)
686
828
  // ... more commands
687
829
  };
688
830
 
@@ -714,15 +856,15 @@ Commands can have derived state for reactive UI binding. The `active` property i
714
856
 
715
857
  ```js
716
858
  class ToggleEmphasisCommand extends Command {
717
- // Automatically recomputes when annotation state changes
718
- active = $derived(this.context.session.active_annotation('emphasis'));
859
+ // Automatically recomputes when mark state changes
860
+ active = $derived(this.context.session.active_mark?.node.type === 'emphasis');
719
861
 
720
862
  is_enabled() {
721
863
  return this.context.editable && this.context.session.selection?.type === 'text';
722
864
  }
723
865
 
724
866
  execute() {
725
- this.context.session.apply(this.context.session.tr.annotate_text('emphasis'));
867
+ this.context.session.apply(this.context.session.tr.toggle_mark('emphasis'));
726
868
  }
727
869
  }
728
870
  ```
@@ -957,13 +1099,13 @@ A typical node component follows this pattern:
957
1099
 
958
1100
  ```svelte
959
1101
  <script>
960
- import { Node, AnnotatedTextProperty } from 'svedit';
1102
+ import { Node, TextProperty } from 'svedit';
961
1103
  let { path } = $props();
962
1104
  </script>
963
1105
 
964
1106
  <Node {path}>
965
1107
  <div class="my-node">
966
- <AnnotatedTextProperty path={[...path, 'content']} />
1108
+ <TextProperty path={[...path, 'content']} />
967
1109
  </div>
968
1110
  </Node>
969
1111
  ```
@@ -980,15 +1122,10 @@ Every node component must wrap its content in the `<Node>` component. This wrapp
980
1122
 
981
1123
  Svedit provides specialized components for rendering different property types:
982
1124
 
983
- **`<AnnotatedTextProperty>`** - For editable text content with inline formatting:
1125
+ **`<TextProperty>`** - For editable text content with inline marks:
984
1126
 
985
1127
  ```svelte
986
- <AnnotatedTextProperty
987
- tag="p"
988
- class="body"
989
- path={[...path, 'content']}
990
- placeholder="Enter text here"
991
- />
1128
+ <TextProperty tag="p" class="body" path={[...path, 'content']} placeholder="Enter text here" />
992
1129
  ```
993
1130
 
994
1131
  **`<NodeArrayProperty>`** - For container properties that hold multiple nodes:
@@ -997,6 +1134,40 @@ Svedit provides specialized components for rendering different property types:
997
1134
  <NodeArrayProperty class="list-items" path={[...path, 'list_items']} />
998
1135
  ```
999
1136
 
1137
+ Node arrays can also carry marks and annotations when their schema includes `mark_types`/`annotation_types`. The value still has one `nodes` array, plus `marks` and `annotations` arrays whose offsets refer to node indexes:
1138
+
1139
+ ```js
1140
+ buttons: {
1141
+ nodes: ['button_1', 'button_2', 'button_3'],
1142
+ marks: [{ start_offset: 0, end_offset: 2, node_id: 'section_1' }],
1143
+ annotations: []
1144
+ }
1145
+ ```
1146
+
1147
+ `<NodeArrayProperty>` wraps marked ranges with the matching mark component, just like `<TextProperty>` does for text ranges. Child node components also receive range context:
1148
+
1149
+ ```svelte
1150
+ <script>
1151
+ let { path, mark: section = null, annotations = [] } = $props();
1152
+ </script>
1153
+
1154
+ <div class:section-start={section?.is_start}>
1155
+ <!-- render node content -->
1156
+ </div>
1157
+ ```
1158
+
1159
+ `mark` is the mark wrapping the child node, or `null` when none does — mark exclusivity guarantees there is at most one. `annotations` contains all annotations covering the child node, including overlapping ones. Annotations are data-only: they never wrap and are never rendered as components. Each entry contains the flattened attachment plus context for the current child node: `start_offset`, `end_offset`, `node_id`, `index`, `node`, `is_start`, `is_middle`, `is_end`. `node` is the resolved mark or annotation node, `index` is the position in the parent `marks`/`annotations` array, and the `is_*` flags describe the current child node's position in the attached range.
1160
+
1161
+ For styling, you usually don't need the props at all: `<Node>` adds range classes to the node wrapper automatically. A node covered by a `section` mark gets `mark-section`, a node covered by a `marker` annotation gets `anno-marker` — plus `-start`/`-end` variants on the run's first/last covered node. So styling a mark or annotation type — including types without a component — is pure CSS:
1162
+
1163
+ ```css
1164
+ .anno-marker {
1165
+ background: color-mix(in oklch, var(--app-primary-fill) 13%, transparent);
1166
+ }
1167
+ ```
1168
+
1169
+ Use the `annotations` prop only when a component should render something richer than styles (badges, margin indicators, etc.).
1170
+
1000
1171
  > **One path = one DOM mount.** Within a single Svedit document, each node path
1001
1172
  > may be mounted exactly once. Mounting the same path twice (e.g. rendering the
1002
1173
  > same `nav_items` array in both header and footer) breaks anchor positioning,
@@ -1012,7 +1183,7 @@ Svedit provides specialized components for rendering different property types:
1012
1183
  ```svelte
1013
1184
  <CustomProperty class="image-wrapper" path={[...path, 'image']}>
1014
1185
  <div contenteditable="false">
1015
- <img src={node.image} alt={node.title.text} />
1186
+ <img src={node.image} alt={node.title.content} />
1016
1187
  </div>
1017
1188
  </CustomProperty>
1018
1189
  ```
@@ -1039,7 +1210,7 @@ Here's a complete example of a text node component that supports multiple layout
1039
1210
  ```svelte
1040
1211
  <script>
1041
1212
  import { getContext } from 'svelte';
1042
- import { Node, AnnotatedTextProperty } from 'svedit';
1213
+ import { Node, TextProperty } from 'svedit';
1043
1214
 
1044
1215
  const svedit = getContext('svedit');
1045
1216
  let { path } = $props();
@@ -1050,12 +1221,7 @@ Here's a complete example of a text node component that supports multiple layout
1050
1221
 
1051
1222
  <Node {path}>
1052
1223
  <div class="text layout-{layout}">
1053
- <AnnotatedTextProperty
1054
- {tag}
1055
- class="body"
1056
- path={[...path, 'content']}
1057
- placeholder="Enter text"
1058
- />
1224
+ <TextProperty {tag} class="body" path={[...path, 'content']} placeholder="Enter text" />
1059
1225
  </div>
1060
1226
  </Node>
1061
1227
  ```
@@ -1113,7 +1279,7 @@ a simplified version of the markup of `<NodeGap>` and why it is implemented the
1113
1279
  And using <span></span> will not make it addressable at all.
1114
1280
 
1115
1281
  Svedit uses this behavior for node gaps, and when an
1116
- <AnnotatedTextProperty> is empty.
1282
+ <TextProperty> is empty.
1117
1283
  -->
1118
1284
  <div class="node-gap"><br /></div>
1119
1285
  <!--
@@ -1132,8 +1298,8 @@ a simplified version of the markup of `<NodeGap>` and why it is implemented the
1132
1298
  Further things to consider:
1133
1299
 
1134
1300
  - 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`>)
1135
- - `<AnnotatedTextProperty>` and `<CustomProperty>` must not be wrapped in `contenteditable="false"` to work properly.
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.
1301
+ - `<TextProperty>` and `<CustomProperty>` must not be wrapped in `contenteditable="false"` to work properly.
1302
+ - Never apply `position: relative` to the direct parent of `<TextProperty>`, it will cause a [weird Safari bug](https://bsky.app/profile/michaelaufreiter.com/post/3lxvdqyxc622s) to destroy the DOM.
1137
1303
  - 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.
1138
1304
  - 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` ).
1139
1305
  - 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.
@@ -71,21 +71,37 @@ export class SelectParentCommand extends Command {
71
71
  execute(): void;
72
72
  }
73
73
  /**
74
- * Generic command that toggles an annotation on the current text selection.
75
- * Used for simple annotations like bold, italic, highlight, etc.
74
+ * Generic command that toggles a mark on the current text or node selection.
75
+ *
76
+ * Marks are mutually exclusive, so all touched marks compete: same-type marks
77
+ * are removed, a single touched property-less mark may switch type, and mixed
78
+ * touched types disable the command. Annotations never affect mark toggling.
76
79
  */
77
- export class ToggleAnnotationCommand extends Command {
80
+ export class ToggleMarkCommand extends Command {
78
81
  constructor(node_type: any, context: any);
79
82
  node_type: any;
80
83
  active: any;
81
84
  is_active(): any;
82
- is_enabled(): any;
85
+ execute(): void;
86
+ }
87
+ /**
88
+ * Generic command that toggles an annotation on the current text or node selection.
89
+ *
90
+ * Annotations only compete with touched annotations of the same type: touched
91
+ * same-type annotations are removed, otherwise a new annotation is created.
92
+ * Marks and other annotation types never block the toggle.
93
+ */
94
+ export class ToggleAnnotationCommand extends Command {
95
+ constructor(node_type: any, context: any);
96
+ node_type: any;
97
+ active: boolean;
98
+ relevant_annotations(): any;
99
+ is_active(): boolean;
83
100
  execute(): void;
84
101
  }
85
102
  /**
86
103
  * Command that adds a new line character at the current caret position.
87
104
  * Only works in text selections where newlines are allowed.
88
- * Disabled on mobile browsers where Shift+Enter has different behavior.
89
105
  */
90
106
  export class AddNewLineCommand extends Command {
91
107
  is_enabled(): any;
@@ -1 +1 @@
1
- {"version":3,"file":"Command.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Command.svelte.js"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH;IAOC;;;;OAIG;IACH,qBAFW,GAAG,EAIb;IAbD;;;OAGG;IACH,kBAAwC;IAQvC,aAAsB;IAGvB;;;;;OAKG;IACH,cAFa,OAAO,CAInB;IAED;;;;;;OAMG;IACH,WAFa,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhC;CACD;AAED;;GAEG;AACH;IACC,kBAEC;IAED,gBAEC;CACD;AAED;;GAEG;AACH;IACC,kBAEC;IAED,gBAEC;CACD;AAED;;;GAGG;AACH;IASC,gBAEC;CACD;AAED;;;GAGG;AACH;IACC,0CAGC;IADA,eAA0B;IAG3B,YAAoC;IAEpC,iBAEC;IAED,kBAqBC;IAED,gBAEC;CACD;AAED;;;;GAIG;AACH;IACC,kBAUC;IAED,gBAqBC;CACD;AAED;;;;GAIG;AACH;IAaC,gBAKC;CACD;AAED;;;GAGG;AACH;IACC,kBAEC;IAED,gBAwGC;CACD;AAED;;;GAGG;AACH;IAUC,gBAIC;CACD"}
1
+ {"version":3,"file":"Command.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Command.svelte.js"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH;IAOC;;;;OAIG;IACH,qBAFW,GAAG,EAIb;IAbD;;;OAGG;IACH,kBAAwC;IAQvC,aAAsB;IAGvB;;;;;OAKG;IACH,cAFa,OAAO,CAInB;IAED;;;;;;OAMG;IACH,WAFa,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhC;CACD;AAED;;GAEG;AACH;IACC,kBAEC;IAED,gBAEC;CACD;AAED;;GAEG;AACH;IACC,kBAEC;IAED,gBAEC;CACD;AAED;;;GAGG;AACH;IASC,gBAEC;CACD;AAED;;;;;;GAMG;AACH;IACC,0CAGC;IADA,eAA0B;IAG3B,YAAoC;IAEpC,iBAMC;IA2BD,gBAEC;CACD;AAED;;;;;;GAMG;AACH;IACC,0CAGC;IADA,eAA0B;IAG3B,gBAAoC;IAIpC,4BAIC;IAED,qBAEC;IAmBD,gBAEC;CACD;AAED;;;GAGG;AACH;IACC,kBASC;IAED,gBAqBC;CACD;AAED;;;;GAIG;AACH;IAaC,gBAKC;CACD;AAED;;;GAGG;AACH;IACC,kBAEC;IAED,gBAwGC;CACD;AAED;;;GAGG;AACH;IAUC,gBAIC;CACD"}