svedit 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) 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 +153 -88
  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.map +1 -1
  41. package/dist/transforms.svelte.js +7 -10
  42. package/dist/types.d.ts +56 -68
  43. package/dist/utils.d.ts +0 -3
  44. package/dist/utils.d.ts.map +1 -1
  45. package/dist/utils.js +10 -10
  46. package/package.json +21 -21
  47. package/src/lib/AnnotatedTextProperty.svelte +14 -17
  48. package/src/lib/Command.svelte.js +5 -5
  49. package/src/lib/CustomProperty.svelte +5 -4
  50. package/src/lib/Node.svelte +29 -14
  51. package/src/lib/NodeArrayProperty.svelte +29 -9
  52. package/src/lib/NodeCaret.svelte +71 -0
  53. package/src/lib/NodeGap.svelte +249 -0
  54. package/src/lib/NodeGapMarkers.svelte +456 -0
  55. package/src/lib/NodeSelectionMarkers.svelte +49 -0
  56. package/src/lib/Session.svelte.js +13 -69
  57. package/src/lib/Svedit.svelte +153 -88
  58. package/src/lib/Transaction.svelte.js +8 -68
  59. package/src/lib/doc_utils.js +61 -2
  60. package/src/lib/index.js +4 -0
  61. package/src/lib/node_gap_computation.svelte.js +621 -0
  62. package/src/lib/styles/svedit-colors.css +8 -0
  63. package/src/lib/transforms.svelte.js +7 -10
  64. package/src/lib/types.d.ts +56 -68
  65. package/src/lib/utils.js +10 -10
package/dist/types.d.ts CHANGED
@@ -93,59 +93,53 @@ export type PrimitiveType = ScalarType | ArrayType | RichType;
93
93
  */
94
94
  export type PropertyType = PrimitiveType | ReferenceType;
95
95
 
96
- /**
97
- * Document schema primitive types - all possible property types in document schemas.
98
- */
99
- export type DocumentSchemaPrimitive =
100
- | 'string'
101
- | 'number'
102
- | 'boolean'
103
- | 'integer'
104
- | 'datetime'
105
- | 'string_array'
106
- | 'number_array'
107
- | 'boolean_array'
108
- | 'integer_array'
109
- | 'annotated_text'
110
- | 'node'
111
- | 'node_array';
112
-
113
- /**
114
- * Maps document schema types to their JavaScript runtime types.
115
- */
116
- export type DocumentSchemaValueToJs<T> = T extends 'string'
117
- ? string
118
- : T extends 'number'
119
- ? number
120
- : T extends 'boolean'
121
- ? boolean
122
- : T extends 'integer'
123
- ? number
124
- : T extends 'datetime'
125
- ? string
126
- : T extends 'string_array'
127
- ? Array<string>
128
- : T extends 'number_array'
129
- ? Array<number>
130
- : T extends 'boolean_array'
131
- ? Array<boolean>
132
- : T extends 'integer_array'
133
- ? Array<number>
134
- : T extends 'annotated_text'
135
- ? AnnotatedText
136
- : T extends 'node'
137
- ? string
138
- : T extends 'node_array'
139
- ? Array<string>
140
- : never;
141
-
142
- /**
143
- * Converts a document node schema definition to its inferred JS type.
144
- * Handles the {type: "..."} wrapper structure used in document schemas.
145
- */
146
- export type DocumentNodeToJs<S extends NodeSchema> = { id: string; type: string } & {
147
- [K in keyof S['properties']]: DocumentSchemaValueToJs<S['properties'][K]['type']>;
148
- };
96
+ // TODO: We may later want more sophisticated type inference, so that typing
97
+ // e.g. `session.get('image')` auto-completes the node's properties based on
98
+ // the schema definition. The types below are a starting point for that.
99
+ //
100
+ // export type DocumentSchemaPrimitive =
101
+ // | 'string'
102
+ // | 'number'
103
+ // | 'boolean'
104
+ // | 'integer'
105
+ // | 'datetime'
106
+ // | 'string_array'
107
+ // | 'number_array'
108
+ // | 'boolean_array'
109
+ // | 'integer_array'
110
+ // | 'annotated_text'
111
+ // | 'node'
112
+ // | 'node_array';
113
+ //
114
+ // export type DocumentSchemaValueToJs<T> = T extends 'string'
115
+ // ? string
116
+ // : T extends 'number'
117
+ // ? number
118
+ // : T extends 'boolean'
119
+ // ? boolean
120
+ // : T extends 'integer'
121
+ // ? number
122
+ // : T extends 'datetime'
123
+ // ? string
124
+ // : T extends 'string_array'
125
+ // ? Array<string>
126
+ // : T extends 'number_array'
127
+ // ? Array<number>
128
+ // : T extends 'boolean_array'
129
+ // ? Array<boolean>
130
+ // : T extends 'integer_array'
131
+ // ? Array<number>
132
+ // : T extends 'annotated_text'
133
+ // ? AnnotatedText
134
+ // : T extends 'node'
135
+ // ? string
136
+ // : T extends 'node_array'
137
+ // ? Array<string>
138
+ // : never;
139
+ //
140
+ // export type DocumentNodeToJs<S extends NodeSchema> = { id: string; type: string } & {
141
+ // [K in keyof S['properties']]: DocumentSchemaValueToJs<S['properties'][K]['type']>;
142
+ // };
149
143
 
