svedit 0.1.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -0
  3. package/dist/AnnotatedStringProperty.svelte +126 -0
  4. package/dist/AnnotatedStringProperty.svelte.d.ts +7 -0
  5. package/dist/AnnotatedStringProperty.svelte.d.ts.map +1 -0
  6. package/dist/CustomProperty.svelte +40 -0
  7. package/dist/CustomProperty.svelte.d.ts +8 -0
  8. package/dist/CustomProperty.svelte.d.ts.map +1 -0
  9. package/dist/Document.svelte.d.ts +191 -0
  10. package/dist/Document.svelte.d.ts.map +1 -0
  11. package/dist/Document.svelte.js +681 -0
  12. package/dist/Node.svelte +41 -0
  13. package/dist/Node.svelte.d.ts +7 -0
  14. package/dist/Node.svelte.d.ts.map +1 -0
  15. package/dist/NodeArrayProperty.svelte +45 -0
  16. package/dist/NodeArrayProperty.svelte.d.ts +7 -0
  17. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -0
  18. package/dist/Svedit.svelte +936 -0
  19. package/dist/Svedit.svelte.d.ts +12 -0
  20. package/dist/Svedit.svelte.d.ts.map +1 -0
  21. package/dist/Transaction.svelte.d.ts +198 -0
  22. package/dist/Transaction.svelte.d.ts.map +1 -0
  23. package/dist/Transaction.svelte.js +577 -0
  24. package/dist/UnknownNode.svelte +12 -0
  25. package/dist/UnknownNode.svelte.d.ts +12 -0
  26. package/dist/UnknownNode.svelte.d.ts.map +1 -0
  27. package/dist/commands.svelte.d.ts +5 -0
  28. package/dist/commands.svelte.d.ts.map +1 -0
  29. package/dist/commands.svelte.js +261 -0
  30. package/dist/index.d.ts +10 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +14 -0
  33. package/dist/types.d.ts +264 -0
  34. package/dist/util.d.ts +4 -0
  35. package/dist/util.d.ts.map +1 -0
  36. package/dist/util.js +78 -0
  37. package/package.json +65 -0
  38. package/src/lib/AnnotatedStringProperty.svelte +126 -0
  39. package/src/lib/CustomProperty.svelte +40 -0
  40. package/src/lib/Document.svelte.js +681 -0
  41. package/src/lib/Node.svelte +41 -0
  42. package/src/lib/NodeArrayProperty.svelte +45 -0
  43. package/src/lib/Svedit.svelte +936 -0
  44. package/src/lib/Transaction.svelte.js +577 -0
  45. package/src/lib/UnknownNode.svelte +12 -0
  46. package/src/lib/commands.svelte.js +261 -0
  47. package/src/lib/index.js +14 -0
  48. package/src/lib/types.d.ts +264 -0
  49. package/src/lib/util.js +78 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Michael Aufreiter, Johannes Mutter.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,323 @@
