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