svedit 0.8.0 → 0.10.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 (68) hide show
  1. package/README.md +137 -4
  2. package/dist/AnnotatedTextProperty.svelte +41 -8
  3. package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.d.ts +0 -1
  5. package/dist/Command.svelte.d.ts.map +1 -1
  6. package/dist/Command.svelte.js +18 -3
  7. package/dist/CustomProperty.svelte +20 -9
  8. package/dist/CustomProperty.svelte.d.ts +2 -1
  9. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  10. package/dist/Node.svelte +7 -5
  11. package/dist/Node.svelte.d.ts.map +1 -1
  12. package/dist/NodeArrayProperty.svelte +65 -42
  13. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  14. package/dist/NodeCaret.svelte +0 -7
  15. package/dist/NodeGap.svelte +179 -81
  16. package/dist/NodeGap.svelte.d.ts +0 -2
  17. package/dist/NodeGap.svelte.d.ts.map +1 -1
  18. package/dist/NodeGapMarkers.svelte +251 -84
  19. package/dist/NodeGapMarkers.svelte.d.ts +2 -0
  20. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
  21. package/dist/NodeSelectionMarkers.svelte +4 -3
  22. package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
  23. package/dist/Session.svelte.d.ts +12 -1
  24. package/dist/Session.svelte.d.ts.map +1 -1
  25. package/dist/Session.svelte.js +40 -3
  26. package/dist/Svedit.svelte +340 -147
  27. package/dist/Svedit.svelte.d.ts.map +1 -1
  28. package/dist/Transaction.svelte.d.ts +24 -5
  29. package/dist/Transaction.svelte.d.ts.map +1 -1
  30. package/dist/Transaction.svelte.js +103 -61
  31. package/dist/doc_utils.d.ts +64 -3
  32. package/dist/doc_utils.d.ts.map +1 -1
  33. package/dist/doc_utils.js +276 -10
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.js +5 -1
  36. package/dist/node_visibility.svelte.d.ts +9 -0
  37. package/dist/node_visibility.svelte.d.ts.map +1 -0
  38. package/dist/node_visibility.svelte.js +718 -0
  39. package/dist/styles/svedit-animations.css +8 -0
  40. package/dist/styles/svedit-colors.css +1 -0
  41. package/dist/types.d.ts +46 -3
  42. package/dist/utils.d.ts +52 -0
  43. package/dist/utils.d.ts.map +1 -1
  44. package/dist/utils.js +109 -3
  45. package/package.json +3 -2
  46. package/src/lib/AnnotatedTextProperty.svelte +41 -8
  47. package/src/lib/Command.svelte.js +18 -3
  48. package/src/lib/CustomProperty.svelte +20 -9
  49. package/src/lib/Node.svelte +7 -5
  50. package/src/lib/NodeArrayProperty.svelte +65 -42
  51. package/src/lib/NodeCaret.svelte +0 -7
  52. package/src/lib/NodeGap.svelte +179 -81
  53. package/src/lib/NodeGapMarkers.svelte +251 -84
  54. package/src/lib/NodeSelectionMarkers.svelte +4 -3
  55. package/src/lib/Session.svelte.js +40 -3
  56. package/src/lib/Svedit.svelte +340 -147
  57. package/src/lib/Transaction.svelte.js +103 -61
  58. package/src/lib/doc_utils.js +276 -10
  59. package/src/lib/index.js +5 -1
  60. package/src/lib/node_visibility.svelte.js +718 -0
  61. package/src/lib/styles/svedit-animations.css +8 -0
  62. package/src/lib/styles/svedit-colors.css +1 -0
  63. package/src/lib/types.d.ts +46 -3
  64. package/src/lib/utils.js +109 -3
  65. package/dist/node_gap_computation.svelte.d.ts +0 -5
  66. package/dist/node_gap_computation.svelte.d.ts.map +0 -1
  67. package/dist/node_gap_computation.svelte.js +0 -621
  68. package/src/lib/node_gap_computation.svelte.js +0 -621