150
144
  /**
151
145
  * A property that stores an annotated text with required allow_newlines setting.
@@ -337,6 +331,8 @@ export type AnnotatedTextPropertyProps = {
337
331
  class?: string;
338
332
  /** A placeholder to be rendered for empty content */
339
333
  placeholder?: string;
334
+ /** Rest props to spread onto the rendered element (e.g. href, target, etc.) */
335
+ [key: string]: any;
340
336
  };
341
337
 
342
338
  /**
@@ -351,6 +347,8 @@ export type CustomPropertyProps = {
351
347
  class?: string;
352
348
  /** The content of the custom property (e.g. an image) */
353
349
  children: Snippet;
350
+ /** Rest props to spread onto the rendered element */
351
+ [key: string]: any;
354
352
  };
355
353
 
356
354
  /**
@@ -361,10 +359,10 @@ export type NodeArrayPropertyProps = {
361
359
  path: DocumentPath;
362
360
  /** Optional custom HTML tag */
363
361
  tag?: string;
364
- /** Optional custom HTML tag */
365
- tag?: string;
366
362
  /** The `class` attribute on the container element */
367
363
  class?: string;
364
+ /** Rest props to spread onto the rendered element (e.g. href, target, etc.) */
365
+ [key: string]: any;
368
366
  };
369
367
 
370
368
  /**
@@ -379,22 +377,12 @@ export type NodeProps = {
379
377
  class?: string;
380
378
  /** The type-specific content of the node */
381
379
  children: Snippet;
380
+ /** Rest props to spread onto the rendered element (e.g. href, target, etc.) */
381
+ [key: string]: any;
382
382
  };
383
383
 
384
384
  /**
385
- * Props for the CustomProperty component
386
- */
387
- export type CustomPropertyProps = {
388
- /** The full path to the property */
389
- path: DocumentPath;
390
- /** The `class` attribute on the content element */
391
- class?: string;
392
- /** The content of the custom property (e.g. an image) */
393
- children: Snippet;
394
- };
395
-
396
- /**
397
- * Props for the CustomProperty component
385
+ * Props for the Svedit component
398
386
  */
