svedit 0.6.3 → 0.7.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 +117 -45
- package/dist/AnnotatedTextProperty.svelte +19 -16
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.d.ts +3 -3
- package/dist/Command.svelte.js +5 -5
- package/dist/CustomProperty.svelte +5 -4
- package/dist/Node.svelte +29 -14
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +29 -9
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/NodeCaret.svelte +71 -0
- package/dist/NodeCaret.svelte.d.ts +27 -0
- package/dist/NodeCaret.svelte.d.ts.map +1 -0
- package/dist/NodeGap.svelte +249 -0
- package/dist/NodeGap.svelte.d.ts +20 -0
- package/dist/NodeGap.svelte.d.ts.map +1 -0
- package/dist/NodeGapMarkers.svelte +456 -0
- package/dist/NodeGapMarkers.svelte.d.ts +12 -0
- package/dist/NodeGapMarkers.svelte.d.ts.map +1 -0
- package/dist/NodeSelectionMarkers.svelte +49 -0
- package/dist/NodeSelectionMarkers.svelte.d.ts +7 -0
- package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -0
- package/dist/Session.svelte.d.ts +6 -6
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +13 -69
- package/dist/Svedit.svelte +158 -90
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +1 -1
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +10 -70
- package/dist/doc_utils.d.ts +12 -0
- package/dist/doc_utils.d.ts.map +1 -1
- package/dist/doc_utils.js +61 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/node_gap_computation.svelte.d.ts +5 -0
- package/dist/node_gap_computation.svelte.d.ts.map +1 -0
- package/dist/node_gap_computation.svelte.js +621 -0
- package/dist/styles/svedit-colors.css +8 -0
- package/dist/transforms.svelte.d.ts.map +1 -1
- package/dist/transforms.svelte.js +7 -10
- package/dist/types.d.ts +56 -68
- package/dist/utils.d.ts +0 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +10 -10
- package/package.json +21 -21
- package/src/lib/AnnotatedTextProperty.svelte +19 -16
- package/src/lib/Command.svelte.js +5 -5
- package/src/lib/CustomProperty.svelte +5 -4
- package/src/lib/Node.svelte +29 -14
- package/src/lib/NodeArrayProperty.svelte +29 -9
- package/src/lib/NodeCaret.svelte +71 -0
- package/src/lib/NodeGap.svelte +249 -0
- package/src/lib/NodeGapMarkers.svelte +456 -0
- package/src/lib/NodeSelectionMarkers.svelte +49 -0
- package/src/lib/Session.svelte.js +13 -69
- package/src/lib/Svedit.svelte +158 -90
- package/src/lib/Transaction.svelte.js +10 -70
- package/src/lib/doc_utils.js +61 -2
- package/src/lib/index.js +4 -0
- package/src/lib/node_gap_computation.svelte.js +621 -0
- package/src/lib/styles/svedit-colors.css +8 -0
- package/src/lib/transforms.svelte.js +7 -10
- package/src/lib/types.d.ts +56 -68
- package/src/lib/utils.js +10 -10
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Svedit (think Svelte Edit) is a tiny library for building editable websites in Svelte. You can model your content in JSON, render it with custom Svelte components, and (this is the kicker) site owners can **edit their site directly in the layout** — no CMS needed.
|
|
4
4
|
|
|
5
|
-
Try the [demo](https://svedit.dev)
|
|
5
|
+
- Try the [Svedit demo](https://svedit.dev) — watch the debug output at the bottom of the page
|
|
6
|
+
- Try [Editable Website](https://editable.website) — utilizes Svedit to enable CMS-free editable websites
|
|
6
7
|
|
|
7
8
|
## Why Svedit?
|
|
8
9
|
|
|
@@ -10,32 +11,24 @@ Because Svelte‘s reactivity system is **the perfect fit** for building super-l
|
|
|
10
11
|
|
|
11
12
|
## Getting started
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
The fastest way to get started is to clone the `hello-svedit` template and turn it into your own project:
|
|
14
15
|
|
|
15
16
|
```bash
|
|
16
17
|
git clone https://github.com/michael/hello-svedit
|
|
17
18
|
cd hello-svedit
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Install dependencies:
|
|
21
|
-
|
|
22
|
-
```bash
|
|
23
19
|
npm install
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
And run the development server:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
20
|
npm run dev
|
|
30
21
|
```
|
|
31
22
|
|
|
32
23
|
Now make it your own. The next thing you probably want to do is define your own [node types](./src/routes/create_demo_session.js), add a [Toolbar](./src/routes/components/Toolbar.svelte), and render custom [Overlays](./src/routes/components/Overlays.svelte). For that just get inspired by the [Svedit demo code](./src/routes).
|
|
33
24
|
|
|
25
|
+
You can also install Svedit into an existing SvelteKit project with `npm install svedit`, but you'll need to set up the session, schema, config, and components yourself. See the [hello-svedit repo](https://github.com/michael/hello-svedit) or this repo's [`src/routes`](./src/routes) for reference.
|
|
26
|
+
|
|
34
27
|
## Principles
|
|
35
28
|
|
|
36
29
|
**Simplicity over completeness:** Svedit doesn't guess what your app needs or offer ready-made blocks. Instead, we keep the core lean and provide carefully crafted examples showing how to build anything on top — without compromising flexibility.
|
|
37
30
|
|
|
38
|
-
**White-box library:** We expose the internals of the library to allow you to customize and extend it to your needs. That means a little bit more work upfront, but in return lets you control "everything" — the toolbar, the overlays, or how fast the node
|
|
31
|
+
**White-box library:** We expose the internals of the library to allow you to customize and extend it to your needs. That means a little bit more work upfront, but in return lets you control "everything" — the toolbar, the overlays, or how fast the node caret blinks.
|
|
39
32
|
|
|
40
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.
|
|
41
34
|
|
|
@@ -69,15 +62,60 @@ Each node has a `kind` that determines its behavior:
|
|
|
69
62
|
- `text`: A node with editable text content (can be split and joined)
|
|
70
63
|
- `annotation`: An inline annotation applied to text (bold, link, etc.)
|
|
71
64
|
|
|
65
|
+
### Choosing between `text` and `block`
|
|
66
|
+
|
|
67
|
+
`kind: 'text'` opts into the split/join system (`break_text_node`, `join_text_node`), which assumes:
|
|
68
|
+
- The node has exactly **one** `annotated_text` property named **`content`**
|
|
69
|
+
- Pressing Enter splits the node into two nodes of the same type
|
|
70
|
+
- Pressing Backspace at position 0 joins it with the previous node
|
|
71
|
+
|
|
72
|
+
If **any** of those assumptions don't hold, use `kind: 'block'`. Blocks can still have `annotated_text` properties with full editing support (typing, formatting, selection) — they just don't participate in split/join.
|
|
73
|
+
|
|
74
|
+
> **Common mistake:** A quote node with `content` + `author` properties might seem like `kind: 'text'` because both fields are editable text. But splitting a quote into two half-quotes doesn't make sense, and `join_text_node` hard-codes `node.content` — so Backspace in the `author` field would join the wrong property with the previous block and drop the author text. The correct kind is `'block'`.
|
|
75
|
+
|
|
76
|
+
<details>
|
|
77
|
+
<summary>Decision tree for choosing a node kind</summary>
|
|
78
|
+
|
|
79
|
+
```mermaid
|
|
80
|
+
flowchart TD
|
|
81
|
+
Start(["I need a new node type"])
|
|
82
|
+
Q1{"Is it inline formatting applied within text?"}
|
|
83
|
+
Q2{"Is it a top-level routable entry point?"}
|
|
84
|
+
Q3{"Is text the node's entire purpose?"}
|
|
85
|
+
Q4{"Does it have exactly one annotated_text property?"}
|
|
86
|
+
Q5{"Does pressing Enter to split into two nodes make sense?"}
|
|
87
|
+
|
|
88
|
+
KindAnnotation["kind: annotation e.g. bold, italic, link"]
|
|
89
|
+
KindDocument["kind: document e.g. page, article, event"]
|
|
90
|
+
KindBlock["kind: block e.g. image+caption, quote+author, list, nav, embed"]
|
|
91
|
+
KindText["kind: text e.g. paragraph, heading, list_item"]
|
|
92
|
+
|
|
93
|
+
Start --> Q1
|
|
94
|
+
Q1 -->|Yes| KindAnnotation
|
|
95
|
+
Q1 -->|No| Q2
|
|
96
|
+
Q2 -->|Yes| KindDocument
|
|
97
|
+
Q2 -->|No| Q3
|
|
98
|
+
Q3 -->|"No — it has non-text properties or children"| KindBlock
|
|
99
|
+
Q3 -->|Yes| Q4
|
|
100
|
+
Q4 -->|"No — multiple text properties"| KindBlock
|
|
101
|
+
Q4 -->|Yes| Q5
|
|
102
|
+
Q5 -->|"No — splitting doesn't make sense"| KindBlock
|
|
103
|
+
Q5 -->|Yes| KindText
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
</details>
|
|
107
|
+
|
|
72
108
|
Properties of nodes can hold values:
|
|
73
109
|
- `string`: A good old JavaScript string
|
|
74
110
|
- `number`: Just like a number in JavaScript
|
|
75
111
|
- `integer`: A number for which Number.isInteger(number) returns true
|
|
76
112
|
- `boolean`: true or false
|
|
77
|
-
- `
|
|
78
|
-
- `
|
|
113
|
+
- `datetime`: A date/time string parseable by `Date.parse()`
|
|
114
|
+
- `string_array`: An array of strings
|
|
79
115
|
- `number_array`: An array of numbers
|
|
80
|
-
- `
|
|
116
|
+
- `integer_array`: An array of integers
|
|
117
|
+
- `boolean_array`: An array of booleans
|
|
118
|
+
- `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).
|
|
81
119
|
|
|
82
120
|
Or references:
|
|
83
121
|
- `node`: References a single node (e.g. an image node can reference a global asset node)
|
|
@@ -99,13 +137,21 @@ const document_schema = {
|
|
|
99
137
|
paragraph: {
|
|
100
138
|
kind: 'text',
|
|
101
139
|
properties: {
|
|
102
|
-
content: {
|
|
140
|
+
content: {
|
|
141
|
+
type: 'annotated_text',
|
|
142
|
+
node_types: ['strong', 'emphasis', 'link'],
|
|
143
|
+
allow_newlines: true
|
|
144
|
+
}
|
|
103
145
|
}
|
|
104
146
|
},
|
|
105
147
|
list_item: {
|
|
106
148
|
kind: 'text',
|
|
107
149
|
properties: {
|
|
108
|
-
content: {
|
|
150
|
+
content: {
|
|
151
|
+
type: 'annotated_text',
|
|
152
|
+
node_types: ['strong', 'emphasis', 'link'],
|
|
153
|
+
allow_newlines: true
|
|
154
|
+
},
|
|
109
155
|
}
|
|
110
156
|
},
|
|
111
157
|
list: {
|
|
@@ -134,10 +180,26 @@ const document_schema = {
|
|
|
134
180
|
url: { type: 'string' },
|
|
135
181
|
label: { type: 'string' },
|
|
136
182
|
}
|
|
183
|
+
},
|
|
184
|
+
strong: {
|
|
185
|
+
kind: 'annotation',
|
|
186
|
+
properties: {}
|
|
187
|
+
},
|
|
188
|
+
emphasis: {
|
|
189
|
+
kind: 'annotation',
|
|
190
|
+
properties: {}
|
|
191
|
+
},
|
|
192
|
+
link: {
|
|
193
|
+
kind: 'annotation',
|
|
194
|
+
properties: {
|
|
195
|
+
href: { type: 'string' }
|
|
196
|
+
}
|
|
137
197
|
}
|
|
138
198
|
};
|
|
139
199
|
```
|
|
140
200
|
|
|
201
|
+
Annotation types are defined as nodes with `kind: 'annotation'`. Simple annotations like `strong` and `emphasis` have no properties, while annotations like `link` can carry data (e.g. `href`). Each `annotated_text` property specifies which annotation types are allowed via `node_types` — this lets you control formatting per property (e.g. allow bold and links in body text, but only emphasis in titles).
|
|
202
|
+
|
|
141
203
|
## Document
|
|
142
204
|
|
|
143
205
|
A document is a plain JavaScript object (POJO) with a `document_id` (the entry point) and a `nodes` object containing all content nodes.
|
|
@@ -166,8 +228,7 @@ const doc = {
|
|
|
166
228
|
},
|
|
167
229
|
paragraph_1: {
|
|
168
230
|
id: 'paragraph_1',
|
|
169
|
-
type: '
|
|
170
|
-
layout: 1,
|
|
231
|
+
type: 'paragraph',
|
|
171
232
|
content: { text: 'Hello world.', annotations: [] }
|
|
172
233
|
},
|
|
173
234
|
list_item_1: {
|
|
@@ -203,8 +264,8 @@ const session_config = {
|
|
|
203
264
|
// ID generator for creating new nodes
|
|
204
265
|
generate_id: () => nanoid(),
|
|
205
266
|
|
|
206
|
-
//
|
|
207
|
-
system_components: {
|
|
267
|
+
// User-land overlays and optional system component overrides
|
|
268
|
+
system_components: { Overlays },
|
|
208
269
|
|
|
209
270
|
// Map node types to Svelte components
|
|
210
271
|
node_components: { Page, Text, Story, List, Button, ... },
|
|
@@ -230,7 +291,9 @@ const session_config = {
|
|
|
230
291
|
|
|
231
292
|
- **`generate_id`** - Function that generates unique IDs for new nodes
|
|
232
293
|
- **`node_components`** - Maps each node type from your schema to a Svelte component
|
|
233
|
-
- **`system_components`** -
|
|
294
|
+
- **`system_components`** - Optional overrides for internal editor components and a slot for your own overlays:
|
|
295
|
+
- `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.
|
|
296
|
+
- `NodeGap`, `NodeGapMarkers`, `NodeSelectionMarkers` — Override the default system components if you need custom visuals for node gaps or selection indicators.
|
|
234
297
|
- **`inserters`** - Functions that create blank nodes of each type and set up the selection
|
|
235
298
|
- **`create_commands_and_keymap`** - Factory function that creates commands and keybindings for an editor instance
|
|
236
299
|
- **`handle_image_paste`** - Optional handler for image paste events
|
|
@@ -268,7 +331,7 @@ session.kind(node) // => 'text', 'block', or 'annotation'
|
|
|
268
331
|
```js
|
|
269
332
|
session.selection // Current selection (text, node, or property)
|
|
270
333
|
session.selected_node // The currently selected node (derived)
|
|
271
|
-
session.active_annotation('strong') // Check if annotation is active at
|
|
334
|
+
session.active_annotation('strong') // Check if annotation is active at caret
|
|
272
335
|
session.can_insert('paragraph') // Check if node type can be inserted
|
|
273
336
|
session.available_annotation_types // Annotation types allowed at current selection (derived)
|
|
274
337
|
```
|
|
@@ -333,7 +396,7 @@ Transforms are pure functions that modify a transaction. They encapsulate common
|
|
|
333
396
|
Transforms take a transaction (`tr`) as their parameter and return `true` if successful or `false` if the transform cannot be applied (e.g., wrong selection type or invalid state).
|
|
334
397
|
|
|
335
398
|
```js
|
|
336
|
-
// Example: break a text node at the
|
|
399
|
+
// Example: break a text node at the caret
|
|
337
400
|
import { break_text_node } from 'svedit';
|
|
338
401
|
|
|
339
402
|
const tr = session.tr;
|
|
@@ -347,7 +410,7 @@ if (success) {
|
|
|
347
410
|
|
|
348
411
|
Svedit provides several core transforms in [`src/lib/transforms.svelte.js`](src/lib/transforms.svelte.js):
|
|
349
412
|
|
|
350
|
-
- `break_text_node(tr)` - Split a text node at the
|
|
413
|
+
- `break_text_node(tr)` - Split a text node at the caret position
|
|
351
414
|
- `join_text_node(tr)` - Join current text node with the previous one
|
|
352
415
|
- `insert_default_node(tr)` - Insert a new node at the current selection
|
|
353
416
|
|
|
@@ -377,7 +440,7 @@ function insert_heading(tr) {
|
|
|
377
440
|
// Create and insert a heading node
|
|
378
441
|
const heading_id = tr.generate_id();
|
|
379
442
|
tr.create({ id: heading_id, type: 'heading', content: { text: '', annotations: [] } });
|
|
380
|
-
tr.insert_nodes(
|
|
443
|
+
tr.insert_nodes([heading_id]);
|
|
381
444
|
|
|
382
445
|
return true;
|
|
383
446
|
}
|
|
@@ -413,7 +476,7 @@ const new_node_id = tr.build('the_list', {
|
|
|
413
476
|
id: 'first_item',
|
|
414
477
|
type: 'list_item',
|
|
415
478
|
content: node.content
|
|
416
|
-
}
|
|
479
|
+
},
|
|
417
480
|
the_list: {
|
|
418
481
|
id: 'the_list',
|
|
419
482
|
type: 'list',
|
|
@@ -425,7 +488,7 @@ const new_node_id = tr.build('the_list', {
|
|
|
425
488
|
### Text operations
|
|
426
489
|
|
|
427
490
|
```js
|
|
428
|
-
// Insert text at
|
|
491
|
+
// Insert text at caret (replaces selection if expanded)
|
|
429
492
|
tr.insert_text('Hello');
|
|
430
493
|
|
|
431
494
|
// Toggle annotation on selected text
|
|
@@ -530,9 +593,9 @@ Svedit provides several [core commands](src/lib/Command.svelte.js) out of the bo
|
|
|
530
593
|
- `SelectParentCommand` - Select the parent of the current selection
|
|
531
594
|
- `ToggleAnnotationCommand` - Toggle text annotations (bold, italic, etc.)
|
|
532
595
|
- `AddNewLineCommand` - Insert newline character in text
|
|
533
|
-
- `BreakTextNodeCommand` - Split text node at
|
|
596
|
+
- `BreakTextNodeCommand` - Split text node at caret
|
|
534
597
|
- `SelectAllCommand` - Progressively expand selection
|
|
535
|
-
- `InsertDefaultNodeCommand` - Insert a new node at
|
|
598
|
+
- `InsertDefaultNodeCommand` - Insert a new node at caret
|
|
536
599
|
|
|
537
600
|
#### Using document commands
|
|
538
601
|
|
|
@@ -757,7 +820,13 @@ The KeyMapper tries scopes from top to bottom, so push more specific keymaps las
|
|
|
757
820
|
|
|
758
821
|
Selections are at the heart of Svedit. There are just three types of selections:
|
|
759
822
|
|
|
760
|
-
|
|
823
|
+
### Terminology note
|
|
824
|
+
|
|
825
|
+
- Use "node" as the domain term.
|
|
826
|
+
- Use "node caret" for a collapsed node selection.
|
|
827
|
+
- Use "node gap" for the DOM landing zone between nodes.
|
|
828
|
+
|
|
829
|
+
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'.
|
|
761
830
|
|
|
762
831
|
```js
|
|
763
832
|
{
|
|
@@ -828,7 +897,7 @@ A typical node component follows this pattern:
|
|
|
828
897
|
|
|
829
898
|
Every node component must wrap its content in the `<Node>` component. This wrapper:
|
|
830
899
|
- Registers the node with the editor
|
|
831
|
-
- Handles selection and
|
|
900
|
+
- Handles selection and caret behavior
|
|
832
901
|
- Provides the foundation for editing interactions
|
|
833
902
|
|
|
834
903
|
### Property components
|
|
@@ -941,38 +1010,38 @@ const session_config = {
|
|
|
941
1010
|
}
|
|
942
1011
|
```
|
|
943
1012
|
|
|
944
|
-
The
|
|
1013
|
+
The keys must be PascalCase versions of the snake_case node types in your schema. Svedit converts node types automatically (e.g. `list_item` → `ListItem`, `image_grid` → `ImageGrid`), so a node with `type: "list_item"` will look for a component registered as `ListItem`.
|
|
945
1014
|
|
|
946
1015
|
## Mastering contenteditable
|
|
947
1016
|
|
|
948
1017
|
Svedit relies on the contenteditable attribute to make elements editable. The below example shows you
|
|
949
|
-
a simplified version of the markup of `<
|
|
1018
|
+
a simplified version of the markup of `<NodeGap>` and why it is implemented the way it is.
|
|
950
1019
|
|
|
951
1020
|
```html
|
|
952
1021
|
<div contenteditable="true">
|
|
953
1022
|
<div class="some-wrapper">
|
|
954
1023
|
<!--
|
|
955
|
-
Putting a <br> tag into a div gives you a single addressable
|
|
1024
|
+
Putting a <br> tag into a div gives you a single addressable caret position.
|
|
956
1025
|
|
|
957
|
-
Adding a ​ (or any character) here will lead to 2
|
|
1026
|
+
Adding a ​ (or any character) here will lead to 2 caret
|
|
958
1027
|
positions (one before, and one after the character)
|
|
959
1028
|
|
|
960
1029
|
Using <wbr> will make it only addressable for ArrowLeft and ArrowRight, but not ArrowUp and ArrowDown.
|
|
961
1030
|
And using <span></span> will not make it addressable at all.
|
|
962
1031
|
|
|
963
|
-
Svedit uses this behavior for node
|
|
1032
|
+
Svedit uses this behavior for node gaps, and when an
|
|
964
1033
|
<AnnotatedTextProperty> is empty.
|
|
965
1034
|
-->
|
|
966
|
-
<div class="
|
|
1035
|
+
<div class="node-gap"><br></div>
|
|
967
1036
|
<!--
|
|
968
1037
|
If you create a contenteditable="false" island, there needs to be some content in it,
|
|
969
|
-
otherwise it will create two additional
|
|
1038
|
+
otherwise it will create two additional caret positions. One before, and another one
|
|
970
1039
|
after the island.
|
|
971
1040
|
|
|
972
|
-
The Svedit demo uses this technique in `<
|
|
973
|
-
visualization, that doesn't mess with the contenteditable
|
|
1041
|
+
The Svedit demo uses this technique in `<NodeGap>` to create a node-caret
|
|
1042
|
+
visualization, that doesn't mess with the contenteditable caret positions.
|
|
974
1043
|
-->
|
|
975
|
-
<div contenteditable="false" class="node-
|
|
1044
|
+
<div contenteditable="false" class="node-caret">​</div>
|
|
976
1045
|
</div>
|
|
977
1046
|
</div>
|
|
978
1047
|
```
|
|
@@ -982,11 +1051,14 @@ Further things to consider:
|
|
|
982
1051
|
- If you make a sub-tree `contenteditable="false"`, be aware that you can't create a `contenteditable="true"` segment somewhere inside it. Svedit can only work reliably when there's one contenteditable="true" at root (it's set by `<Svedit`>)
|
|
983
1052
|
- `<AnnotatedTextProperty>` and `<CustomProperty>` must not be wrapped in `contenteditable="false"` to work properly.
|
|
984
1053
|
- 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.
|
|
1054
|
+
- 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.
|
|
985
1055
|
- 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` ).
|
|
1056
|
+
- Avoid CSS selectors like `:last-child` or `:first-child` on nodes (e.g., `[data-type="node"]:last-child`) because `<NodeGap>` elements are inserted in edit mode and will become the actual first or last child. This can cause unexpected layout shifts (e.g., if you have `.paragraph-node:last-child { margin-bottom: 10px }`, the margin won't apply as expected).
|
|
1057
|
+
- Avoid adding css `margin` to nodes inside node arrays when using flex or grid layouts. Use `gap` on the container instead. This ensure `NodeGap` and `NodeGapMarkers` render consistently. If you need to add a margin, add it to child element of Node.
|
|
986
1058
|
|
|
987
|
-
##
|
|
1059
|
+
## Beyond the README
|
|
988
1060
|
|
|
989
|
-
|
|
1061
|
+
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.
|
|
990
1062
|
|
|
991
1063
|
## Developing Svedit
|
|
992
1064
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
const svedit = getContext('svedit');
|
|
8
8
|
|
|
9
9
|
/** @type {AnnotatedTextPropertyProps} */
|
|
10
|
-
let { path, class: css_class, placeholder = '', tag = 'div' } = $props();
|
|
10
|
+
let { path, class: css_class, placeholder = '', tag = 'div', style = '', ...rest } = $props();
|
|
11
11
|
|
|
12
12
|
let is_focused = $derived.by(() => {
|
|
13
13
|
return (
|
|
@@ -26,7 +26,12 @@
|
|
|
26
26
|
);
|
|
27
27
|
|
|
28
28
|
// Get selection highlight range if not inside an annotation
|
|
29
|
+
// Only render selection highlight when canvas is NOT focused.
|
|
30
|
+
// This avoids DOM mutations (splitting text nodes for highlight spans)
|
|
31
|
+
// while the user is actively selecting, which would cause selection
|
|
32
|
+
// feedback loops and scroll-to-focus issues.
|
|
29
33
|
let selection_highlight_range = $derived.by(() => {
|
|
34
|
+
if (svedit.canvas_focused) return null;
|
|
30
35
|
if (is_collapsed) return null;
|
|
31
36
|
if (!is_focused) return null;
|
|
32
37
|
if (svedit.session.active_annotation()) return null;
|
|
@@ -101,17 +106,19 @@
|
|
|
101
106
|
<!-- ATTENTION: The comments are needed to prevent unwanted text nodes with whitespace. -->
|
|
102
107
|
<!-- We need to use <br> so the element is reachable by Arrow Up/Down navigation. -->
|
|
103
108
|
<!-- But as soon as the element is focused, we get rid of the <br> as it causes issues with caret positioning. -->
|
|
104
|
-
<!-- Before, we were just hiding it on focus, but that caused the
|
|
109
|
+
<!-- Before, we were just hiding it on focus, but that caused the caret to disappear on Safari Desktop (tested with v26). -->
|
|
105
110
|
<!-- Edge Case: Shift Enter stops working if <br> is not present on a non-empty text property. -->
|
|
106
111
|
<svelte:element
|
|
107
112
|
this={tag}
|
|
108
113
|
data-type="text"
|
|
109
114
|
data-path={path.join('.')}
|
|
110
|
-
style="anchor-name: --{path.join('-')};"
|
|
115
|
+
style="anchor-name: --{path.join('-')};{style}"
|
|
111
116
|
class="text svedit-selectable {css_class}"
|
|
112
117
|
class:empty={is_empty}
|
|
113
118
|
class:focused={is_focused}
|
|
119
|
+
class:editable={svedit.editable}
|
|
114
120
|
{placeholder}
|
|
121
|
+
{...rest}
|
|
115
122
|
>
|
|
116
123
|
{#each fragments as fragment, index (index)}
|
|
117
124
|
{#if typeof fragment === 'string'}{fragment}{:else if fragment.type === 'selection_highlight'}<span
|
|
@@ -134,10 +141,6 @@
|
|
|
134
141
|
white-space: pre-wrap;
|
|
135
142
|
overflow-wrap: anywhere;
|
|
136
143
|
box-sizing: content-box;
|
|
137
|
-
|
|
138
|
-
&.heading1 {
|
|
139
|
-
text-wrap: var(--text-wrap);
|
|
140
|
-
}
|
|
141
144
|
}
|
|
142
145
|
/* We switch from ::before to ::after when the element is focused. So the the caret is always before the placeholder. */
|
|
143
146
|
[placeholder].empty:not(.focused)::before,
|
|
@@ -152,19 +155,19 @@
|
|
|
152
155
|
caret-color: transparent;
|
|
153
156
|
}
|
|
154
157
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
/* Disable text-transform when editable and focused so users see original text */
|
|
159
|
+
.text.editable.focused {
|
|
160
|
+
text-transform: none !important;
|
|
161
|
+
}
|
|
159
162
|
|
|
160
163
|
/* Dim the selection highlight when canvas loses native focus */
|
|
161
|
-
:global(.svedit-canvas:not(:focus)) .selection-highlight {
|
|
162
|
-
background: oklch(from var(--editing-fill
|
|
164
|
+
:global(.svedit-canvas:not(:focus-within)) .selection-highlight {
|
|
165
|
+
background: oklch(from var(--svedit-editing-fill) l 0 h / alpha);
|
|
163
166
|
}
|
|
164
167
|
|
|
165
|
-
/* Make a collapsed
|
|
166
|
-
:global(.svedit-canvas:not(:focus)) .selection-highlight:empty {
|
|
168
|
+
/* Make a collapsed caret visible */
|
|
169
|
+
:global(.svedit-canvas:not(:focus-within)) .selection-highlight:empty {
|
|
167
170
|
background: none;
|
|
168
|
-
outline: 0.5px solid oklch(from var(--editing-stroke
|
|
171
|
+
outline: 0.5px solid oklch(from var(--svedit-editing-stroke) l 0 h / alpha);
|
|
169
172
|
}
|
|
170
173
|
</style>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AnnotatedTextProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AnnotatedTextProperty.svelte.js"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"AnnotatedTextProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AnnotatedTextProperty.svelte.js"],"names":[],"mappings":";;;;;AAkIA,2HAAoE"}
|
package/dist/Command.svelte.d.ts
CHANGED
|
@@ -84,7 +84,7 @@ export class ToggleAnnotationCommand extends Command {
|
|
|
84
84
|
execute(): void;
|
|
85
85
|
}
|
|
86
86
|
/**
|
|
87
|
-
* Command that adds a new line character at the current
|
|
87
|
+
* Command that adds a new line character at the current caret position.
|
|
88
88
|
* Only works in text selections where newlines are allowed.
|
|
89
89
|
* Disabled on mobile browsers where Shift+Enter has different behavior.
|
|
90
90
|
*/
|
|
@@ -93,7 +93,7 @@ export class AddNewLineCommand extends Command {
|
|
|
93
93
|
execute(): void;
|
|
94
94
|
}
|
|
95
95
|
/**
|
|
96
|
-
* Command that breaks a text node at the
|
|
96
|
+
* Command that breaks a text node at the caret position.
|
|
97
97
|
* Creates a new node and splits the content between the current and new node.
|
|
98
98
|
* Only works in text selections.
|
|
99
99
|
*/
|
|
@@ -109,7 +109,7 @@ export class SelectAllCommand extends Command {
|
|
|
109
109
|
execute(): void;
|
|
110
110
|
}
|
|
111
111
|
/**
|
|
112
|
-
* Command that inserts a default node at the current
|
|
112
|
+
* Command that inserts a default node at the current caret position.
|
|
113
113
|
* Only works when a collapsed node selection is active.
|
|
114
114
|
*/
|
|
115
115
|
export class InsertDefaultNodeCommand extends Command {
|
package/dist/Command.svelte.js
CHANGED
|
@@ -120,13 +120,13 @@ export class ToggleAnnotationCommand extends Command {
|
|
|
120
120
|
is_enabled() {
|
|
121
121
|
const { session, editable } = this.context;
|
|
122
122
|
const has_annotation = session.active_annotation(this.node_type);
|
|
123
|
-
const
|
|
123
|
+
const no_annotation_and_caret_not_collapsed =
|
|
124
124
|
!session.active_annotation() && !is_selection_collapsed(session.selection);
|
|
125
125
|
|
|
126
126
|
return (
|
|
127
127
|
editable &&
|
|
128
128
|
session.selection?.type === 'text' &&
|
|
129
|
-
(has_annotation ||
|
|
129
|
+
(has_annotation || no_annotation_and_caret_not_collapsed)
|
|
130
130
|
);
|
|
131
131
|
}
|
|
132
132
|
|
|
@@ -136,7 +136,7 @@ export class ToggleAnnotationCommand extends Command {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
/**
|
|
139
|
-
* Command that adds a new line character at the current
|
|
139
|
+
* Command that adds a new line character at the current caret position.
|
|
140
140
|
* Only works in text selections where newlines are allowed.
|
|
141
141
|
* Disabled on mobile browsers where Shift+Enter has different behavior.
|
|
142
142
|
*/
|
|
@@ -159,7 +159,7 @@ export class AddNewLineCommand extends Command {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
|
-
* Command that breaks a text node at the
|
|
162
|
+
* Command that breaks a text node at the caret position.
|
|
163
163
|
* Creates a new node and splits the content between the current and new node.
|
|
164
164
|
* Only works in text selections.
|
|
165
165
|
*/
|
|
@@ -293,7 +293,7 @@ export class SelectAllCommand extends Command {
|
|
|
293
293
|
}
|
|
294
294
|
|
|
295
295
|
/**
|
|
296
|
-
* Command that inserts a default node at the current
|
|
296
|
+
* Command that inserts a default node at the current caret position.
|
|
297
297
|
* Only works when a collapsed node selection is active.
|
|
298
298
|
*/
|
|
299
299
|
export class InsertDefaultNodeCommand extends Command {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
const svedit = getContext('svedit');
|
|
6
6
|
|
|
7
7
|
/** @type {CustomPropertyProps} */
|
|
8
|
-
let { path, tag = 'div', class: css_class, children } = $props();
|
|
8
|
+
let { path, tag = 'div', class: css_class, children, style, ...rest } = $props();
|
|
9
9
|
</script>
|
|
10
10
|
|
|
11
11
|
<svelte:element
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
class={css_class}
|
|
14
14
|
data-type="property"
|
|
15
15
|
data-path={path.join('.')}
|
|
16
|
-
style="anchor-name: --{path.join('-')};"
|
|
16
|
+
style="anchor-name: --{path.join('-')};{style ? ` ${style}` : ''}"
|
|
17
|
+
{...rest}
|
|
17
18
|
>
|
|
18
19
|
{#if svedit.editable}
|
|
19
|
-
<div class="
|
|
20
|
+
<div class="property-selectable">
|
|
20
21
|
<div class="svedit-selectable"><br /></div>
|
|
21
22
|
</div>
|
|
22
23
|
{/if}
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
[data-type='property'] {
|
|
29
30
|
position: relative;
|
|
30
31
|
}
|
|
31
|
-
.
|
|
32
|
+
.property-selectable {
|
|
32
33
|
position: absolute;
|
|
33
34
|
top: 0;
|
|
34
35
|
left: 0;
|
package/dist/Node.svelte
CHANGED
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { getContext } from 'svelte';
|
|
3
|
+
import DefaultNodeGap from './NodeGap.svelte';
|
|
3
4
|
|
|
4
5
|
/** @import { NodeProps } from './types.d.ts'; */
|
|
5
6
|
|
|
6
7
|
const svedit = getContext('svedit');
|
|
7
|
-
let
|
|
8
|
+
let NodeGap = $derived(svedit.session.config.system_components?.NodeGap ?? DefaultNodeGap);
|
|
8
9
|
|
|
9
10
|
/** @type {NodeProps} */
|
|
10
|
-
let { path, children, tag = 'div', class: css_class } = $props();
|
|
11
|
+
let { path, children, tag = 'div', class: css_class, style = '', ...rest } = $props();
|
|
11
12
|
|
|
12
|
-
// NOTE: When the next to last path segment is a node_array property, the node is wrapped in a node_array
|
|
13
|
-
let is_inside_node_array = $derived(
|
|
14
|
-
path.length > 1 && svedit.session.inspect(path.slice(0, -1))?.type === 'node_array'
|
|
15
|
-
);
|
|
16
13
|
let node = $derived(svedit.session.get(path));
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
|
|
15
|
+
// NodeArrayProperty sets this context — its presence means we're in a node_array.
|
|
16
|
+
// Using context avoids expensive session.inspect() and session.get() calls
|
|
17
|
+
// that would create O(N) reactive subscriptions to the parent array.
|
|
18
|
+
const node_array_meta = getContext('node_array_meta');
|
|
19
|
+
const is_inside_node_array = !!node_array_meta;
|
|
20
|
+
let child_index = $derived(parseInt(String(path.at(-1)), 10));
|
|
21
|
+
let is_first_node_array_child = $derived(is_inside_node_array && child_index === 0);
|
|
22
|
+
let is_last_node_array_child = $derived(
|
|
23
|
+
is_inside_node_array && child_index === node_array_meta.length - 1
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// Drives lazy anchor positioning for node gaps. Gaps are always in the
|
|
27
|
+
// DOM (stable structure for selection), but only anchor-positioned when
|
|
28
|
+
// near the viewport to avoid O(N) layout cost.
|
|
29
|
+
let is_near_viewport = $derived(
|
|
30
|
+
svedit.is_near_viewport?.(path) ?? true
|
|
19
31
|
);
|
|
20
32
|
</script>
|
|
21
33
|
|
|
@@ -26,19 +38,22 @@
|
|
|
26
38
|
data-node-id={node.id}
|
|
27
39
|
data-path={path.join('.')}
|
|
28
40
|
data-type="node"
|
|
29
|
-
style="anchor-name: --{path.join('-')};"
|
|
41
|
+
style="anchor-name: --{path.join('-')};{style}"
|
|
42
|
+
{...rest}
|
|
30
43
|
>
|
|
31
44
|
{#if svedit.editable && is_first_node_array_child}
|
|
32
|
-
<
|
|
45
|
+
<NodeGap {path} type="gap-before" positioned={is_near_viewport} />
|
|
33
46
|
{/if}
|
|
34
47
|
{@render children()}
|
|
35
48
|
{#if svedit.editable && is_inside_node_array}
|
|
36
|
-
<
|
|
49
|
+
<NodeGap {path} type="gap-after" last={is_last_node_array_child} positioned={is_near_viewport} />
|
|
37
50
|
{/if}
|
|
38
51
|
</svelte:element>
|
|
39
52
|
|
|
40
53
|
<style>
|
|
41
|
-
|
|
42
|
-
position
|
|
54
|
+
[data-type='node'] {
|
|
55
|
+
/** any other position than static will break the anchor positioning of node gaps and node gap-marker */
|
|
56
|
+
/* For developers who need to position their node with `position: absolute` or `position: relative`, they need to wrap their node in a div */
|
|
57
|
+
position: static !important;
|
|
43
58
|
}
|
|
44
|
-
</style>
|
|
59
|
+
</style>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Node.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Node.svelte.js"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"Node.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Node.svelte.js"],"names":[],"mappings":";;;;;AAoDA,yFAAmD"}
|