@@ -0,0 +1,8 @@
1
+ @layer svedit-defaults {
2
+ @keyframes node-caret-blink {
3
+ 0%, 60% { opacity: 1; }
4
+ 68% { opacity: 0; }
5
+ 88% { opacity: 0; }
6
+ 100% { opacity: 1; }
7
+ }
8
+ }
@@ -4,5 +4,6 @@
4
4
  --svedit-editing-stroke: var(--svedit-brand);
5
5
  --svedit-editing-fill: oklch(from var(--svedit-brand) l c h / 0.1);
6
6
  --svedit-canvas-stroke: color-mix(in oklch, currentColor 25%, transparent);
7
+ --svedit-caret-color: var(--svedit-brand);
7
8
  }
8
9
  }
package/dist/types.d.ts CHANGED
@@ -11,12 +11,17 @@ import type { Component, Snippet } from 'svelte';
11
11
  // ===== SELECTION TYPE DEFINITIONS =====
12
12
 
13
13
  /**
14
- * A unique node identifier (ideally UUID)
14
+ * A unique node identifier.
15
+ *
16
+ * Node ids must be valid Svedit path string segments: they must start with
17
+ * a letter or underscore, contain only letters, numbers, underscores, or
18
+ * dashes, and must not contain `__`.
15
19
  */
16
20
  export type NodeId = string;
17
21
 
18
22
  /**
19
- * Array of IDs, property names (strings), or indexes (integers) that identify a node or property in the document
23
+ * Array of IDs, property names (strings), or indexes (integers) that identify a node or property in the document.
24
+ * String segments must follow the same path segment rules as NodeId.
20
25
  */
21
26
  export type DocumentPath = Array<string | number>;
22
27
 
@@ -269,7 +274,8 @@ export type PropertyDefinition =
269
274
  export type NodeKind = 'document' | 'block' | 'text' | 'annotation';
270
275
 
271
276
  /**
272
- * Schema for text nodes - must have a content property of type annotated_text
277
+ * Schema for text nodes - must have a content property of type annotated_text.
278
+ * Use define_document_schema to also check that content is the only annotated_text property.
273
279
  */
274
280
  export type TextNodeSchema = {
275
281
  kind: 'text';
@@ -278,6 +284,43 @@ export type TextNodeSchema = {
278
284
  } & Record<string, PropertyDefinition>;
279
285
  };
280
286
 
287
+ export type AnnotatedTextPropertyNames<Properties> = {
288
+ [PropertyName in keyof Properties]: Properties[PropertyName] extends { type: 'annotated_text' }
289
+ ? PropertyName
290
+ : never;
291
+ }[keyof Properties];
292
+
293
+ export type TextNodeSchemaError<Message extends string> = {
294
+ [SchemaError in `Svedit schema error: ${Message}`]: never;
295
+ };
296
+
297
+ export type TextNodeMissingContentError = TextNodeSchemaError<
298
+ 'Text node schemas must define a "content" property of type annotated_text.'
299
+ >;
300
+
301
+ export type TextNodeExtraAnnotatedTextError<ExtraProperty extends string> = TextNodeSchemaError<
302
+ `Text node schemas must not define annotated_text property "${ExtraProperty}". Use "content" as the only annotated_text property.`
303
+ >;
304
+
305
+ export type ValidateTextNodeSchema<Schema> = Schema extends {
306
+ kind: 'text';
307
+ properties: infer Properties;
308
+ }
309
+ ? string extends keyof Properties
310
+ ? Schema
311
+ : Properties extends { content: AnnotatedTextProperty }
312
+ ? Exclude<AnnotatedTextPropertyNames<Properties>, 'content'> extends infer ExtraProperties
313
+ ? [ExtraProperties] extends [never]
314
+ ? Schema
315
+ : TextNodeExtraAnnotatedTextError<Extract<ExtraProperties, string>>
316
+ : never
317
+ : TextNodeMissingContentError
318
+ : Schema;
319
+
320
+ export type ValidateDocumentSchema<Schema extends Record<string, NodeSchema>> = {
321
+ [NodeType in keyof Schema]: ValidateTextNodeSchema<Schema[NodeType]>;
322
+ };
323
+
281
324
  /**
282
325
  * Schema for non-text nodes
283
326
  */
