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