svedit 0.6.4 → 0.7.1

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 (66) hide show
  1. package/README.md +117 -45
  2. package/dist/AnnotatedTextProperty.svelte +14 -17
  3. package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.d.ts +3 -3
  5. package/dist/Command.svelte.js +5 -5
  6. package/dist/CustomProperty.svelte +5 -4
  7. package/dist/Node.svelte +29 -14
  8. package/dist/Node.svelte.d.ts.map +1 -1
  9. package/dist/NodeArrayProperty.svelte +29 -9
  10. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  11. package/dist/NodeCaret.svelte +71 -0
  12. package/dist/NodeCaret.svelte.d.ts +27 -0
  13. package/dist/NodeCaret.svelte.d.ts.map +1 -0
  14. package/dist/NodeGap.svelte +249 -0
  15. package/dist/NodeGap.svelte.d.ts +20 -0
  16. package/dist/NodeGap.svelte.d.ts.map +1 -0
  17. package/dist/NodeGapMarkers.svelte +456 -0
  18. package/dist/NodeGapMarkers.svelte.d.ts +12 -0
  19. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -0
  20. package/dist/NodeSelectionMarkers.svelte +49 -0
  21. package/dist/NodeSelectionMarkers.svelte.d.ts +7 -0
  22. package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -0
  23. package/dist/Session.svelte.d.ts +6 -6
  24. package/dist/Session.svelte.d.ts.map +1 -1
  25. package/dist/Session.svelte.js +13 -69
  26. package/dist/Svedit.svelte +161 -95
  27. package/dist/Svedit.svelte.d.ts.map +1 -1
  28. package/dist/Transaction.svelte.d.ts +1 -1
  29. package/dist/Transaction.svelte.d.ts.map +1 -1
  30. package/dist/Transaction.svelte.js +8 -68
  31. package/dist/doc_utils.d.ts +12 -0
  32. package/dist/doc_utils.d.ts.map +1 -1
  33. package/dist/doc_utils.js +61 -2
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.js +4 -0
  36. package/dist/node_gap_computation.svelte.d.ts +5 -0
  37. package/dist/node_gap_computation.svelte.d.ts.map +1 -0
  38. package/dist/node_gap_computation.svelte.js +621 -0
  39. package/dist/styles/svedit-colors.css +8 -0
  40. package/dist/transforms.svelte.d.ts +8 -0
  41. package/dist/transforms.svelte.d.ts.map +1 -1
  42. package/dist/transforms.svelte.js +20 -10
  43. package/dist/types.d.ts +56 -68
  44. package/dist/utils.d.ts +0 -3
  45. package/dist/utils.d.ts.map +1 -1
  46. package/dist/utils.js +10 -10
  47. package/package.json +21 -21
  48. package/src/lib/AnnotatedTextProperty.svelte +14 -17
  49. package/src/lib/Command.svelte.js +5 -5
  50. package/src/lib/CustomProperty.svelte +5 -4
  51. package/src/lib/Node.svelte +29 -14
  52. package/src/lib/NodeArrayProperty.svelte +29 -9
  53. package/src/lib/NodeCaret.svelte +71 -0
  54. package/src/lib/NodeGap.svelte +249 -0
  55. package/src/lib/NodeGapMarkers.svelte +456 -0
  56. package/src/lib/NodeSelectionMarkers.svelte +49 -0
  57. package/src/lib/Session.svelte.js +13 -69
  58. package/src/lib/Svedit.svelte +161 -95
  59. package/src/lib/Transaction.svelte.js +8 -68
  60. package/src/lib/doc_utils.js +61 -2
  61. package/src/lib/index.js +4 -0
  62. package/src/lib/node_gap_computation.svelte.js +621 -0
  63. package/src/lib/styles/svedit-colors.css +8 -0
  64. package/src/lib/transforms.svelte.js +20 -10
  65. package/src/lib/types.d.ts +56 -68
  66. 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