package/dist/utils.d.ts CHANGED
@@ -129,6 +129,57 @@ export function join_annotated_text(first_text: AnnotatedText, second_text: Anno
129
129
  * snake_to_pascal('list_item') // Returns: 'ListItem'
130
130
  */
131
131
  export function snake_to_pascal(str: string): string;
132
+ /**
133
+ * Check whether a string can be used as a Svedit path segment.
134
+ *
135
+ * Path string segments include node ids and schema property names. Keeping
136
+ * them restricted makes serialized paths reversible, valid as HTML ids, and
137
+ * safe to use as CSS anchor-name suffixes.
138
+ *
139
+ * @param {string} segment
140
+ * @returns {boolean}
141
+ */
142
+ export function is_path_string_segment_valid(segment: string): boolean;
143
+ /**
144
+ * Assert that a string can be used as a Svedit path segment.
145
+ *
146
+ * @param {string} segment
147
+ * @param {string} [label]
148
+ * @returns {void}
149
+ */
150
+ export function assert_path_string_segment(segment: string, label?: string): void;
151
+ /**
152
+ * Serialize a document path while preserving whether each segment is a
153
+ * property/key string or an array index number.
154
+ *
155
+ * The result is reversible and safe to use as a data attribute value, HTML id,
156
+ * or CSS anchor-name suffix.
157
+ *
158
+ * @param {Array<string | number>} path
159
+ * @returns {string}
160
+ *
161
+ * @example
162
+ * serialize_path(['page_1', 'body', 0, 'items']) // Returns: 'page_1__body__0__items'
163
+ */
164
+ export function serialize_path(path: Array<string | number>): string;
165
+ /**
166
+ * Deserialize a path string produced by serialize_path.
167
+ *
168
+ * @param {string} serialized_path
169
+ * @returns {Array<string | number>}
170
+ *
171
+ * @example
172
+ * deserialize_path('page_1__body__0__items') // Returns: ['page_1', 'body', 0, 'items']
173
+ */
174
+ export function deserialize_path(serialized_path: string): Array<string | number>;
175
+ /**
176
+ * Compare two document paths segment-by-segment, preserving segment types.
177
+ *
178
+ * @param {Array<string | number>} a
179
+ * @param {Array<string | number>} b
180
+ * @returns {boolean}
181
+ */
182
+ export function paths_equal(a: Array<string | number>, b: Array<string | number>): boolean;
132
183
  export function traverse(node_id: any, schema: any, nodes: any): any[];
133
184
  /**
134
185
  * Extracts the normalized range from a text or node selection.
@@ -138,6 +189,7 @@ export function traverse(node_id: any, schema: any, nodes: any): any[];
138
189
  */
139
190
  export function get_selection_range(selection?: Selection): SelectionRange | null;
140
191
  export function is_selection_collapsed(selection: any): boolean;
192
+ export const PATH_SEPARATOR: "__";
141
193
  import type { AnnotatedText } from './types.d.ts';
142
194
  import type { Selection } from './types.d.ts';
143
195
  import type { SelectionRange } from './types.d.ts';
@@ -1 +1 @@
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"}
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;AAGD;;;;;;;;;;;;;;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;AAMD;;;;;;;;;GASG;AACH,sDAHW,MAAM,GACJ,OAAO,CAQnB;AAED;;;;;;GAMG;AACH,oDAJW,MAAM,UACN,MAAM,GACJ,IAAI,CAQhB;AAED;;;;;;;;;;;;GAYG;AACH,qCANW,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,GACpB,MAAM,CAmBlB;AAED;;;;;;;;GAQG;AACH,kDANW,MAAM,GACJ,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAuBlC;AAED;;;;;;GAMG;AACH,+BAJW,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,KACtB,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,GACpB,OAAO,CAKnB;AAED,uEAgCC;AAED;;;;;GAKG;AACH,gDAHW,SAAS,GACP,cAAc,GAAG,IAAI,CAWjC;AAED,gEAMC;AApKD,6BAA8B,IAAI,CAAC;mCAtRsC,cAAc;+BAAd,cAAc;oCAAd,cAAc"}
package/dist/utils.js CHANGED
@@ -21,7 +21,7 @@ export function is_mobile_browser() {
21
21
  );
22
22
  }
