svedit 0.11.1 → 0.13.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 +260 -82
- package/dist/Command.svelte.d.ts +54 -29
- package/dist/Command.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.js +286 -283
- package/dist/CustomProperty.svelte +3 -5
- package/dist/CustomProperty.svelte.d.ts +3 -6
- package/dist/CustomProperty.svelte.d.ts.map +1 -1
- package/dist/KeyMapper.svelte.d.ts +19 -6
- package/dist/KeyMapper.svelte.d.ts.map +1 -1
- package/dist/KeyMapper.svelte.js +95 -112
- package/dist/Node.svelte +40 -9
- package/dist/Node.svelte.d.ts +3 -5
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +142 -18
- package/dist/NodeArrayProperty.svelte.d.ts +3 -5
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/NodeCaret.svelte +1 -1
- package/dist/NodeCaret.svelte.d.ts +6 -14
- package/dist/NodeCaret.svelte.d.ts.map +1 -1
- package/dist/NodeGap.svelte +60 -12
- package/dist/NodeGap.svelte.d.ts +7 -14
- package/dist/NodeGap.svelte.d.ts.map +1 -1
- package/dist/NodeGapMarkers.svelte +19 -10
- package/dist/NodeGapMarkers.svelte.d.ts +6 -11
- package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
- package/dist/NodeSelectionMarkers.svelte +20 -6
- package/dist/NodeSelectionMarkers.svelte.d.ts +2 -5
- package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.d.ts +85 -116
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +543 -571
- package/dist/Svedit.svelte +243 -216
- package/dist/Svedit.svelte.d.ts +30 -10
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
- package/dist/TextProperty.svelte.d.ts +5 -0
- package/dist/TextProperty.svelte.d.ts.map +1 -0
- package/dist/Transaction.svelte.d.ts +101 -126
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +843 -827
- package/dist/UnknownNode.svelte +4 -3
- package/dist/UnknownNode.svelte.d.ts +5 -9
- package/dist/UnknownNode.svelte.d.ts.map +1 -1
- package/dist/doc_utils.d.ts +109 -122
- package/dist/doc_utils.d.ts.map +1 -1
- package/dist/doc_utils.js +726 -698
- package/dist/index.d.ts +20 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -18
- package/dist/node_visibility.svelte.d.ts +132 -5
- package/dist/node_visibility.svelte.d.ts.map +1 -1
- package/dist/node_visibility.svelte.js +535 -642
- package/dist/paste_utils.d.ts +11 -43
- package/dist/paste_utils.d.ts.map +1 -1
- package/dist/paste_utils.js +111 -149
- package/dist/transforms.svelte.d.ts +9 -7
- package/dist/transforms.svelte.d.ts.map +1 -1
- package/dist/transforms.svelte.js +126 -152
- package/dist/types.d.ts +329 -291
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +79 -95
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +392 -305
- package/package.json +69 -67
- package/src/lib/{Command.svelte.js → Command.svelte.ts} +111 -41
- package/src/lib/CustomProperty.svelte +3 -5
- package/src/lib/{KeyMapper.svelte.js → KeyMapper.svelte.ts} +28 -11
- package/src/lib/Node.svelte +40 -9
- package/src/lib/NodeArrayProperty.svelte +142 -18
- package/src/lib/NodeCaret.svelte +1 -1
- package/src/lib/NodeGap.svelte +60 -12
- package/src/lib/NodeGapMarkers.svelte +19 -10
- package/src/lib/NodeSelectionMarkers.svelte +20 -6
- package/src/lib/Session.svelte.ts +656 -0
- package/src/lib/Svedit.svelte +243 -216
- package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
- package/src/lib/Transaction.svelte.ts +1020 -0
- package/src/lib/UnknownNode.svelte +4 -3
- package/src/lib/doc_utils.ts +1120 -0
- package/src/lib/{index.js → index.ts} +48 -1
- package/src/lib/node_visibility.svelte.ts +658 -0
- package/src/lib/{paste_utils.js → paste_utils.ts} +51 -59
- package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +44 -27
- package/src/lib/{types.d.ts → types.ts} +250 -103
- package/src/lib/utils.ts +660 -0
- package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
- package/src/lib/Session.svelte.js +0 -575
- package/src/lib/Transaction.svelte.js +0 -846
- package/src/lib/doc_utils.js +0 -807
- package/src/lib/node_visibility.svelte.js +0 -718
- package/src/lib/utils.js +0 -465
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Svedit
|
|
2
2
|
|
|
3
|
-
Svedit
|
|
3
|
+
Svedit is Editing for Svelte. It offers structured, full-canvas content editing. Model content in JSON, render it with custom components, and **edit it directly in the layout**.
|
|
4
4
|
|
|
5
5
|
- Try the [Svedit demo](https://svedit.dev) — watch the debug output at the bottom of the page
|
|
6
|
-
- Try [Editable
|
|
6
|
+
- Try [Editable](https://editable.website) — build Svelte websites you can edit on the page
|
|
7
7
|
|
|
8
8
|
## Why Svedit?
|
|
9
9
|
|
|
@@ -16,13 +16,13 @@ The fastest way to get started is to clone the `hello-svedit` template and turn
|
|
|
16
16
|
```bash
|
|
17
17
|
git clone https://github.com/michael/hello-svedit
|
|
18
18
|
cd hello-svedit
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
pnpm install
|
|
20
|
+
pnpm dev
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
Now make it your own. The next thing you probably want to do is define your own [node types](./src/routes/
|
|
23
|
+
Now make it your own. The next thing you probably want to do is define your own [node types](./src/routes/demo_schema.ts), 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).
|
|
24
24
|
|
|
25
|
-
You can also install Svedit into an existing SvelteKit project with `
|
|
25
|
+
You can also install Svedit into an existing SvelteKit project with `pnpm add 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
26
|
|
|
27
27
|
## Principles
|
|
28
28
|
|
|
@@ -38,7 +38,7 @@ You can also install Svedit into an existing SvelteKit project with `npm install
|
|
|
38
38
|
|
|
39
39
|
Svedit connects eight key pieces:
|
|
40
40
|
|
|
41
|
-
1. **Schema** - Define your content structure (node types, properties, annotations)
|
|
41
|
+
1. **Schema** - Define your content structure (node types, properties, marks, annotations)
|
|
42
42
|
2. **Document** - An actual document, containing a `document_id` and a flat map of nodes that hold the content
|
|
43
43
|
3. **Session** - Manages the document, selection state, and history
|
|
44
44
|
4. **Transaction** - Groups multiple document operations (create, delete, set) into a single atomic unit with undo/redo support
|
|
@@ -64,17 +64,18 @@ Each node has a `kind` that determines its behavior:
|
|
|
64
64
|
- `document`: A top-level node accessible via a route (e.g. a page, event)
|
|
65
65
|
- `block`: A structured node that contains other nodes or properties
|
|
66
66
|
- `text`: A node with editable text content (can be split and joined)
|
|
67
|
-
- `
|
|
67
|
+
- `mark`: A content-level range applied to text or a node array (bold, link, section, etc.). Marks are mutually exclusive and render in-place.
|
|
68
|
+
- `annotation`: A metadata/overlay range applied to text or a node array (comments, markers, etc.). Annotations may overlap and are data-only.
|
|
68
69
|
|
|
69
70
|
### Choosing between `text` and `block`
|
|
70
71
|
|
|
71
72
|
`kind: 'text'` opts into the split/join system (`break_text_node`, `join_text_node`), which assumes:
|
|
72
73
|
|
|
73
|
-
- The node has exactly **one** `
|
|
74
|
+
- The node has exactly **one** `text` property named **`content`**
|
|
74
75
|
- Pressing Enter splits the node into two nodes of the same type
|
|
75
76
|
- Pressing Backspace at position 0 joins it with the previous node
|
|
76
77
|
|
|
77
|
-
If **any** of those assumptions don't hold, use `kind: 'block'`. Blocks can still have `
|
|
78
|
+
If **any** of those assumptions don't hold, use `kind: 'block'`. Blocks can still have `text` properties with full editing support (typing, formatting, selection) — they just don't participate in split/join.
|
|
78
79
|
|
|
79
80
|
> **Common mistake:** A quote node with `content` + `author` properties might seem like `kind: 'text'` because both fields are editable text. But splitting a quote into two half-quotes doesn't make sense, and `join_text_node` hard-codes `node.content` — so Backspace in the `author` field would join the wrong property with the previous block and drop the author text. The correct kind is `'block'`.
|
|
80
81
|
|
|
@@ -84,19 +85,23 @@ If **any** of those assumptions don't hold, use `kind: 'block'`. Blocks can stil
|
|
|
84
85
|
```mermaid
|
|
85
86
|
flowchart TD
|
|
86
87
|
Start(["I need a new node type"])
|
|
87
|
-
Q1{"Is it
|
|
88
|
+
Q1{"Is it a range applied within text or over child nodes?"}
|
|
89
|
+
Q1a{"Is it part of the content (formatting) rather than metadata?"}
|
|
88
90
|
Q2{"Is it a top-level routable entry point?"}
|
|
89
91
|
Q3{"Is text the node's entire purpose?"}
|
|
90
|
-
Q4{"Does it have exactly one
|
|
92
|
+
Q4{"Does it have exactly one text property named content?"}
|
|
91
93
|
Q5{"Does pressing Enter to split into two nodes make sense?"}
|
|
92
94
|
|
|
93
|
-
|
|
95
|
+
KindMark["kind: mark e.g. bold, italic, link, section"]
|
|
96
|
+
KindAnnotation["kind: annotation e.g. comment, marker"]
|
|
94
97
|
KindDocument["kind: document e.g. page, article, event"]
|
|
95
98
|
KindBlock["kind: block e.g. image+caption, quote+author, list, nav, embed"]
|
|
96
99
|
KindText["kind: text e.g. paragraph, heading, list_item"]
|
|
97
100
|
|
|
98
101
|
Start --> Q1
|
|
99
|
-
Q1 -->|Yes|
|
|
102
|
+
Q1 -->|Yes| Q1a
|
|
103
|
+
Q1a -->|Yes| KindMark
|
|
104
|
+
Q1a -->|"No — it's metadata that may overlap"| KindAnnotation
|
|
100
105
|
Q1 -->|No| Q2
|
|
101
106
|
Q2 -->|Yes| KindDocument
|
|
102
107
|
Q2 -->|No| Q3
|
|
@@ -112,22 +117,24 @@ flowchart TD
|
|
|
112
117
|
|
|
113
118
|
Properties of nodes can hold values:
|
|
114
119
|
|
|
115
|
-
- `string`: A good old JavaScript string
|
|
120
|
+
- `string`: A good old JavaScript string. Use optional `values` to restrict it to an ordered set of allowed strings.
|
|
116
121
|
- `number`: Just like a number in JavaScript
|
|
117
|
-
- `integer`: A number for which Number.isInteger(number) returns true
|
|
122
|
+
- `integer`: A number for which Number.isInteger(number) returns true. Optional `min` and `max` bounds are inclusive.
|
|
118
123
|
- `boolean`: true or false
|
|
119
124
|
- `datetime`: A date/time string parseable by `Date.parse()`
|
|
120
125
|
- `string_array`: An array of strings
|
|
121
126
|
- `number_array`: An array of numbers
|
|
122
127
|
- `integer_array`: An array of integers
|
|
123
128
|
- `boolean_array`: An array of booleans
|
|
124
|
-
- `
|
|
129
|
+
- `text`: Plain text content with marks (bold, italic, link etc.) and annotations (comments etc.). Text values use `{ content: '', marks: [], annotations: [] }`. Set `allow_newlines: true` to let users insert line breaks with Shift+Enter, or `false` to keep content single-line (e.g. for titles).
|
|
125
130
|
|
|
126
131
|
Or references:
|
|
127
132
|
|
|
128
133
|
- `node`: References a single node (e.g. an image node can reference a global asset node)
|
|
129
134
|
- `node_array`: References a sequence of nodes (e.g. page.body references paragraph and list nodes)
|
|
130
135
|
|
|
136
|
+
`node_array` properties use `node_types` for the child node types they can contain, plus optional `mark_types` for marks that can wrap ranges of child nodes and optional `annotation_types` for overlay annotations.
|
|
137
|
+
|
|
131
138
|
```js
|
|
132
139
|
const document_schema = {
|
|
133
140
|
page: {
|
|
@@ -136,6 +143,8 @@ const document_schema = {
|
|
|
136
143
|
body: {
|
|
137
144
|
type: 'node_array',
|
|
138
145
|
node_types: ['nav', 'paragraph', 'list'],
|
|
146
|
+
mark_types: ['section'],
|
|
147
|
+
annotation_types: ['comment'],
|
|
139
148
|
default_node_type: 'paragraph'
|
|
140
149
|
}
|
|
141
150
|
}
|
|
@@ -144,8 +153,9 @@ const document_schema = {
|
|
|
144
153
|
kind: 'text',
|
|
145
154
|
properties: {
|
|
146
155
|
content: {
|
|
147
|
-
type: '
|
|
148
|
-
|
|
156
|
+
type: 'text',
|
|
157
|
+
mark_types: ['strong', 'emphasis', 'link'],
|
|
158
|
+
annotation_types: ['comment'],
|
|
149
159
|
allow_newlines: true
|
|
150
160
|
}
|
|
151
161
|
}
|
|
@@ -154,8 +164,8 @@ const document_schema = {
|
|
|
154
164
|
kind: 'text',
|
|
155
165
|
properties: {
|
|
156
166
|
content: {
|
|
157
|
-
type: '
|
|
158
|
-
|
|
167
|
+
type: 'text',
|
|
168
|
+
mark_types: ['strong', 'emphasis', 'link'],
|
|
159
169
|
allow_newlines: true
|
|
160
170
|
}
|
|
161
171
|
}
|
|
@@ -188,23 +198,31 @@ const document_schema = {
|
|
|
188
198
|
}
|
|
189
199
|
},
|
|
190
200
|
strong: {
|
|
191
|
-
kind: '
|
|
201
|
+
kind: 'mark',
|
|
192
202
|
properties: {}
|
|
193
203
|
},
|
|
194
204
|
emphasis: {
|
|
195
|
-
kind: '
|
|
205
|
+
kind: 'mark',
|
|
196
206
|
properties: {}
|
|
197
207
|
},
|
|
198
208
|
link: {
|
|
199
|
-
kind: '
|
|
209
|
+
kind: 'mark',
|
|
200
210
|
properties: {
|
|
201
211
|
href: { type: 'string' }
|
|
202
212
|
}
|
|
213
|
+
},
|
|
214
|
+
section: {
|
|
215
|
+
kind: 'mark',
|
|
216
|
+
properties: {}
|
|
217
|
+
},
|
|
218
|
+
comment: {
|
|
219
|
+
kind: 'annotation',
|
|
220
|
+
properties: {}
|
|
203
221
|
}
|
|
204
222
|
};
|
|
205
223
|
```
|
|
206
224
|
|
|
207
|
-
|
|
225
|
+
Mark types are defined as nodes with `kind: 'mark'`, annotation types as nodes with `kind: 'annotation'`. Simple marks like `strong` and `emphasis` have no properties, while marks like `link` can carry data (e.g. `href`). Each `text` or `node_array` property specifies which types are allowed via `mark_types` and `annotation_types` — this lets you control formatting per property (e.g. allow bold and links in body text, but only emphasis in titles). `mark_types` may only reference `kind: 'mark'` node types and `annotation_types` only `kind: 'annotation'` node types.
|
|
208
226
|
|
|
209
227
|
## Document
|
|
210
228
|
|
|
@@ -214,7 +232,60 @@ Rules:
|
|
|
214
232
|
|
|
215
233
|
- All nodes must be reachable from the document node (unreachable nodes are discarded)
|
|
216
234
|
- No cyclic references allowed
|
|
217
|
-
- Text
|
|
235
|
+
- Text properties use `{ content: '', marks: [], annotations: [] }`
|
|
236
|
+
- Node array properties use `{ nodes: [], marks: [], annotations: [] }`
|
|
237
|
+
|
|
238
|
+
### Node IDs
|
|
239
|
+
|
|
240
|
+
Node IDs uniquely identify nodes inside a document. They are also used whenever one node references another node, for example in `node`, `node_array.nodes`, and mark or annotation `node_id` values.
|
|
241
|
+
|
|
242
|
+
Because Svedit also uses these IDs in HTML ids, document paths, and CSS selectors, IDs need to follow a simple, safe format:
|
|
243
|
+
|
|
244
|
+
- It must be a string.
|
|
245
|
+
- It must not be empty.
|
|
246
|
+
- It must start with a letter (`A-Z`, `a-z`) or underscore (`_`).
|
|
247
|
+
- After the first character, it may contain letters, numbers, underscores, or dashes.
|
|
248
|
+
- It must not contain Svedit's path separator `__`.
|
|
249
|
+
|
|
250
|
+
Valid examples:
|
|
251
|
+
|
|
252
|
+
```js
|
|
253
|
+
'page_1';
|
|
254
|
+
'story-hero';
|
|
255
|
+
'_temporary_node';
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Invalid examples:
|
|
259
|
+
|
|
260
|
+
| ID | Why invalid |
|
|
261
|
+
| ----------- | --------------------------------------------------- |
|
|
262
|
+
| `1` | Not a string |
|
|
263
|
+
| `'1'` | Serialized paths would treat this as an array index |
|
|
264
|
+
| `'1_page'` | Starts with a number |
|
|
265
|
+
| `'page.1'` | `.` is not allowed |
|
|
266
|
+
| `'page__1'` | `__` is reserved as Svedit's path separator |
|
|
267
|
+
|
|
268
|
+
The `doc.nodes` map key must also match the node's own `id`:
|
|
269
|
+
|
|
270
|
+
```js
|
|
271
|
+
nodes: {
|
|
272
|
+
page_1: {
|
|
273
|
+
id: 'page_1',
|
|
274
|
+
type: 'page'
|
|
275
|
+
// ...
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
If you provide `config.generate_id`, every generated ID must follow the same rules. Svedit validates generated IDs immediately and throws if the provider returns an invalid value:
|
|
281
|
+
|
|
282
|
+
```js
|
|
283
|
+
const session_config = {
|
|
284
|
+
generate_id: () => `node_${crypto.randomUUID()}`
|
|
285
|
+
};
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Provide your own generator: use short custom-alphabet `nanoid` IDs for local documents, monotonic ULIDs (`ulid`) for sortable/synced IDs, or prefixed UUIDs for globally unique IDs.
|
|
218
289
|
|
|
219
290
|
Here's an example document:
|
|
220
291
|
|
|
@@ -222,6 +293,14 @@ Here's an example document:
|
|
|
222
293
|
const doc = {
|
|
223
294
|
document_id: 'page_1',
|
|
224
295
|
nodes: {
|
|
296
|
+
strong_1: {
|
|
297
|
+
id: 'strong_1',
|
|
298
|
+
type: 'strong'
|
|
299
|
+
},
|
|
300
|
+
section_1: {
|
|
301
|
+
id: 'section_1',
|
|
302
|
+
type: 'section'
|
|
303
|
+
},
|
|
225
304
|
nav_item_1: {
|
|
226
305
|
id: 'nav_item_1',
|
|
227
306
|
type: 'nav_item',
|
|
@@ -231,37 +310,92 @@ const doc = {
|
|
|
231
310
|
nav_1: {
|
|
232
311
|
id: 'nav_1',
|
|
233
312
|
type: 'nav',
|
|
234
|
-
nav_items: ['nav_item_1']
|
|
313
|
+
nav_items: { nodes: ['nav_item_1'], marks: [], annotations: [] }
|
|
235
314
|
},
|
|
236
315
|
paragraph_1: {
|
|
237
316
|
id: 'paragraph_1',
|
|
238
317
|
type: 'paragraph',
|
|
239
|
-
content: {
|
|
318
|
+
content: {
|
|
319
|
+
content: 'Hello world.',
|
|
320
|
+
marks: [{ start_offset: 0, end_offset: 5, node_id: 'strong_1' }],
|
|
321
|
+
annotations: []
|
|
322
|
+
}
|
|
240
323
|
},
|
|
241
324
|
list_item_1: {
|
|
242
325
|
id: 'list_item_1',
|
|
243
326
|
type: 'list_item',
|
|
244
|
-
content: {
|
|
327
|
+
content: { content: 'First list item', marks: [], annotations: [] }
|
|
245
328
|
},
|
|
246
329
|
list_item_2: {
|
|
247
330
|
id: 'list_item_2',
|
|
248
331
|
type: 'list_item',
|
|
249
|
-
content: {
|
|
332
|
+
content: { content: 'Second list item', marks: [], annotations: [] }
|
|
250
333
|
},
|
|
251
334
|
list_1: {
|
|
252
335
|
id: 'list_1',
|
|
253
336
|
type: 'list',
|
|
254
|
-
list_items: ['list_item_1', 'list_item_2']
|
|
337
|
+
list_items: { nodes: ['list_item_1', 'list_item_2'], marks: [], annotations: [] }
|
|
255
338
|
},
|
|
256
339
|
page_1: {
|
|
257
340
|
id: 'page_1',
|
|
258
341
|
type: 'page',
|
|
259
|
-
body:
|
|
342
|
+
body: {
|
|
343
|
+
nodes: ['nav_1', 'paragraph_1', 'list_1'],
|
|
344
|
+
marks: [{ start_offset: 1, end_offset: 3, node_id: 'section_1' }],
|
|
345
|
+
annotations: []
|
|
346
|
+
}
|
|
260
347
|
}
|
|
261
348
|
}
|
|
262
349
|
};
|
|
263
350
|
```
|
|
264
351
|
|
|
352
|
+
### Marks and annotations
|
|
353
|
+
|
|
354
|
+
Marks and annotations are regular nodes attached to a range in a property value. Both use the same attachment shape:
|
|
355
|
+
|
|
356
|
+
```js
|
|
357
|
+
{
|
|
358
|
+
start_offset: 0,
|
|
359
|
+
end_offset: 5,
|
|
360
|
+
node_id: 'strong_1'
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
They differ in semantics:
|
|
365
|
+
|
|
366
|
+
- **Marks** are part of the content (bold, italic, link, section). They are mutually exclusive within a property and are rendered in-place by `<TextProperty>`/`<NodeArrayProperty>`.
|
|
367
|
+
- **Annotations** are metadata layered over the content (comments, markers). They may overlap marks and each other, and are data-only: Svedit keeps and transforms the data (and adds CSS classes to covered node wrappers), but your app is responsible for interpreting it. Annotation types must not have registered components.
|
|
368
|
+
|
|
369
|
+
For `text` properties, offsets address character positions in the `content` string:
|
|
370
|
+
|
|
371
|
+
```js
|
|
372
|
+
{
|
|
373
|
+
id: 'paragraph_1',
|
|
374
|
+
type: 'paragraph',
|
|
375
|
+
content: {
|
|
376
|
+
content: 'Hello world.',
|
|
377
|
+
marks: [{ start_offset: 0, end_offset: 5, node_id: 'strong_1' }],
|
|
378
|
+
annotations: [{ start_offset: 3, end_offset: 8, node_id: 'comment_1' }]
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
For `node_array` properties, offsets address node positions in the `nodes` array. This lets you wrap a contiguous group of child nodes with the same range model:
|
|
384
|
+
|
|
385
|
+
```js
|
|
386
|
+
{
|
|
387
|
+
id: 'page_1',
|
|
388
|
+
type: 'page',
|
|
389
|
+
body: {
|
|
390
|
+
nodes: ['paragraph_1', 'paragraph_2', 'paragraph_3'],
|
|
391
|
+
marks: [{ start_offset: 1, end_offset: 3, node_id: 'section_1' }],
|
|
392
|
+
annotations: []
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
The ranges are half-open: `start_offset` is included, `end_offset` is excluded. Marks must not overlap; annotations may overlap (including same-type annotations created through lower-level APIs).
|
|
398
|
+
|
|
265
399
|
### Document schema changes
|
|
266
400
|
|
|
267
401
|
Svedit validates documents against the current schema when you create a `Session`. If you change the schema, you are responsible for migrating existing documents before loading them.
|
|
@@ -275,9 +409,13 @@ const document_schema = {
|
|
|
275
409
|
paragraph: {
|
|
276
410
|
kind: 'text',
|
|
277
411
|
properties: {
|
|
278
|
-
layout: {
|
|
412
|
+
layout: {
|
|
413
|
+
type: 'string',
|
|
414
|
+
values: ['image-left', 'image-right', 'stacked'],
|
|
415
|
+
default: 'image-left'
|
|
416
|
+
},
|
|
279
417
|
content: {
|
|
280
|
-
type: '
|
|
418
|
+
type: 'text',
|
|
281
419
|
allow_newlines: true
|
|
282
420
|
}
|
|
283
421
|
}
|
|
@@ -288,7 +426,7 @@ const migrated_doc = fill_document_defaults(existing_doc, document_schema);
|
|
|
288
426
|
const session = new Session(document_schema, migrated_doc, config);
|
|
289
427
|
```
|
|
290
428
|
|
|
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 `
|
|
429
|
+
This only fills properties that are missing. Explicit schema `default` values are used when present; otherwise Svedit uses built-in defaults for value types such as strings, numbers, booleans, arrays, `node_array`, and `text`. Constraints such as `values`, `min`, and `max` do not change those defaults, so declare an explicit `default` when the built-in value would be invalid. Existing values are preserved, and the original document object is not mutated.
|
|
292
430
|
|
|
293
431
|
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
432
|
|
|
@@ -296,17 +434,17 @@ Defaults make it safe to add new defaultable properties, but they are not a repl
|
|
|
296
434
|
|
|
297
435
|
## Config
|
|
298
436
|
|
|
299
|
-
Documents need a config object that tells Svedit how to render and manipulate your content. See the full example in [`src/routes/
|
|
437
|
+
Documents need a config object that tells Svedit how to render and manipulate your content. See the full example in [`src/routes/demo_config.ts`](src/routes/demo_config.ts).
|
|
300
438
|
|
|
301
439
|
Two optional hooks are especially useful when integrating custom media workflows:
|
|
302
440
|
|
|
303
441
|
- `handle_media_paste(session, pasted_media)`
|
|
304
442
|
Called when media is pasted. You can upload/process files, replace an existing media property, or return a node payload to insert new content.
|
|
305
|
-
See example implementation in [`src/routes/
|
|
443
|
+
See example implementation in [`src/routes/demo_config.ts`](src/routes/demo_config.ts).
|
|
306
444
|
|
|
307
445
|
- `handle_property_deletion(session, path)`
|
|
308
446
|
Called when deleting/cutting a property selection. Use it to define app-specific reset behavior (for example clearing an image property or resetting a referenced media node).
|
|
309
|
-
See example implementation in [`src/routes/
|
|
447
|
+
See example implementation in [`src/routes/demo_config.ts`](src/routes/demo_config.ts).
|
|
310
448
|
|
|
311
449
|
```js
|
|
312
450
|
const session_config = {
|
|
@@ -321,7 +459,7 @@ const session_config = {
|
|
|
321
459
|
|
|
322
460
|
// Functions that create and insert new nodes
|
|
323
461
|
inserters: {
|
|
324
|
-
text: (tr, content = {
|
|
462
|
+
text: (tr, content = { content: '', marks: [], annotations: [] }) => {
|
|
325
463
|
const text_id = nanoid();
|
|
326
464
|
tr.create({ id: text_id, type: 'text', content });
|
|
327
465
|
tr.insert_nodes([text_id]);
|
|
@@ -369,11 +507,11 @@ const session = new Session(schema, doc, config);
|
|
|
369
507
|
### Reading the graph
|
|
370
508
|
|
|
371
509
|
```js
|
|
372
|
-
session.get(['page_1', 'body']); // => ['nav_1', 'paragraph_1', 'list_1']
|
|
510
|
+
session.get(['page_1', 'body']); // => { nodes: ['nav_1', 'paragraph_1', 'list_1'], marks: [], annotations: [] }
|
|
373
511
|
session.get(['nav_1']); // => { id: 'nav_1', type: 'nav', ... }
|
|
374
512
|
session.get('nav_1'); // => shorthand for above (single node ID)
|
|
375
513
|
session.inspect(['page_1', 'body']); // => { kind: 'property', type: 'node_array', node_types: [...] }
|
|
376
|
-
session.kind(node); // => 'text', 'block', or 'annotation'
|
|
514
|
+
session.kind(node); // => 'text', 'block', 'mark', or 'annotation'
|
|
377
515
|
```
|
|
378
516
|
|
|
379
517
|
### Selection and state
|
|
@@ -381,8 +519,12 @@ session.kind(node); // => 'text', 'block', or 'annotation'
|
|
|
381
519
|
```js
|
|
382
520
|
session.selection; // Current selection (text, node, or property)
|
|
383
521
|
session.selected_node; // The currently selected node (derived)
|
|
384
|
-
session.
|
|
522
|
+
session.selected_marks; // Mark records touched by the current selection (derived)
|
|
523
|
+
session.active_mark; // The selected mark record when exactly one mark is touched, otherwise null
|
|
524
|
+
session.selected_annotations; // Annotation records touched by the current selection (derived)
|
|
525
|
+
session.active_annotation; // The selected annotation record when exactly one annotation is touched, otherwise null
|
|
385
526
|
session.can_insert('paragraph'); // Check if node type can be inserted
|
|
527
|
+
session.available_mark_types; // Mark types allowed at current selection (derived)
|
|
386
528
|
session.available_annotation_types; // Annotation types allowed at current selection (derived)
|
|
387
529
|
```
|
|
388
530
|
|
|
@@ -392,7 +534,7 @@ Every selection that can exist in Svedit must have a DOM representation. This is
|
|
|
392
534
|
|
|
393
535
|
This applies to all selection types:
|
|
394
536
|
|
|
395
|
-
- Text selections are native selections inside
|
|
537
|
+
- Text selections are native selections inside a `text` property.
|
|
396
538
|
- Node selections are native selections whose anchor and focus land inside the selected node range.
|
|
397
539
|
- Property selections are native selections inside the DOM representation of that property.
|
|
398
540
|
|
|
@@ -515,7 +657,11 @@ function insert_heading(tr) {
|
|
|
515
657
|
|
|
516
658
|
// Create and insert a heading node
|
|
517
659
|
const heading_id = tr.generate_id();
|
|
518
|
-
tr.create({
|
|
660
|
+
tr.create({
|
|
661
|
+
id: heading_id,
|
|
662
|
+
type: 'heading',
|
|
663
|
+
content: { content: '', marks: [], annotations: [] }
|
|
664
|
+
});
|
|
519
665
|
tr.insert_nodes([heading_id]);
|
|
520
666
|
|
|
521
667
|
return true;
|
|
@@ -538,7 +684,11 @@ session.apply(tr); // Apply atomically
|
|
|
538
684
|
|
|
539
685
|
```js
|
|
540
686
|
// Create a new node (must include all required properties from schema)
|
|
541
|
-
tr.create({
|
|
687
|
+
tr.create({
|
|
688
|
+
id: 'paragraph_1',
|
|
689
|
+
type: 'paragraph',
|
|
690
|
+
content: { content: '', marks: [], annotations: [] }
|
|
691
|
+
});
|
|
542
692
|
|
|
543
693
|
// Delete a node (cascades to unreferenced child nodes)
|
|
544
694
|
tr.delete('paragraph_26');
|
|
@@ -556,7 +706,7 @@ const new_node_id = tr.build('the_list', {
|
|
|
556
706
|
the_list: {
|
|
557
707
|
id: 'the_list',
|
|
558
708
|
type: 'list',
|
|
559
|
-
list_items: ['first_item']
|
|
709
|
+
list_items: { nodes: ['first_item'], marks: [], annotations: [] }
|
|
560
710
|
}
|
|
561
711
|
});
|
|
562
712
|
```
|
|
@@ -567,9 +717,12 @@ const new_node_id = tr.build('the_list', {
|
|
|
567
717
|
// Insert text at caret (replaces selection if expanded)
|
|
568
718
|
tr.insert_text('Hello');
|
|
569
719
|
|
|
570
|
-
// Toggle
|
|
571
|
-
tr.
|
|
572
|
-
tr.
|
|
720
|
+
// Toggle mark on selected text (marks are mutually exclusive)
|
|
721
|
+
tr.toggle_mark('strong');
|
|
722
|
+
tr.toggle_mark('link', { href: 'https://example.com' });
|
|
723
|
+
|
|
724
|
+
// Toggle annotation on selected text (competes only with same-type annotations)
|
|
725
|
+
tr.toggle_annotation('comment');
|
|
573
726
|
|
|
574
727
|
// Delete selected text or nodes
|
|
575
728
|
tr.delete_selection();
|
|
@@ -621,7 +774,7 @@ class ToggleStrongCommand extends Command {
|
|
|
621
774
|
}
|
|
622
775
|
|
|
623
776
|
execute() {
|
|
624
|
-
this.context.session.apply(this.context.session.tr.
|
|
777
|
+
this.context.session.apply(this.context.session.tr.toggle_mark('strong'));
|
|
625
778
|
}
|
|
626
779
|
}
|
|
627
780
|
```
|
|
@@ -666,7 +819,8 @@ Svedit provides several [core commands](src/lib/Command.svelte.js) out of the bo
|
|
|
666
819
|
- `UndoCommand` - Undo the last change
|
|
667
820
|
- `RedoCommand` - Redo the last undone change
|
|
668
821
|
- `SelectParentCommand` - Select the parent of the current selection
|
|
669
|
-
- `
|
|
822
|
+
- `ToggleMarkCommand` - Toggle marks on text or node-array selections
|
|
823
|
+
- `ToggleAnnotationCommand` - Toggle annotations on text or node-array selections
|
|
670
824
|
- `AddNewLineCommand` - Insert newline character in text
|
|
671
825
|
- `BreakTextNodeCommand` - Split text node at caret
|
|
672
826
|
- `SelectAllCommand` - Progressively expand selection
|
|
@@ -674,15 +828,15 @@ Svedit provides several [core commands](src/lib/Command.svelte.js) out of the bo
|
|
|
674
828
|
|
|
675
829
|
#### Using document commands
|
|
676
830
|
|
|
677
|
-
Commands are created by passing them a context object from the Svedit component. See a complete example in [`src/routes/
|
|
831
|
+
Commands are created by passing them a context object from the Svedit component. See a complete example in [`src/routes/demo_config.ts`](src/routes/demo_config.ts) in the `create_commands_and_keymap` configuration function:
|
|
678
832
|
|
|
679
833
|
```js
|
|
680
834
|
create_commands_and_keymap: (context) => {
|
|
681
835
|
const commands = {
|
|
682
836
|
undo: new UndoCommand(context),
|
|
683
837
|
redo: new RedoCommand(context),
|
|
684
|
-
toggle_strong: new
|
|
685
|
-
toggle_emphasis: new
|
|
838
|
+
toggle_strong: new ToggleMarkCommand('strong', context),
|
|
839
|
+
toggle_emphasis: new ToggleMarkCommand('emphasis', context)
|
|
686
840
|
// ... more commands
|
|
687
841
|
};
|
|
688
842
|
|
|
@@ -714,15 +868,15 @@ Commands can have derived state for reactive UI binding. The `active` property i
|
|
|
714
868
|
|
|
715
869
|
```js
|
|
716
870
|
class ToggleEmphasisCommand extends Command {
|
|
717
|
-
// Automatically recomputes when
|
|
718
|
-
active = $derived(this.context.session.
|
|
871
|
+
// Automatically recomputes when mark state changes
|
|
872
|
+
active = $derived(this.context.session.active_mark?.node.type === 'emphasis');
|
|
719
873
|
|
|
720
874
|
is_enabled() {
|
|
721
875
|
return this.context.editable && this.context.session.selection?.type === 'text';
|
|
722
876
|
}
|
|
723
877
|
|
|
724
878
|
execute() {
|
|
725
|
-
this.context.session.apply(this.context.session.tr.
|
|
879
|
+
this.context.session.apply(this.context.session.tr.toggle_mark('emphasis'));
|
|
726
880
|
}
|
|
727
881
|
}
|
|
728
882
|
```
|
|
@@ -957,13 +1111,13 @@ A typical node component follows this pattern:
|
|
|
957
1111
|
|
|
958
1112
|
```svelte
|
|
959
1113
|
<script>
|
|
960
|
-
import { Node,
|
|
1114
|
+
import { Node, TextProperty } from 'svedit';
|
|
961
1115
|
let { path } = $props();
|
|
962
1116
|
</script>
|
|
963
1117
|
|
|
964
1118
|
<Node {path}>
|
|
965
1119
|
<div class="my-node">
|
|
966
|
-
<
|
|
1120
|
+
<TextProperty path={[...path, 'content']} />
|
|
967
1121
|
</div>
|
|
968
1122
|
</Node>
|
|
969
1123
|
```
|
|
@@ -980,15 +1134,10 @@ Every node component must wrap its content in the `<Node>` component. This wrapp
|
|
|
980
1134
|
|
|
981
1135
|
Svedit provides specialized components for rendering different property types:
|
|
982
1136
|
|
|
983
|
-
**`<
|
|
1137
|
+
**`<TextProperty>`** - For editable text content with inline marks:
|
|
984
1138
|
|
|
985
1139
|
```svelte
|
|
986
|
-
<
|
|
987
|
-
tag="p"
|
|
988
|
-
class="body"
|
|
989
|
-
path={[...path, 'content']}
|
|
990
|
-
placeholder="Enter text here"
|
|
991
|
-
/>
|
|
1140
|
+
<TextProperty tag="p" class="body" path={[...path, 'content']} placeholder="Enter text here" />
|
|
992
1141
|
```
|
|
993
1142
|
|
|
994
1143
|
**`<NodeArrayProperty>`** - For container properties that hold multiple nodes:
|
|
@@ -997,6 +1146,40 @@ Svedit provides specialized components for rendering different property types:
|
|
|
997
1146
|
<NodeArrayProperty class="list-items" path={[...path, 'list_items']} />
|
|
998
1147
|
```
|
|
999
1148
|
|
|
1149
|
+
Node arrays can also carry marks and annotations when their schema includes `mark_types`/`annotation_types`. The value still has one `nodes` array, plus `marks` and `annotations` arrays whose offsets refer to node indexes:
|
|
1150
|
+
|
|
1151
|
+
```js
|
|
1152
|
+
buttons: {
|
|
1153
|
+
nodes: ['button_1', 'button_2', 'button_3'],
|
|
1154
|
+
marks: [{ start_offset: 0, end_offset: 2, node_id: 'section_1' }],
|
|
1155
|
+
annotations: []
|
|
1156
|
+
}
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
`<NodeArrayProperty>` wraps marked ranges with the matching mark component, just like `<TextProperty>` does for text ranges. Child node components also receive range context:
|
|
1160
|
+
|
|
1161
|
+
```svelte
|
|
1162
|
+
<script>
|
|
1163
|
+
let { path, mark: section = null, annotations = [] } = $props();
|
|
1164
|
+
</script>
|
|
1165
|
+
|
|
1166
|
+
<div class:section-start={section?.is_start}>
|
|
1167
|
+
<!-- render node content -->
|
|
1168
|
+
</div>
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
`mark` is the mark wrapping the child node, or `null` when none does — mark exclusivity guarantees there is at most one. `annotations` contains all annotations covering the child node, including overlapping ones. Annotations are data-only: they never wrap and are never rendered as components. Each entry contains the flattened attachment plus context for the current child node: `start_offset`, `end_offset`, `node_id`, `index`, `node`, `is_start`, `is_middle`, `is_end`. `node` is the resolved mark or annotation node, `index` is the position in the parent `marks`/`annotations` array, and the `is_*` flags describe the current child node's position in the attached range.
|
|
1172
|
+
|
|
1173
|
+
For styling, you usually don't need the props at all: `<Node>` adds range classes to the node wrapper automatically. A node covered by a `section` mark gets `mark-section`, a node covered by a `marker` annotation gets `anno-marker` — plus `-start`/`-end` variants on the run's first/last covered node. So styling a mark or annotation type — including types without a component — is pure CSS:
|
|
1174
|
+
|
|
1175
|
+
```css
|
|
1176
|
+
.anno-marker {
|
|
1177
|
+
background: color-mix(in oklch, var(--app-primary-fill) 13%, transparent);
|
|
1178
|
+
}
|
|
1179
|
+
```
|
|
1180
|
+
|
|
1181
|
+
Use the `annotations` prop only when a component should render something richer than styles (badges, margin indicators, etc.).
|
|
1182
|
+
|
|
1000
1183
|
> **One path = one DOM mount.** Within a single Svedit document, each node path
|
|
1001
1184
|
> may be mounted exactly once. Mounting the same path twice (e.g. rendering the
|
|
1002
1185
|
> same `nav_items` array in both header and footer) breaks anchor positioning,
|
|
@@ -1012,7 +1195,7 @@ Svedit provides specialized components for rendering different property types:
|
|
|
1012
1195
|
```svelte
|
|
1013
1196
|
<CustomProperty class="image-wrapper" path={[...path, 'image']}>
|
|
1014
1197
|
<div contenteditable="false">
|
|
1015
|
-
<img src={node.image} alt={node.title.
|
|
1198
|
+
<img src={node.image} alt={node.title.content} />
|
|
1016
1199
|
</div>
|
|
1017
1200
|
</CustomProperty>
|
|
1018
1201
|
```
|
|
@@ -1039,7 +1222,7 @@ Here's a complete example of a text node component that supports multiple layout
|
|
|
1039
1222
|
```svelte
|
|
1040
1223
|
<script>
|
|
1041
1224
|
import { getContext } from 'svelte';
|
|
1042
|
-
import { Node,
|
|
1225
|
+
import { Node, TextProperty } from 'svedit';
|
|
1043
1226
|
|
|
1044
1227
|
const svedit = getContext('svedit');
|
|
1045
1228
|
let { path } = $props();
|
|
@@ -1050,12 +1233,7 @@ Here's a complete example of a text node component that supports multiple layout
|
|
|
1050
1233
|
|
|
1051
1234
|
<Node {path}>
|
|
1052
1235
|
<div class="text layout-{layout}">
|
|
1053
|
-
<
|
|
1054
|
-
{tag}
|
|
1055
|
-
class="body"
|
|
1056
|
-
path={[...path, 'content']}
|
|
1057
|
-
placeholder="Enter text"
|
|
1058
|
-
/>
|
|
1236
|
+
<TextProperty {tag} class="body" path={[...path, 'content']} placeholder="Enter text" />
|
|
1059
1237
|
</div>
|
|
1060
1238
|
</Node>
|
|
1061
1239
|
```
|
|
@@ -1113,7 +1291,7 @@ a simplified version of the markup of `<NodeGap>` and why it is implemented the
|
|
|
1113
1291
|
And using <span></span> will not make it addressable at all.
|
|
1114
1292
|
|
|
1115
1293
|
Svedit uses this behavior for node gaps, and when an
|
|
1116
|
-
<
|
|
1294
|
+
<TextProperty> is empty.
|
|
1117
1295
|
-->
|
|
1118
1296
|
<div class="node-gap"><br /></div>
|
|
1119
1297
|
<!--
|
|
@@ -1132,8 +1310,8 @@ a simplified version of the markup of `<NodeGap>` and why it is implemented the
|
|
|
1132
1310
|
Further things to consider:
|
|
1133
1311
|
|
|
1134
1312
|
- If you make a sub-tree `contenteditable="false"`, be aware that you can't create a `contenteditable="true"` segment somewhere inside it. Svedit can only work reliably when there's one contenteditable="true" at root (it's set by `<Svedit`>)
|
|
1135
|
-
- `<
|
|
1136
|
-
- Never apply `position: relative` to the direct parent of `<
|
|
1313
|
+
- `<TextProperty>` and `<CustomProperty>` must not be wrapped in `contenteditable="false"` to work properly.
|
|
1314
|
+
- Never apply `position: relative` to the direct parent of `<TextProperty>`, it will cause a [weird Safari bug](https://bsky.app/profile/michaelaufreiter.com/post/3lxvdqyxc622s) to destroy the DOM.
|
|
1137
1315
|
- Never apply `position: relative`, `position: absolute`, `position: fixed` to `<Node.svelte>` (data-type="node") in edit mode. Only `position: static` is permitted to allow css anchor positioning queries to resolve correctly.
|
|
1138
1316
|
- Never use an `<a>` tag inside a `contenteditable="true"` element, as it will cause unexpected behavior. Make it a `<div>` while editing, and an `<a>` in read-only mode (when `svedit.editable` is `false` ).
|
|
1139
1317
|
- 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.
|
|
@@ -1274,10 +1452,10 @@ The source code is compact and readable — less than 3000 LOC across a handful
|
|
|
1274
1452
|
|
|
1275
1453
|
## Developing Svedit
|
|
1276
1454
|
|
|
1277
|
-
Once you've cloned the Svedit repository and installed dependencies with `
|
|
1455
|
+
Once you've cloned the Svedit repository and installed dependencies with `pnpm install`, start a development server:
|
|
1278
1456
|
|
|
1279
1457
|
```bash
|
|
1280
|
-
|
|
1458
|
+
pnpm dev
|
|
1281
1459
|
```
|
|
1282
1460
|
|
|
1283
1461
|
## Building
|
|
@@ -1285,10 +1463,10 @@ npm run dev
|
|
|
1285
1463
|
To create a production version of your app:
|
|
1286
1464
|
|
|
1287
1465
|
```bash
|
|
1288
|
-
|
|
1466
|
+
pnpm build
|
|
1289
1467
|
```
|
|
1290
1468
|
|
|
1291
|
-
You can preview the production build with `
|
|
1469
|
+
You can preview the production build with `pnpm preview`.
|
|
1292
1470
|
|
|
1293
1471
|
## Contributing
|
|
1294
1472
|
|
|
@@ -1315,4 +1493,4 @@ It's still early. Expect bugs. Expect missing features. Expect the need for more
|
|
|
1315
1493
|
|
|
1316
1494
|
## Credits
|
|
1317
1495
|
|
|
1318
|
-
Svedit is
|
|
1496
|
+
Svedit is made by [Michael Aufreiter](https://michaelaufreiter.com) (maintainer) and [Johannes Mutter](https://mutter.co) (design and engineering). Illustrations and icons are hand-crafted by Johannes.
|