399
387
  export type SveditProps = {
400
388
  /** The session instance */
package/dist/utils.d.ts CHANGED
@@ -1,6 +1,3 @@
1
- /**
2
- * @import { Annotation, AnnotatedText, SelectionRange, Selection } from './types.d.ts';
3
- */
4
1
  /**
5
2
  * Detect if the current browser is on a mobile device
6
3
  * @returns {boolean} true if mobile browser, false otherwise
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/lib/utils.js"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;GAGG;AACH,qCAFa,OAAO,CAanB;AAED;;;GAGG;AACH,6CAFa,OAAO,CAUnB;AAED;;;;;;;;;;;;;GAaG;AACH,qCARW,MAAM,GACJ,MAAM,CAUlB;AAED;;;;;;;;;;;;;;GAcG;AACH,iCATW,MAAM,SACN,MAAM,GACJ,MAAM,CAWlB;AAED;;;;;;;;;;;;;;GAcG;AACH,gCATW,MAAM,SACN,MAAM,QACN,MAAM,GACJ,MAAM,CAalB;AAED;;;;;;;;;;;;;GAaG;AACH,0CARW,MAAM,gBACN,MAAM,GACJ,MAAM,CAoBlB;AAED;;;;;;;;;;;;;GAaG;AACH,0CARW,MAAM,eACN,MAAM,GACJ,MAAM,CAgBlB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,4DAZW,aAAa,eACb,MAAM,GACJ,CAAC,aAAa,EAAE,aAAa,CAAC,CA6C1C;AAED;;;;;;;;;;;;;GAaG;AACH,gDARW,aAAa,eACb,aAAa,GACX,aAAa,CA4CzB;AAED;;;;;;;;GAQG;AACH,qCANW,MAAM,GACJ,MAAM,CAUlB;AAED,uEAgCC;AAED;;;;;GAKG;AACH,gDAHW,SAAS,GACP,cAAc,GAAG,IAAI,CAWjC;AAED,gEAMC;mCAhVwE,cAAc;+BAAd,cAAc;oCAAd,cAAc"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/lib/utils.js"],"names":[],"mappings":"AAMA;;;GAGG;AACH,qCAFa,OAAO,CAanB;AAGD;;;GAGG;AACH,6CAFa,OAAO,CAUnB;AAED;;;;;;;;;;;;;GAaG;AACH,qCARW,MAAM,GACJ,MAAM,CASlB;AAID;;;;;;;;;;;;;;GAcG;AACH,iCATW,MAAM,SACN,MAAM,GACJ,MAAM,CAUlB;AAED;;;;;;;;;;;;;;GAcG;AACH,gCATW,MAAM,SACN,MAAM,QACN,MAAM,GACJ,MAAM,CAYlB;AAED;;;;;;;;;;;;;GAaG;AACH,0CARW,MAAM,gBACN,MAAM,GACJ,MAAM,CAmBlB;AAED;;;;;;;;;;;;;GAaG;AACH,0CARW,MAAM,eACN,MAAM,GACJ,MAAM,CAelB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,4DAZW,aAAa,eACb,MAAM,GACJ,CAAC,aAAa,EAAE,aAAa,CAAC,CA6C1C;AAED;;;;;;;;;;;;;GAaG;AACH,gDARW,aAAa,eACb,aAAa,GACX,aAAa,CA4CzB;AAED;;;;;;;;GAQG;AACH,qCANW,MAAM,GACJ,MAAM,CAUlB;AAED,uEAgCC;AAED;;;;;GAKG;AACH,gDAHW,SAAS,GACP,cAAc,GAAG,IAAI,CAWjC;AAED,gEAMC;mCAhVwE,cAAc;+BAAd,cAAc;oCAAd,cAAc"}
package/dist/utils.js CHANGED
@@ -2,6 +2,8 @@
2
2
  * @import { Annotation, AnnotatedText, SelectionRange, Selection } from './types.d.ts';
3
3
  */
4
4
 
5
+ const SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' });
6
+
5
7
  /**
6
8
  * Detect if the current browser is on a mobile device
7
9
  * @returns {boolean} true if mobile browser, false otherwise
@@ -19,6 +21,7 @@ export function is_mobile_browser() {
19
21
  );
20
22
  }
21
23
 
24
+ // ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
22
25
  /**
23
26
  * Detect if the current browser is Chrome on desktop
24
27
  * @returns {boolean} true if Chrome desktop browser, false otherwise
@@ -48,10 +51,11 @@ export function is_chrome_desktop_browser() {
48
51
  * get_char_length('👋🏽') // Returns: 1 (not 4 - skin tone modifier treated as single char)
49
52
  */
50
53
  export function get_char_length(str) {
51
- const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
52
- return [...segmenter.segment(str)].length;
54
+ return [...SEGMENTER.segment(str)].length;
53
55
  }
54
56
 
57
+
58
+ // ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
55
59
  /**
56
60
  * Get a single character at the specified position (accounting for multi-byte characters)
57
61
  *
@@ -68,8 +72,7 @@ export function get_char_length(str) {
68
72
  * get_char_at('👋🏽', 0) // Returns: '👋🏽' (skin tone modifier included)
69
73
  */
70
74
  export function get_char_at(str, index) {
71
- const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
72
- const segments = [...segmenter.segment(str)];
75
+ const segments = [...SEGMENTER.segment(str)];
73
76
  return segments[index].segment;
74
77
  }
75
78
 
@@ -89,8 +92,7 @@ export function get_char_at(str, index) {
89
92
  * char_slice('a👋🏽b', 1, 2) // Returns: '👋🏽' (skin tone modifier included)
90
93
  */
91
94
  export function char_slice(str, start, end = undefined) {
92
- const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
93
- const segments = [...segmenter.segment(str)];
95
+ const segments = [...SEGMENTER.segment(str)];
94
96
  return segments
95
97
  .slice(start, end)
96
98
  .map((s) => s.segment)
@@ -112,8 +114,7 @@ export function char_slice(str, start, end = undefined) {
112
114
  * utf16_to_char_offset("a😀b", 3) // Returns: 2 (position after emoji)
113
115
  */
114
116
  export function utf16_to_char_offset(str, utf16_offset) {
115
- const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
116
- const segments = [...segmenter.segment(str)];
117
+ const segments = [...SEGMENTER.segment(str)];
117
118
  let char_offset = 0;
118
119
  let utf16_count = 0;
119
120
 
@@ -142,8 +143,7 @@ export function utf16_to_char_offset(str, utf16_offset) {
142
143
  * char_to_utf16_offset("a😀b", 2) // Returns: 3 (UTF-16 position after emoji)
143
144
  */
144
145
  export function char_to_utf16_offset(str, char_offset) {
145
- const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
146
- const segments = [...segmenter.segment(str)];
146
+ const segments = [...SEGMENTER.segment(str)];
147
147
  let utf16_offset = 0;
148
148
 
149
149
  for (let i = 0; i < Math.min(char_offset, segments.length); i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svedit",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -39,29 +39,29 @@
39
39
  "svelte": "^5.0.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@eslint/compat": "^1.4.1",
43
- "@eslint/js": "^9.39.1",
42
+ "@eslint/compat": "^2.0.3",
43
+ "@eslint/js": "^10.0.1",
44
44
  "@sveltejs/adapter-auto": "^7.0.0",
45
- "@sveltejs/kit": "^2.48.4",
46
- "@sveltejs/package": "^2.5.4",
47
- "@sveltejs/vite-plugin-svelte": "^6.2.1",
48
- "@typescript-eslint/eslint-plugin": "^8.46.3",
49
- "@typescript-eslint/parser": "^8.46.3",
50
- "@vitest/browser": "^4.0.8",
51
- "@vitest/browser-preview": "^4.0.8",
52
- "eslint": "^9.39.1",
45
+ "@sveltejs/kit": "^2.55.0",
46
+ "@sveltejs/package": "^2.5.7",
47
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
48
+ "@typescript-eslint/eslint-plugin": "^8.57.0",
49
+ "@typescript-eslint/parser": "^8.57.0",
50
+ "@vitest/browser": "^4.1.0",
51
+ "@vitest/browser-preview": "^4.1.0",
52
+ "eslint": "^10.0.3",
53
53
  "eslint-config-prettier": "^10.1.8",
54
- "eslint-plugin-svelte": "^3.13.0",
55
- "globals": "^16.5.0",
54
+ "eslint-plugin-svelte": "^3.15.1",
55
+ "globals": "^17.4.0",
56
56
  "nanoid": "^5.1.6",
57
- "prettier": "^3.6.2",
58
- "prettier-plugin-svelte": "^3.4.0",
59
- "publint": "^0.3.15",
60
- "svelte": "^5.43.4",
61
- "svelte-check": "^4.3.3",
62
- "vite": "^7.2.2",
57
+ "prettier": "^3.8.1",
58
+ "prettier-plugin-svelte": "^3.5.1",
59
+ "publint": "^0.3.18",
60
+ "svelte": "^5.53.12",
61
+ "svelte-check": "^4.4.5",
62
+ "vite": "^8.0.0",
63
63
  "typescript": "^5.9.3",
64
- "vitest": "^4.0.8",
65
- "vitest-browser-svelte": "^2.0.1"
64
+ "vitest": "^4.1.0",
65
+ "vitest-browser-svelte": "^2.1.0"
66
66
  }
67
67
  }
@@ -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>
@@ -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;
@@ -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,24 +1,39 @@
1
1
  <script>
2
- import { getContext } from 'svelte';
2
+ import { getContext, setContext } from 'svelte';
3
3
  import UnknownNode from './UnknownNode.svelte';
4
4
  import { snake_to_pascal } from './utils.js';
5
+ import DefaultNodeGap from './NodeGap.svelte';
6
+ import DefaultNodeGapMarkers from './NodeGapMarkers.svelte';
5
7
 
6
8
  /** @import { NodeArrayPropertyProps } from './types.d.ts'; */
7
9
 
8
10
  const svedit = getContext('svedit');
9
- let NodeCursorTrap = $derived(svedit.session.config.system_components.NodeCursorTrap);
11
+ let NodeGap = $derived(svedit.session.config.system_components?.NodeGap ?? DefaultNodeGap);
12
+ let NodeGapMarkers = $derived(svedit.session.config.system_components?.NodeGapMarkers ?? DefaultNodeGapMarkers);
10
13
 
11
14
  /** @type {NodeArrayPropertyProps} */
12
- let { path, tag = 'div', class: css_class } = $props();
15
+ let { path, tag = 'div', class: css_class, style = '', ...rest } = $props();
13
16
 
14
17
  let nodes = $derived(
15
18
  svedit.session
16
19
  .get(path)
17
20
  .map(/** @param {string} node_id */ (node_id) => svedit.session.get(node_id))
18
21
  );
19
- </script>
20
22
 
21
- <svelte:element this={tag} class={css_class} data-type="node_array" data-path={path.join('.')}>
23
+ // Expose array metadata to child Node.svelte instances via context,
24
+ // avoiding expensive session.inspect() / session.get() calls per node.
25
+ setContext('node_array_meta', {
26
+ get length() { return nodes.length; }
27
+ });
28
+ </script>
29
+ <!-- we use the anchor of node_array in Overlays.svelte to position the last insertion point in a horizontal layout based on the right edge of the container -->
30
+ <svelte:element
31
+ this={tag}
32
+ class={css_class}
33
+ data-type="node_array"
34
+ data-path={path.join('.')}
35
+ style="anchor-name: --{path.join('-')};{style ? ` ${style}` : ''}" {...rest}
36
+ >
22
37
  {#if nodes.length === 0 && svedit.editable}
23
38
  <!--
24
39
  Experimental: We'll let .empty-node-array act like a node, so the existing
@@ -32,10 +47,12 @@
32
47
  data-type="node"
33
48
  style="anchor-name: --{[...path, 0].join(
34
49
  '-'
35
- )}; position: relative; min-height: 40px; min-width: 40px;"
36
- >
37
- <NodeCursorTrap path={[...path, 0]} type="position-zero-cursor-trap" />
38
- </div>
50
+ )}; min-height: 40px; min-width: 24px;"
51
+ ></div>
52
+ <!-- Sibling (not child) of .empty-node-array so its .svedit-selectable
53
+ resolves anchor positioning against the shared containing block,
54
+ not the placeholder which inherits .node positioning styles. -->
55
+ <NodeGap path={[...path, 0]} type="gap-before" empty />
39
56
  {/if}
40
57
  {#each nodes as node, index (index)}
41
58
  {@const Component = svedit.session.config.node_components[snake_to_pascal(node.type)]}
@@ -45,4 +62,7 @@
45
62
  <UnknownNode path={[...path, index]} />
46
63
  {/if}
47
64
  {/each}
65
+ {#if svedit.editable && NodeGapMarkers}
66
+ <NodeGapMarkers {path} />
67
+ {/if}
48
68
  </svelte:element>