23
23
 
24
- // ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
24
+ // ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
25
25
  /**
26
26
  * Detect if the current browser is Chrome on desktop
27
27
  * @returns {boolean} true if Chrome desktop browser, false otherwise
@@ -54,8 +54,7 @@ export function get_char_length(str) {
54
54
  return [...SEGMENTER.segment(str)].length;
55
55
  }
56
56
 
57
-
58
- // ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
57
+ // ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
59
58
  /**
60
59
  * Get a single character at the specified position (accounting for multi-byte characters)
61
60
  *
@@ -278,6 +277,113 @@ export function snake_to_pascal(str) {
278
277
  .join('');
279
278
  }
280
279
 
280
+ export const PATH_SEPARATOR = '__';
281
+ const PATH_STRING_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
282
+ const PATH_INDEX_SEGMENT_RE = /^(0|[1-9]\d*)$/;
283
+
284
+ /**
285
+ * Check whether a string can be used as a Svedit path segment.
286
+ *
287
+ * Path string segments include node ids and schema property names. Keeping
288
+ * them restricted makes serialized paths reversible, valid as HTML ids, and
289
+ * safe to use as CSS anchor-name suffixes.
290
+ *
291
+ * @param {string} segment
292
+ * @returns {boolean}
293
+ */
294
+ export function is_path_string_segment_valid(segment) {
295
+ return (
296
+ typeof segment === 'string' &&
297
+ PATH_STRING_SEGMENT_RE.test(segment) &&
298
+ !segment.includes(PATH_SEPARATOR)
299
+ );
300
+ }
301
+
302
+ /**
303
+ * Assert that a string can be used as a Svedit path segment.
304
+ *
305
+ * @param {string} segment
306
+ * @param {string} [label]
307
+ * @returns {void}
308
+ */
309
+ export function assert_path_string_segment(segment, label = 'Path segment') {
310
+ if (!is_path_string_segment_valid(segment)) {
311
+ throw new Error(
312
+ `${label} must start with a letter or underscore and contain only letters, numbers, underscores, or dashes. It must not contain "${PATH_SEPARATOR}".`
313
+ );
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Serialize a document path while preserving whether each segment is a
319
+ * property/key string or an array index number.
320
+ *
321
+ * The result is reversible and safe to use as a data attribute value, HTML id,
322
+ * or CSS anchor-name suffix.
323
+ *
324
+ * @param {Array<string | number>} path
325
+ * @returns {string}
326
+ *
327
+ * @example
328
+ * serialize_path(['page_1', 'body', 0, 'items']) // Returns: 'page_1__body__0__items'
329
+ */
330
+ export function serialize_path(path) {
331
+ return path
332
+ .map((segment) => {
333
+ if (typeof segment === 'number') {
334
+ if (!Number.isInteger(segment) || segment < 0) {
335
+ throw new Error(`Path index must be a non-negative integer: ${segment}`);
336
+ }
337
+ return String(segment);
338
+ }
339
+
340
+ assert_path_string_segment(segment);
341
+ return segment;
342
+ })
343
+ .join(PATH_SEPARATOR);
344
+ }
345
+
346
+ /**
347
+ * Deserialize a path string produced by serialize_path.
348
+ *
349
+ * @param {string} serialized_path
350
+ * @returns {Array<string | number>}
351
+ *
352
+ * @example
353
+ * deserialize_path('page_1__body__0__items') // Returns: ['page_1', 'body', 0, 'items']
354
+ */
355
+ export function deserialize_path(serialized_path) {
356
+ if (serialized_path === '') return [];
357
+
358
+ return serialized_path.split(PATH_SEPARATOR).map((segment) => {
359
+ if (segment === '') {
360
+ throw new Error(`Invalid serialized path: ${serialized_path}`);
361
+ }
362
+
363
+ if (/^\d+$/.test(segment)) {
364
+ if (!PATH_INDEX_SEGMENT_RE.test(segment)) {
365
+ throw new Error(`Invalid serialized path index: ${segment}`);
366
+ }
367
+ return Number(segment);
368
+ }
369
+
370
+ assert_path_string_segment(segment, 'Serialized path segment');
371
+ return segment;
372
+ });
373
+ }
374
+
375
+ /**
376
+ * Compare two document paths segment-by-segment, preserving segment types.
377
+ *
378
+ * @param {Array<string | number>} a
379
+ * @param {Array<string | number>} b
380
+ * @returns {boolean}
381
+ */
382
+ export function paths_equal(a, b) {
383
+ if (a.length !== b.length) return false;
384
+ return a.every((segment, index) => segment === b[index]);
385
+ }
386
+
281
387
  export function traverse(node_id, schema, nodes) {
282
388
  const json = [];
283
389
  const visited = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svedit",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -45,6 +45,7 @@
45
45
  "@sveltejs/kit": "^2.55.0",
46
46
  "@sveltejs/package": "^2.5.7",
47
47
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
48
+ "@types/node": "^25.6.0",
48
49
  "@typescript-eslint/eslint-plugin": "^8.57.0",
49
50
  "@typescript-eslint/parser": "^8.57.0",
50
51
  "@vitest/browser": "^4.1.0",
@@ -59,8 +60,8 @@
59
60
  "publint": "^0.3.18",
60
61
  "svelte": "^5.53.12",
61
62
  "svelte-check": "^4.4.5",
62
- "vite": "^8.0.0",
63
63
  "typescript": "^5.9.3",
64
+ "vite": "^8.0.0",
64
65
  "vitest": "^4.1.0",
65
66
  "vitest-browser-svelte": "^2.1.0"
66
67
  }
@@ -1,6 +1,13 @@
1
1
  <script>
2
2
  import { getContext } from 'svelte';
3
- import { char_slice, get_char_length, snake_to_pascal, get_selection_range } from './utils.js';
3
+ import {
4
+ char_slice,
5
+ get_char_length,
6
+ paths_equal,
7
+ serialize_path,
8
+ snake_to_pascal,
9
+ get_selection_range
10
+ } from './utils.js';
4
11
 
5
12
  /** @import { AnnotatedTextPropertyProps, Annotation, Fragment, SelectionRange } from './types.d.ts'; */
6
13
 
@@ -9,10 +16,11 @@
9
16
  /** @type {AnnotatedTextPropertyProps} */
10
17
  let { path, class: css_class, placeholder = '', tag = 'div', style = '', ...rest } = $props();
11
18
 
19
+ let path_str = $derived(serialize_path(path));
20
+
12
21
  let is_focused = $derived.by(() => {
13
22
  return (
14
- svedit.session.selection?.type === 'text' &&
15
- path.join('.') === svedit.session.selection?.path.join('.')
23
+ svedit.session.selection?.type === 'text' && paths_equal(path, svedit.session.selection.path)
16
24
  );
17
25
  });
18
26
 
@@ -101,6 +109,7 @@
101
109
 
102
110
  return fragments;
103
111
  }
112
+
104
113
  </script>
105
114
 
106
115
  <!-- ATTENTION: The comments are needed to prevent unwanted text nodes with whitespace. -->
@@ -111,12 +120,11 @@
111
120
  <svelte:element
112
121
  this={tag}
113
122
  data-type="text"
114
- data-path={path.join('.')}
115
- style="anchor-name: --{path.join('-')};{style}"
123
+ data-path={path_str}
124
+ style="anchor-name: --{path_str};{style}"
116
125
  class="text svedit-selectable {css_class}"
117
126
  class:empty={is_empty}
118
127
  class:focused={is_focused}
119
- class:editable={svedit.editable}
120
128
  {placeholder}
121
129
  {...rest}
122
130
  >
@@ -137,11 +145,13 @@
137
145
  </svelte:element>
138
146
 
139
147
  <style>
140
- .text {
148
+ /* Editable text base layout; :where() allows easy override without specificity conflicts. */
149
+ :where(.text) {
141
150
  white-space: pre-wrap;
142
151
  overflow-wrap: anywhere;
143
152
  box-sizing: content-box;
144
153
  }
154
+
145
155
  /* We switch from ::before to ::after when the element is focused. So the the caret is always before the placeholder. */
146
156
  [placeholder].empty:not(.focused)::before,
147
157
  [placeholder].empty.focused::after {
@@ -150,13 +160,36 @@
150
160
  color: color-mix(in oklch, currentcolor 50%, transparent);
151
161
  }
152
162
 
163
+ /* A virtual caret: to fix the caret vertical alignment issue in Chrome and Firefox for empty focused contenteditable with placeholders */
164
+ /* Browser BUG: iOS Safari only considers the caret color set on the top contenteditable element, not on nested elements (e.g. the second selector doesn't work in iOS Safari) */
165
+ :global(.svedit.editable .svedit-canvas:has([placeholder].empty.focused)),
166
+ :global(.svedit.editable) [placeholder].empty.focused {
167
+ caret-color: transparent !important;
168
+ }
169
+ :global(.svedit.editable) [placeholder].empty.focused::before {
170
+ content: "";
171
+ /* we limit width & height to avoid layout shifts in case the text has a lower natural height */
172
+ width: 0px;
173
+ height: 1cap;
174
+ display: inline-block;
175
+ /* we use box-shadow to draw the caret shape, matching the native caret */
176
+ box-shadow:
177
+ 0 -0.4cap 0 0.65px var(--svedit-caret-color, AccentColor),
178
+ 0 0 0 0.65px var(--svedit-caret-color, AccentColor),
179
+ 0 0.4cap 0 0.65px var(--svedit-caret-color, AccentColor);
180
+ animation: var(
181
+ --node-caret-animation,
182
+ node-caret-blink var(--node-caret-blink-duration, 1.1s) ease-in-out infinite
183
+ );
184
+ }
185
+
153
186
  /* Hide flickering: in Chrome, the caret jumps from end of placeholder string to start of text property when we focus */
154
187
  .text:not(.focused) {
155
188
  caret-color: transparent;
156
189
  }
157
190
 
158
191
  /* Disable text-transform when editable and focused so users see original text */
159
- .text.editable.focused {
192
+ :global(.svedit.editable) .text.focused {
160
193
  text-transform: none !important;
161
194
  }
162
195
 
@@ -1,4 +1,5 @@
1
1
  import { insert_default_node, break_text_node } from './transforms.svelte.js';
2
+ import { can_switch_annotation_type } from './doc_utils.js';
2
3
  import { is_selection_collapsed, is_mobile_browser, get_char_length } from './utils.js';
3
4
 
4
5
  /**
@@ -93,7 +94,11 @@ export class RedoCommand extends Command {
93
94
  */
94
95
  export class SelectParentCommand extends Command {
95
96
  is_enabled() {
96
- return this.context.editable && this.context.session.selection;
97
+ return (
98
+ this.context.editable &&
99
+ this.context.session.selection &&
100
+ this.context.session.selection.path.length > 3
101
+ );
97
102
  }
98
103
 
99
104
  execute() {
@@ -119,14 +124,24 @@ export class ToggleAnnotationCommand extends Command {
119
124
 
120
125
  is_enabled() {
121
126
  const { session, editable } = this.context;
127
+ const active_annotation = session.active_annotation();
122
128
  const has_annotation = session.active_annotation(this.node_type);
129
+ const can_switch_annotation =
130
+ active_annotation &&
131
+ !has_annotation &&
132
+ session.available_annotation_types.includes(this.node_type) &&
133
+ can_switch_annotation_type(
134
+ session.schema,
135
+ session.get(active_annotation.node_id),
136
+ this.node_type
137
+ );
123
138
  const no_annotation_and_caret_not_collapsed =
124
- !session.active_annotation() && !is_selection_collapsed(session.selection);
139
+ !active_annotation && !is_selection_collapsed(session.selection);
125
140
 
126
141
  return (
127
142
  editable &&
128
143
  session.selection?.type === 'text' &&
129
- (has_annotation || no_annotation_and_caret_not_collapsed)
144
+ (has_annotation || can_switch_annotation || no_annotation_and_caret_not_collapsed)
130
145
  );
131
146
  }
132
147
 
@@ -1,26 +1,25 @@
1
1
  <script>
2
- import { getContext } from 'svelte';
2
+ import { serialize_path } from './utils.js';
3
3
 
4
4
  /** @import { CustomPropertyProps } from './types.d.ts'; */
5
- const svedit = getContext('svedit');
6
5
 
7
6
  /** @type {CustomPropertyProps} */
8
7
  let { path, tag = 'div', class: css_class, children, style, ...rest } = $props();
8
+ let path_str = $derived(serialize_path(path));
9
+
9
10
  </script>
10
11
 
11
12
  <svelte:element
12
13
  this={tag}
13
14
  class={css_class}
14
15
  data-type="property"
15
- data-path={path.join('.')}
16
- style="anchor-name: --{path.join('-')};{style ? ` ${style}` : ''}"
16
+ data-path={path_str}
17
+ style="anchor-name: --{path_str};{style ? ` ${style}` : ''}"
17
18
  {...rest}
18
19
  >
19
- {#if svedit.editable}
20
- <div class="property-selectable">
21
- <div class="svedit-selectable"><br /></div>
22
- </div>
23
- {/if}
20
+ <div class="property-selectable">
21
+ <div class="svedit-selectable"><br /></div>
22
+ </div>
24
23
 
25
24
  {@render children()}
26
25
  </svelte:element>
@@ -37,5 +36,17 @@
37
36
  bottom: 0;
38
37
  z-index: 1;
39
38
  outline: none;
39
+ /* Position the hidden selectable element at the bottom so the
40
+ browser's native scroll-to-caret ensures the full property
41
+ is visible, not just the top edge. */
42
+ display: none;
43
+ align-items: flex-end;
44
+ justify-content: center;
45
+ }
46
+ :global(.svedit.editable) .property-selectable {
47
+ display: flex;
48
+ }
49
+ .svedit-selectable {
50
+ caret-color: transparent;
40
51
  }
41
52
  </style>
@@ -1,5 +1,6 @@
1
1
  <script>
2
2
  import { getContext } from 'svelte';
3
+ import { serialize_path } from './utils.js';
3
4
 
4
5
  /** @import { NodeProps } from './types.d.ts'; */
5
6
 
@@ -9,6 +10,7 @@
9
10
  let { path, children, tag = 'div', class: css_class, style = '', ...rest } = $props();
10
11
 
11
12
  let node = $derived(svedit.session.get(path));
13
+ let path_str = $derived(serialize_path(path));
12
14
 
13
15
  const node_array_meta = getContext('node_array_meta');
14
16
  let child_index = $derived(node_array_meta ? parseInt(String(path.at(-1)), 10) : -1);
@@ -17,13 +19,13 @@
17
19
  </script>
18
20
 
19
21
  <svelte:element
20
- id={node.id}
21
22
  this={tag}
22
- class="{css_class}{is_first ? ' first' : ''}{is_last ? ' last' : ''}"
23
+ id={node.id}
24
+ class="node-{node.type} {css_class}{is_first ? ' first' : ''}{is_last ? ' last' : ''}"
23
25
  data-node-id={node.id}
24
- data-path={path.join('.')}
26
+ data-path={path_str}
25
27
  data-type="node"
26
- style="anchor-name: --{path.join('-')};{style}"
28
+ style="anchor-name: --{path_str};{style}"
27
29
  {...rest}
28
30
  >
29
31
  {@render children()}
@@ -35,4 +37,4 @@
35
37
  /* For developers who need to position their node with `position: absolute` or `position: relative`, they need to wrap their node in a div */
36
38
  position: static !important;
37
39
  }
38
- </style>
40
+ </style>