1
+ # Svedit
2
+
3
+ Svedit (think Svelte Edit) is a tiny library for building **rich content editors** with Svelte 5. You can model your content in JSON, render it with custom Svelte components, and edit it directly in the layout.
4
+
5
+ Try the [demo](https://svedit.vercel.app).
6
+
7
+ ## Why Svedit?
8
+
9
+ Because Svelte‘s reactivity system is **the perfect fit** for building super-lightweight content editing experiences.
10
+
11
+ In fact, they're so lightweight, you can use them to **make webpages in-place editable**, removing the need for an external Content Management System (CMS).
12
+
13
+ Svedit just gives you the gluing pieces around **defining a custom document model** and **mapping DOM selections** to the internal model and vice versa.
14
+
15
+ ## Getting started
16
+
17
+ Clone the bare-bones `hello-svedit` repository:
18
+
19
+ ```bash
20
+ git clone https://github.com/michael/hello-svedit
21
+ cd hello-svedit
22
+ ```
23
+
24
+ Install dependencies:
25
+
26
+ ```bash
27
+ npm install
28
+ ```
29
+
30
+ And run the development server:
31
+
32
+ ```bash
33
+ npm run dev
34
+ ```
35
+
36
+ Now make it your own. The next thing you probably want to do is define your own [node types](./src/routes/create_demo_doc.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).
37
+
38
+ ## Principles
39
+
40
+ **Chromeless canvas:** We keep the canvas chromeless, meaning there's no UI elements like toolbars or menus mingled with the content. You can interact with text directly, but everything else happens via tools are shown in separate overlays or in the fixed toolbar.
41
+
42
+ **Convention over configuration:** We use conventions and assumptions to reduce configuration code and limit the number of ways something can go wrong. For instance, we assume that a node with a property named `content` of type `string` or `annotated_string` is considered kind `text`, while all other nodes are considered kind `node`. Text nodes have special behavior in the system for editing (e.g. they can be splitted and joined).
43
+
44
+ **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 cursor blinks.
45
+
46
+ ## Graph data model
47
+
48
+ Svedit documents are represented in a simple JSON-based graph data model. There's a globally addressable space, a graph of content nodes if you want. This allows you to share pieces of content not only in the same document, but across multiple documents. E.g. you could share a navigation bar, while still being able to edit it in place (while changes will affect all places they are used).
49
+
50
+ ## Schema definitions
51
+
52
+ You can use a simple JSON-compatible schema definition language to enforce constraints on your documents. E.g. to make sure a page node always has a property body with references to nodes that are allowed within a page.
53
+
54
+ 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.
55
+
56
+ A top-level node that is accessible via a route we internally call a `document` (e.g. a page, event, etc.)
57
+
58
+ Properties of nodes can hold values:
59
+ - `string`: A good old JavaScript string
60
+ - `number`: Just like a number in JavaScript
61
+ - `integer`: A number for which Number.isInteger(number) returns true
62
+ - `boolean`: true or false
63
+ - `string_array`: An array of good old JavaScript strings
64
+ - `integer_array`: An array of integers
65
+ - `number_array`: An array of numbers
66
+ - `annotated_string`: a plain text string, but with annotations (bold, italic, link etc.)
67
+
68
+ Or references:
69
+ - `node`: References a single node (e.g. an image node can reference a global asset node)
70
+ - `node_array`: References a sequence of nodes (e.g. page.body references paragraph and list nodes)
71
+
72
+
73
+ ```js
74
+ const document_schema = {
75
+ page: {
76
+ body: {
77
+ type: 'node_array',
78
+ node_types: ['nav', 'paragraph', 'list'],
79
+ default_node_type: 'paragraph',
80
+ }
81
+ },
82
+ paragraph: {
83
+ content: { type: 'annotated_string' }
84
+ },
85
+ list_item: {
86
+ content: { type: 'annotated_string' },
87
+ },
88
+ list: {
89
+ list_items: {
90
+ type: 'node_array',
91
+ node_types: ['list_item'],
92
+ default_node_type: 'list_item',
93
+ }
94
+ },
95
+ nav: {
96
+ nav_items: {
97
+ type: 'node_array',
98
+ node_types: ['nav_item'],
99
+ default_node_type: 'nav_item',
100
+ }
101
+ },
102
+ nav_item: {
103
+ url: { type: 'string' },
104
+ label: { type: 'string' },
105
+ }
106
+ };
107
+ ```
108
+
109
+
110
+ ## Document serialization format (to drive an editing session in the browser)
111
+
112
+ A document is just a subsets of nodes, with a few rules:
113
+
114
+ - there must be a node (the document node) with the id of the document as an entry point (e.g. page_1)
115
+ - so the document is a node itself, with references to the underlying content (which live in separate nodes)
116
+ - all other nodes need to be traversible from that root node (unlinked nodes will be discarded on a save)
117
+ - in the serialization format the nodes need to be ordered, so that nodes that are referenced, are already defined (makes it easier to initialize the document)
118
+ - Your document must not contain cyclic references for that reason
119
+
120
+ Here's an example document:
121
+ ```js
122
+ const serialized_doc = [
123
+ {
124
+ id: 'nav_item_1',
125
+ type: 'nav_item',
126
+ url: '/homepage',
127
+ label: 'Home',
128
+ },
129
+ {
130
+ id: 'nav_1',
131
+ type: 'nav',
132
+ nav_items: ['nav_item_1'],
133
+ },
134
+ {
135
+ id: 'paragraph_1',
136
+ content: ['Hello world.', []],
137
+ },
138
+ {
139
+ id: 'list_item_1',
140
+ type: 'list_item',
141
+ content: ['first list item', []],
142
+ },
143
+ {
144
+ id: 'list_item_2',
145
+ type: 'list_item',
146
+ content: ['second list item', []],
147
+ },
148
+ {
149
+ id: 'list_1',
150
+ type: 'list',
151
+ list_items: ['list_item_1', 'list_item_2'],
152
+ },
153
+ {
154
+ id: 'page_1',
155
+ type: 'page',
156
+ body: ['nav_1', 'paragraph_1', 'list_1'],
157
+ },
158
+ ]
159
+ ```
160
+
161
+ ## Configuration
162
+
163
+ For Svedit to work, you also need to provide an app-specific config object, always available via doc.config for introspection.
164
+
165
+ ```js
166
+ const document_config = {
167
+ // Provide a custom id generator (ideally a UUID to avoid collisions)
168
+ generate_id: function() {
169
+ return nanoid();
170
+ },
171
+ system_components: {
172
+ NodeCursorTrap,
173
+ Overlay,
174
+ },
175
+ node_components: {
176
+ Page,
177
+ Button,
178
+ Text,
179
+ Story,
180
+ List,
181
+ ListItem,
182
+ ImageGrid,
183
+ ImageGridItem,
184
+ Hero
185
+ },
186
+ // TEMPORARY: Determines how many layouts are available for each node type
187
+ node_layouts: {
188
+ text: 4,
189
+ list: 5,
190
+ list_item: 1,
191
+ },
192
+ // Custom functions to insert new "blank" nodes and setting the selection
193
+ // depending on the intended behavior.
194
+ inserters: {
195
+ text: function(tr, content = ['', []], layout = 1) {
196
+ const new_text = {
197
+ id: nanoid(),
198
+ type: 'text',
199
+ layout,
200
+ content
201
+ };
202
+ tr.insert_nodes([new_text]);
203
+ },
204
+ }
205
+ };
206
+ ```
207
+
208
+ ## Document API
209
+
210
+ The Document API is central to Svedit. First you need to create a Document instance.
211
+
212
+ ```js
213
+ const doc = new Document(document_schema, serialized_doc, { config: document_config });
214
+ ```
215
+
216
+ To read/traverse and write the document graph:
217
+
218
+ ```js
219
+ // get the body (=array of node ids)
220
+ const body = doc.get(['page_1', 'body']); // => ['nav_1', 'paragraph_1', 'list_1']
221
+ console.log($state.snapshot(body));
222
+ const nav = doc.get(['nav_1']) // => { id: 'nav_1', type: 'nav', nav_items: ['document_nav_item_1'] }
223
+ ```
224
+
225
+ ## Transaction API
226
+
227
+ Documents need to be changed through transactions, which can consist of one or
228
+ multiple ops that are applied in a single step and are undo/redo-able.
229
+
230
+ ```js
231
+ console.log('nav.nav_items before:', $state.snapshot(nav.nav_items));
232
+ const tr = doc.tr; // creates a new transaction
233
+ const new_nav_items = nav.nav_items.slice(0, -1);
234
+ tr.set(['nav_1', 'nav_items'], new_nav_items);
235
+ doc.apply(tr); // applies the transaction to the document
236
+ console.log('nav.nav_items after:', $state.snapshot(nav.nav_items));
237
+ ```
238
+
239
+ ## Selections
240
+
241
+ Selections are at the heart of Svedit. There are just three types of selections:
242
+
243
+ 1. **Text Selection**: A text selection spans across a range of characters in a string. E.g. the below example has a collapsed cursor at position 1 in a text property 'content'.
244
+
245
+ ```js
246
+ {
247
+ type: 'text',
248
+ path: ['page_1234', 'body', 0, 'content'],
249
+ anchor_offset: 1,
250
+ focus_offset: 1
251
+ }
252
+ ```
253
+
254
+ 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.
255
+
256
+ ```js
257
+ {
258
+ type: 'node',
259
+ path: ['page_1234', 'body'],
260
+ anchor_offset: 2,
261
+ focus_offset: 4
262
+ }
263
+ ```
264
+
265
+ 3. **Property Selection**: A property selection addresses one particular property of a node.
266
+
267
+ ```js
268
+ {
269
+ type: "property",
270
+ path: [
271
+ "page_1",
272
+ "body",
273
+ 11,
274
+ "image"
275
+ ]
276
+ }
277
+ ```
278
+
279
+ You can access the current selection through `doc.selection` anytime. And you can programmatically set the selection using `doc.set_selection(new_selection)`.
280
+
281
+ ## Rendering
282
+
283
+ Now you can start making your Svelte pages in-place editable by wrapping your design inside the `<Svedit>` component.
284
+
285
+ ```js
286
+ <Svedit {doc} path={[doc.document_id]} editable={true} />
287
+ ```
288
+
289
+ ## Full API docs?
290
+
291
+ Not yet. Please just [read the code](./src) for now. It's only a couple of files with less than 3000 LOC in total. The files in `routes` are considered example code (copy them and adapt them to your needs), while files in `lib` are considered library code. Read them to understand the API and what's happening behind the scences.
292
+
293
+ ## Developing Svedit
294
+
295
+ Once you've cloned the Svedit repository and installed dependencies with `npm install`, start a development server:
296
+
297
+ ```bash
298
+ npm run dev
299
+ ```
300
+
301
+ ## Building
302
+
303
+ To create a production version of your app:
304
+
305
+ ```bash
306
+ npm run build
307
+ ```
308
+
309
+ You can preview the production build with `npm run preview`.
310
+
311
+ ## Contributing
312
+
313
+ At the very moment, the best way to help is to donate or to sponsor us, so we can buy time to work on this exclusively for a couple of more months. Please get in touch personally.
314
+
315
+ Find my contact details [here](https://editable.website).
316
+
317
+ ## Beta version
318
+
319
+ It's early. Expect bugs. Expect missing features. Expect the need for more work on your part to make this work for your use case.
320
+
321
+ ## Credits
322
+
323
+ Svedit led by [Michael Aufreiter](https://michaelaufreiter.com) with guidance and support from [Johannes Mutter](https://mutter.co).
@@ -0,0 +1,126 @@
1
+ <script>
2
+ import { getContext } from 'svelte';
3
+
4
+ /** @import { AnnotatedStringPropertyProps, AnnotationFragment } from './types.d.ts'; */
5
+
6
+ const svedit = getContext('svedit');
7
+
8
+ /** @type {AnnotatedStringPropertyProps} */
9
+ let {
10
+ path,
11
+ class: css_class,
12
+ placeholder = '',
13
+ } = $props();
14
+
15
+ let is_focused = $derived.by(() => {
16
+ return svedit.doc.selection?.type === 'text' && path.join('.') === svedit.doc.selection?.path.join('.');
17
+ });
18
+
19
+ /**
20
+ * Converts text with annotations into renderable fragments for display.
21
+ * @param {string} text - The plain text content
22
+ * @param {Array<[number, number, string, ...any]>} annotations - Array of annotations where each is [start_offset, end_offset, type, options?] (minimum 3 elements)
23
+ * @returns {Array<string|AnnotationFragment>} Array of fragments - strings for plain text, AnnotationFragment objects for annotated content
24
+ */
25
+ function render_annotated_string(text, annotations) {
26
+ let fragments = [];
27
+ let last_index = 0;
28
+
29
+ // Sort annotations by start_offset
30
+ const sorted_annotations = $state.snapshot(annotations).sort((a, b) => a[0] - b[0]);
31
+
32
+ for (let [index, annotation] of sorted_annotations.entries()) {
33
+ // Add text before the annotation
34
+ if (annotation[0] > last_index) {
35
+ fragments.push(text.slice(last_index, annotation[0]));
36
+ }
37
+
38
+ // Add the annotated string
39
+ const annotated_content = text.slice(annotation[0], annotation[1]);
40
+ fragments.push({
41
+ type: annotation[2],
42
+ content: annotated_content,
43
+ annotation_index: index,
44
+ data: annotation[3]
45
+ });
46
+
47
+ last_index = annotation[1];
48
+ }
49
+
50
+ // Add any remaining text after the last annotation
51
+ if (last_index < text.length) {
52
+ fragments.push(text.slice(last_index));
53
+ }
54
+
55
+ return fragments;
56
+ }
57
+
58
+ let fragments = $derived(render_annotated_string(svedit.doc.get(path)[0], svedit.doc.get(path)[1]));
59
+ let plain_text = $derived(svedit.doc.get(path)[0]);
60
+
61
+ /**
62
+ * @param {MouseEvent} e - The click event
63
+ */
64
+ function handle_link_click(e) {
65
+ e.preventDefault();
66
+ }
67
+ </script>
68
+
69
+ <!-- ATTENTION: The comments are needed to prevent unwanted text nodes with whitespace. -->
70
+ <div
71
+ data-type="text"
72
+ data-path={path.join('.')}
73
+ style="anchor-name: --{path.join('-')};"
74
+ class="text svedit-selectable {css_class}"
75
+ class:empty={plain_text.length === 0}
76
+ class:focused={is_focused}
77
+ placeholder={placeholder}
78
+ >
79
+ {#each fragments as fragment, index (index)}
80
+ {#if typeof fragment === 'string'}<!--
81
+ -->{fragment}<!--
82
+ -->{:else if fragment.type === 'emphasis'}<!--
83
+ --><em>{fragment.content}</em><!--
84
+ -->{:else if fragment.type === 'strong'}<!--
85
+ --><strong>{fragment.content}</strong><!--
86
+ -->{:else if fragment.type === 'link'}<!--
87
+ --><a
88
+ onclick={handle_link_click}
89
+ style="anchor-name: --{path.join('-') + '-' + fragment.annotation_index};"
90
+ href={fragment.data.href}
91
+ target={fragment.data.target || '_self'}>{fragment.content}</a><!--
92
+ -->{:else}<!--
93
+ -->{fragment.content}<!--
94
+ -->{/if}
95
+ {/each}<!--
96
+ --><br>
97
+ </div>
98
+
99
+ <style>
100
+ .text {
101
+ white-space: pre-wrap;
102
+ overflow-wrap: anywhere;
103
+ box-sizing: content-box;
104
+
105
+ &.heading1 {
106
+ text-wrap: var(--text-wrap);
107
+ }
108
+ }
109
+
110
+ [placeholder].empty::before {
111
+ content: attr(placeholder);
112
+ pointer-events: none;
113
+ color: color-mix(in oklch, currentcolor 50%, transparent);
114
+ }
115
+
116
+ /* We can safely hide the <br> element when the placeholder is empty and focused. */
117
+ /* No longer the cursor will be rendered after the placeholder when focused. */
118
+ [placeholder].empty.focused br {
119
+ display:none;
120
+ }
121
+
122
+ .text.focused {
123
+ background: none;
124
+ outline: 1px dashed var(--editing-stroke-color);
125
+ }
126
+ </style>
@@ -0,0 +1,7 @@
1
+ export default AnnotatedStringProperty;
2
+ type AnnotatedStringProperty = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<AnnotatedStringPropertyProps>): void;
5
+ };
6
+ declare const AnnotatedStringProperty: import("svelte").Component<import("./types.d.ts").AnnotatedStringPropertyProps, {}, "">;
7
+ //# sourceMappingURL=AnnotatedStringProperty.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AnnotatedStringProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AnnotatedStringProperty.svelte.js"],"names":[],"mappings":";;;;;AAoFA,+HAAsE"}
@@ -0,0 +1,40 @@
1
+ <script>
2
+ /** @import { CustomPropertyProps } from './types.d.ts'; */
3
+
4
+ /** @type {CustomPropertyProps} */
5
+ let {
6
+ path,
7
+ class: css_class,
8
+ children,
9
+ } = $props();
10
+ </script>
11
+
12
+ <!-- NOTE: It's super crucial that there is no extra whitespace, to not introduce additional cursor positions in contenteditable -->
13
+ <div
14
+ class="property {css_class}"
15
+ data-type="property"
16
+ data-path={path.join('.')}
17
+ style="anchor-name: --{path.join('-')};"
18
+ >
19
+ <div class="cursor-trap svedit-selectable"><br/></div>
20
+ <div class="property-content" contenteditable="false">{@render children()}</div>
21
+ </div>
22
+
23
+ <style>
24
+ .property {
25
+ position: relative;
26
+ }
27
+ .cursor-trap {
28
+ position: absolute;
29
+ top: 0;
30
+ left: 0;
31
+ right: 0;
32
+ bottom: 0;
33
+ z-index: 1;
34
+ outline: none;
35
+ }
36
+ .property-content {
37
+ width: 100%;
38
+ height: 100%;
39
+ }
40
+ </style>
@@ -0,0 +1,8 @@
1
+ export default CustomProperty;
2
+ type CustomProperty = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<CustomPropertyProps>): void;
5
+ };
6
+ declare const CustomProperty: import("svelte").Component<CustomPropertyProps, {}, "">;
7
+ import type { CustomPropertyProps } from './types.d.ts';
8
+ //# sourceMappingURL=CustomProperty.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CustomProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/CustomProperty.svelte.js"],"names":[],"mappings":";;;;;AAwBA,sFAA6D;yCApBlB,cAAc"}