- Clone the bare-bones `hello-svedit` repository:
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 cursor blinks.
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
- - `string_array`: An array of good old JavaScript strings
78
- - `integer_array`: An array of integers
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
- - `annotated_text`: a plain text string, but with annotations (bold, italic, link etc.)
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: { type: 'annotated_text', allow_newlines: true }
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: { type: 'annotated_text', allow_newlines: true },
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: 'text',
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
- // System components (NodeCursorTrap, Overlays)
207
- system_components: { NodeCursorTrap, Overlays },
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`** - Provides custom NodeCursorTrap and Overlays 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 cursor
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 cursor
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 cursor position
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(selection.path, selection.anchor_offset, [heading_id]);
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 cursor (replaces selection if expanded)
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 cursor
596
+ - `BreakTextNodeCommand` - Split text node at caret
534
597
  - `SelectAllCommand` - Progressively expand selection
535
- - `InsertDefaultNodeCommand` - Insert a new node at cursor
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
- 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'.
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 cursor behavior
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 key in this map corresponds to the node's `type` property in the schema. Note that the component name should match the node type name. For example, a node with `type: "list_item"` will look for a component registered as `ListItem` in the `node_components` map.
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 `<NodeCursorTrap>` and why it is implemented the way it is.
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 cursor position.
1024
+ Putting a <br> tag into a div gives you a single addressable caret position.
956
1025
 
957
- Adding a &ZeroWidthSpace; (or any character) here will lead to 2 cursor
1026
+ Adding a &ZeroWidthSpace; (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-cursor-traps, and when an
1032
+ Svedit uses this behavior for node gaps, and when an
964
1033
  <AnnotatedTextProperty> is empty.
965
1034
  -->
966
- <div class="cursor-trap"><br></div>
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 cursor positions. One before, and another one
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 `<NodeCursorTrap>` to create a node-cursor
973
- visualization, that doesn't mess with the contenteditable cursor positions.
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-cursor">&ZeroWidthSpace;</div>
1044
+ <div contenteditable="false" class="node-caret">&ZeroWidthSpace;</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
- ## Full API docs?
1059
+ ## Beyond the README
988
1060
 
989
- 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 scenes.
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,18 +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 cursor to disappear on Safari Desktop (tested with v26). -->
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}
114
119
  class:editable={svedit.editable}
115
120
  {placeholder}
121
+ {...rest}
116
122
  >
117
123
  {#each fragments as fragment, index (index)}
118
124
  {#if typeof fragment === 'string'}{fragment}{:else if fragment.type === 'selection_highlight'}<span
@@ -135,10 +141,6 @@
135
141
  white-space: pre-wrap;
136
142
  overflow-wrap: anywhere;
137
143
  box-sizing: content-box;
138
-
139
- &.heading1 {
140
- text-wrap: var(--text-wrap);
141
- }
142
144
  }
143
145
  /* We switch from ::before to ::after when the element is focused. So the the caret is always before the placeholder. */
144
146
  [placeholder].empty:not(.focused)::before,
@@ -153,24 +155,19 @@
153
155
  caret-color: transparent;
154
156
  }
155
157
 
156
- /*.text.focused {
157
- background: none;
158
- outline: 1px solid var(--editing-stroke-color);
159
- }*/
160
-
161
158
  /* Disable text-transform when editable and focused so users see original text */
162
159
  .text.editable.focused {
163
160
  text-transform: none !important;
164
161
  }
165
162
 
166
163
  /* Dim the selection highlight when canvas loses native focus */
167
- :global(.svedit-canvas:not(:focus)) .selection-highlight {
168
- background: oklch(from var(--editing-fill-color) l 0.02 h / 0.3);
164
+ :global(.svedit-canvas:not(:focus-within)) .selection-highlight {
165
+ background: oklch(from var(--svedit-editing-fill) l 0 h / alpha);
169
166
  }
170
167
 
171
- /* Make a collapsed cursor visible */
172
- :global(.svedit-canvas:not(:focus)) .selection-highlight:empty {
168
+ /* Make a collapsed caret visible */
169
+ :global(.svedit-canvas:not(:focus-within)) .selection-highlight:empty {
173
170
  background: none;
174
- outline: 0.5px solid oklch(from var(--editing-stroke-color) l 0.02 h / 0.3);
171
+ outline: 0.5px solid oklch(from var(--svedit-editing-stroke) l 0 h / alpha);
175
172
  }
176
173
  </style>
@@ -1 +1 @@
1
- {"version":3,"file":"AnnotatedTextProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AnnotatedTextProperty.svelte.js"],"names":[],"mappings":";;;;;AA6HA,2HAAoE"}
1
+ {"version":3,"file":"AnnotatedTextProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AnnotatedTextProperty.svelte.js"],"names":[],"mappings":";;;;;AAkIA,2HAAoE"}
@@ -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 cursor position.
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 cursor position.
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 cursor position.
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 {
@@ -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 no_annotation_and_cursor_not_collapsed =
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 || no_annotation_and_cursor_not_collapsed)
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 cursor position.
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 cursor position.
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 cursor position.
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="cursor-trap">
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
- .cursor-trap {
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 NodeCursorTrap = $derived(svedit.session.config.system_components.NodeCursorTrap);
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
- let is_first_node_array_child = $derived(
18
- is_inside_node_array && parseInt(String(path.at(-1)), 10) === 0
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
- <NodeCursorTrap {path} type="position-zero-cursor-trap" />
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
- <NodeCursorTrap {path} type="after-node-cursor-trap" />
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
- div {
42
- position: relative;
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":";;;;;AAwCA,yFAAmD"}
1
+ {"version":3,"file":"Node.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Node.svelte.js"],"names":[],"mappings":";;;;;AAoDA,yFAAmD"}