svedit 0.11.1 → 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.
- package/README.md +230 -64
- package/dist/Command.svelte.d.ts +21 -4
- package/dist/Command.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.js +85 -27
- package/dist/Node.svelte +29 -1
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +119 -12
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/NodeGap.svelte +48 -6
- package/dist/NodeGap.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.d.ts +31 -14
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +155 -77
- package/dist/Svedit.svelte +110 -89
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
- package/dist/TextProperty.svelte.d.ts +7 -0
- package/dist/TextProperty.svelte.d.ts.map +1 -0
- package/dist/Transaction.svelte.d.ts +78 -40
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +412 -234
- package/dist/doc_utils.d.ts +96 -21
- package/dist/doc_utils.d.ts.map +1 -1
- package/dist/doc_utils.js +440 -168
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node_visibility.svelte.d.ts +30 -4
- package/dist/node_visibility.svelte.d.ts.map +1 -1
- package/dist/node_visibility.svelte.js +327 -363
- package/dist/paste_utils.d.ts +3 -2
- package/dist/paste_utils.d.ts.map +1 -1
- package/dist/paste_utils.js +16 -13
- package/dist/transforms.svelte.d.ts.map +1 -1
- package/dist/transforms.svelte.js +5 -6
- package/dist/types.d.ts +112 -42
- package/dist/utils.d.ts +75 -12
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +292 -78
- package/package.json +4 -1
- package/src/lib/Command.svelte.js +85 -27
- package/src/lib/Node.svelte +29 -1
- package/src/lib/NodeArrayProperty.svelte +119 -12
- package/src/lib/NodeGap.svelte +48 -6
- package/src/lib/Session.svelte.js +155 -77
- package/src/lib/Svedit.svelte +110 -89
- package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
- package/src/lib/Transaction.svelte.js +412 -234
- package/src/lib/doc_utils.js +440 -168
- package/src/lib/index.js +1 -1
- package/src/lib/node_visibility.svelte.js +327 -363
- package/src/lib/paste_utils.js +16 -13
- package/src/lib/transforms.svelte.js +5 -6
- package/src/lib/types.d.ts +112 -42
- package/src/lib/utils.js +292 -78
- package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
- 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
|
-
- `
|
|
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** `
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
|
|
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|
|
|
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
|
-
- `
|
|
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: '
|
|
148
|
-
|
|
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: '
|
|
158
|
-
|
|
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: '
|
|
201
|
+
kind: 'mark',
|
|
192
202
|
properties: {}
|
|
193
203
|
},
|
|
194
204
|
emphasis: {
|
|
195
|
-
kind: '
|
|
205
|
+
kind: 'mark',
|
|
196
206
|
properties: {}
|
|
197
207
|
},
|
|
198
208
|
link: {
|
|
199
|
-
kind: '
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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:
|
|
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: '
|
|
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 `
|
|
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 = {
|
|
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.
|
|
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
|
|
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: {
|
|
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: {
|
|
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
|
|
571
|
-
tr.
|
|
572
|
-
tr.
|
|
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.
|
|
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
|
-
- `
|
|
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
|
|
685
|
-
toggle_emphasis: new
|
|
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
|
|
718
|
-
active = $derived(this.context.session.
|
|
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.
|
|
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,
|
|
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
|
-
<
|
|
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
|
-
**`<
|
|
1125
|
+
**`<TextProperty>`** - For editable text content with inline marks:
|
|
984
1126
|
|
|
985
1127
|
```svelte
|
|
986
|
-
<
|
|
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.
|
|
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,
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
- `<
|
|
1136
|
-
- Never apply `position: relative` to the direct parent of `<
|
|
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.
|
package/dist/Command.svelte.d.ts
CHANGED
|
@@ -71,15 +71,32 @@ export class SelectParentCommand extends Command {
|
|
|
71
71
|
execute(): void;
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
74
|
-
* Generic command that toggles
|
|
75
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -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
|
|
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"}
|