svedit 0.9.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 +113 -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 +336 -165
  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 +336 -165
  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
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Svedit
2
2
 
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.
3
+ Svedit (think Svelte Edit) is a tiny library for building editable websites in Svelte. Model content in JSON, render it with custom components, and let site owners **edit directly in the layout** — no CMS needed.
4
4
 
5
5
  - Try the [Svedit demo](https://svedit.dev) — watch the debug output at the bottom of the page
6
6
  - Try [Editable Website](https://editable.website) — utilizes Svedit to enable CMS-free editable websites
@@ -32,6 +32,8 @@ You can also install Svedit into an existing SvelteKit project with `npm install
32
32
 
33
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.
34
34
 
35
+ **Native-first:** Svedit favors standardized native browser solutions over custom ones whenever possible — using browser-based rendering and the browser's selection APIs underneath, rather than reimplementing them (e.g. by building on [pretext](https://github.com/chenglou/pretext)). When we hit browser bugs, we try to get them fixed upstream (in collaboration with the W3C editing working group). Workarounds we add are intended to be temporary, until the underlying issue is fixed by browser vendors. And where we believe there's a better way to solve a problem than what's currently specified, we lobby for it to become a new (or improved) web standard.
36
+
35
37
  ## How it works
36
38
 
37
39
  Svedit connects eight key pieces:
@@ -48,7 +50,7 @@ Svedit connects eight key pieces:
48
50
  **The flow:**
49
51
  - Define a schema → create a session → provide config → render with `<Svedit>` component
50
52
  - User interactions trigger commands → commands create transactions → which run transforms to modify the document → session applies the transaction → Svelte's reactivity updates the UI
51
- - Selection state syncs bidirectionally with the DOM
53
+ - Native DOM selections are mapped to Svedit's internal selection model
52
54
 
53
55
  ## Schema
54
56
 
@@ -255,6 +257,38 @@ const doc = {
255
257
  };
256
258
  ```
257
259
 
260
+ ### Document schema changes
261
+
262
+ Svedit validates documents against the current schema when you create a `Session`. If you change the schema, you are responsible for migrating existing documents before loading them.
263
+
264
+ For simple additive changes, defaults can help. When you add a new property, you can call `fill_document_defaults` before creating the session:
265
+
266
+ ```js
267
+ import { Session, fill_document_defaults } from 'svedit';
268
+
269
+ const document_schema = {
270
+ paragraph: {
271
+ kind: 'text',
272
+ properties: {
273
+ layout: { type: 'integer', default: 1 },
274
+ content: {
275
+ type: 'annotated_text',
276
+ allow_newlines: true
277
+ }
278
+ }
279
+ }
280
+ };
281
+
282
+ const migrated_doc = fill_document_defaults(existing_doc, document_schema);
283
+ const session = new Session(document_schema, migrated_doc, config);
284
+ ```
285
+
286
+ This only fills properties that are missing. Explicit schema `default` values are used when present; otherwise Svedit uses built-in defaults for value types such as strings, numbers, booleans, arrays, `node_array`, and `annotated_text`. Existing values are preserved, and the original document object is not mutated.
287
+
288
+ Defaults make it safe to add new defaultable properties, but they are not a replacement for real document migrations. If you rename a property, remove a property, split one property into several, change node types, or need to transform existing data, write your own migration first and use `fill_document_defaults` only as a helper where appropriate.
289
+
290
+ `tr.create` and `tr.build` also fill omitted defaults for newly-created nodes. For document migrations, declare explicit defaults whenever the built-in type default is not the value you want. Do not rely on default filling for schema changes that need a real migration.
291
+
258
292
  ## Config
259
293
 
260
294
  Documents need a config object that tells Svedit how to render and manipulate your content. See the full example in [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js).
@@ -307,6 +341,7 @@ const session_config = {
307
341
  - **`inserters`** - Functions that create blank nodes of each type and set up the selection
308
342
  - **`create_commands_and_keymap`** - Factory function that creates commands and keybindings for an editor instance
309
343
  - **`handle_image_paste`** - Optional handler for image paste events
344
+ - **`view_classes`** - Viewport visibility classes. Every node element gets `.in-view`, `.seen`, `.fully-in-view`, `.visible-top`, `.visible-bottom` toggled as it scrolls through the viewport. Useful for scrollytelling and reveal animations driven from CSS. **On by default**. Set `view_classes: false` to opt out — saves ~5 classList ops per IO entry (measurable scroll-FPS win at 200+ nodes) and narrows the IntersectionObserver thresholds from `[0, 0.98]` to `[0]` (halves callback frequency during scroll). Disable if your app doesn't read these classes from CSS.
310
345
 
311
346
  The config is accessible throughout your app via `session.config`.
312
347
 
@@ -346,6 +381,18 @@ session.can_insert('paragraph') // Check if node type can be inserted
346
381
  session.available_annotation_types // Annotation types allowed at current selection (derived)
347
382
  ```
348
383
 
384
+ #### Native selection as source of truth
385
+
386
+ Every selection that can exist in Svedit must have a DOM representation. This is a core design decision: the browser's native selection determines where the selection is, and `session.selection` is derived from it — not the other way around. If the DOM selection stops being the source of truth, selection mapping becomes ambiguous and Svedit loses the simplicity and determinism that the editor is built on.
387
+
388
+ This applies to all selection types:
389
+
390
+ - Text selections are native selections inside an `annotated_text` property.
391
+ - Node selections are native selections whose anchor and focus land inside the selected node range.
392
+ - Property selections are native selections inside the DOM representation of that property.
393
+
394
+ For example, a multi-node selection is still represented as a native browser selection that starts somewhere inside one node and ends somewhere inside another. Svedit maps that DOM selection to an internal node selection, hides the browser's visual selection, and renders `NodeSelectionMarkers` instead. The native selection remains in the DOM behind the scenes because `contenteditable` still needs it to do its job: pressing an arrow key should move the cursor from the current focus point to the closest valid next position, and Shift+Arrow should expand the selection from that same focus point. Without a native selection rendered at all times, the browser has no reliable starting point for cursor movement, selection expansion, or other editing behavior.
395
+
349
396
  ### Making changes
350
397
 
351
398
  ```js
@@ -948,6 +995,16 @@ Svedit provides specialized components for rendering different property types:
948
995
  />
949
996
  ```
950
997
 
998
+ > **One path = one DOM mount.** Within a single Svedit document, each node path
999
+ > may be mounted exactly once. Mounting the same path twice (e.g. rendering the
1000
+ > same `nav_items` array in both header and footer) breaks anchor positioning,
1001
+ > intersection tracking, id uniqueness, and selection mapping.
1002
+ > Svedit logs an error if it detects a duplicate mount.
1003
+ >
1004
+ > If you need to render shared content in multiple places, model it as distinct
1005
+ > node_arrays (`header_nav`, `footer_nav`) in the same document, or render it
1006
+ > from a separate Svedit instance.
1007
+
951
1008
  **`<CustomProperty>`** - For custom properties like images or other non-text content:
952
1009
 
953
1010
  ```svelte
@@ -1077,8 +1134,59 @@ Further things to consider:
1077
1134
  - 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.
1078
1135
  - 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.
1079
1136
  - 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` ).
1080
- - 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).
1081
- - 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.
1137
+ - Avoid adding css `margin` to nodes inside node arrays when using flex or grid layouts. Use `gap` on the container instead. This ensures `NodeGap` and `NodeGapMarkers` render consistently. If you need to add a margin, add it to a child element of Node.
1138
+
1139
+ ### Consistent DOM structure across modes
1140
+
1141
+ `<NodeArrayProperty>` always renders a `.node-gap` element before each node, regardless of whether the editor is in edit or read-only mode. In edit mode, this is the full `<NodeGap>` component with anchor positioning, hit areas, and caret support. In read-only mode, it's a plain `<div class="node-gap"></div>`.
1142
+
1143
+ This means the DOM structure is identical in both modes:
1144
+
1145
+ ```html
1146
+ <!-- Same structure in edit and read-only mode -->
1147
+ <div data-type="node_array">
1148
+ <div class="node-gap">...</div><!-- First element is a node-gap -->
1149
+ <div data-type="node"><!-- first node --></div>
1150
+ <div class="node-gap">...</div>
1151
+ <div data-type="node"><!-- second node --></div>
1152
+ ...
1153
+ <div class="node-gap">...</div><!-- Last element is a node-gap -->
1154
+ </div>
1155
+ ```
1156
+
1157
+ The `.node-gap` element uses `display: contents`, so it has no layout impact — it doesn't generate a box, doesn't affect flex/grid item counts, and doesn't consume space. It only serves as a DOM placeholder for consistent selector targeting.
1158
+
1159
+ Because `.node-gap` is always the actual first and last child, do not use `:first-child` or `:last-child` on nodes — they will never match. Use `:nth-child(1 of [data-type="node"])` and `:nth-last-child(1 of [data-type="node"])` instead.
1160
+
1161
+ CSS sibling selectors that target node adjacency only need one form:
1162
+
1163
+ ```css
1164
+ /* Works in both edit and read-only mode */
1165
+ .node-text + .node-gap + .node-media {
1166
+ margin-block-start: 1rem;
1167
+ }
1168
+ ```
1169
+
1170
+ For `:nth-child` selectors, use the `of <selector>` syntax to skip over `.node-gap` elements and count only nodes:
1171
+
1172
+ ```css
1173
+ /* Even nodes */
1174
+ .list-node-array > :nth-child(2n of [data-type="node"]) {
1175
+ background: var(--zebra-stripe);
1176
+ }
1177
+
1178
+ /* Odd nodes */
1179
+ .list-node-array > :nth-child(2n-1 of [data-type="node"]) {
1180
+ background: var(--zebra-stripe);
1181
+ }
1182
+
1183
+ /* Last node */
1184
+ .list-node-array > :nth-last-child(1 of [data-type="node"]) {
1185
+ border-bottom: none;
1186
+ }
1187
+ ```
1188
+
1189
+ Without the `of` filter, plain `:nth-child(even)` would count `.node-gap` divs and produce incorrect results. The [`of <selector>`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:nth-child#syntax) form is Baseline 2023 and supported in all modern browsers.
1082
1190
 
1083
1191
  ### Node array CSS tokens
1084
1192
 
@@ -1156,6 +1264,7 @@ For per-axis control, use `--node-caret-boundary-x` (left/right) and `--node-car
1156
1264
  }
1157
1265
  ```
1158
1266
 
1267
+
1159
1268
  ## Beyond the README
1160
1269
 
1161
1270
  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.
@@ -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 +1 @@
1
- {"version":3,"file":"AnnotatedTextProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AnnotatedTextProperty.svelte.js"],"names":[],"mappings":";;;;;AAkIA,2HAAoE"}
1
+ {"version":3,"file":"AnnotatedTextProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AnnotatedTextProperty.svelte.js"],"names":[],"mappings":";;;;;AA2IA,2HAAoE"}
@@ -68,7 +68,6 @@ export class RedoCommand extends Command {
68
68
  * Useful for navigating up the document hierarchy.
69
69
  */
70
70
  export class SelectParentCommand extends Command {
71
- is_enabled(): any;
72
71
  execute(): void;
73
72
  }
74
73
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"Command.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Command.svelte.js"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH;IAOC;;;;OAIG;IACH,qBAFW,GAAG,EAIb;IAbD;;;OAGG;IACH,kBAAwC;IAQvC,aAAsB;IAGvB;;;;;OAKG;IACH,cAFa,OAAO,CAInB;IAED;;;;;;OAMG;IACH,WAFa,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhC;CACD;AAED;;GAEG;AACH;IACC,kBAEC;IAED,gBAEC;CACD;AAED;;GAEG;AACH;IACC,kBAEC;IAED,gBAEC;CACD;AAED;;;GAGG;AACH;IACC,kBAEC;IAED,gBAEC;CACD;AAED;;;GAGG;AACH;IACC,0CAGC;IADA,eAA0B;IAG3B,YAAoC;IAEpC,iBAEC;IAED,kBAWC;IAED,gBAEC;CACD;AAED;;;;GAIG;AACH;IACC,kBAUC;IAED,gBAEC;CACD;AAED;;;;GAIG;AACH;IAKC,gBAKC;CACD;AAED;;;GAGG;AACH;IACC,kBAEC;IAED,gBAwGC;CACD;AAED;;;GAGG;AACH;IAUC,gBAIC;CACD"}
1
+ {"version":3,"file":"Command.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Command.svelte.js"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH;IAOC;;;;OAIG;IACH,qBAFW,GAAG,EAIb;IAbD;;;OAGG;IACH,kBAAwC;IAQvC,aAAsB;IAGvB;;;;;OAKG;IACH,cAFa,OAAO,CAInB;IAED;;;;;;OAMG;IACH,WAFa,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhC;CACD;AAED;;GAEG;AACH;IACC,kBAEC;IAED,gBAEC;CACD;AAED;;GAEG;AACH;IACC,kBAEC;IAED,gBAEC;CACD;AAED;;;GAGG;AACH;IASC,gBAEC;CACD;AAED;;;GAGG;AACH;IACC,0CAGC;IADA,eAA0B;IAG3B,YAAoC;IAEpC,iBAEC;IAED,kBAqBC;IAED,gBAEC;CACD;AAED;;;;GAIG;AACH;IACC,kBAUC;IAED,gBAEC;CACD;AAED;;;;GAIG;AACH;IAKC,gBAKC;CACD;AAED;;;GAGG;AACH;IACC,kBAEC;IAED,gBAwGC;CACD;AAED;;;GAGG;AACH;IAUC,gBAIC;CACD"}
@@ -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>
@@ -3,5 +3,6 @@ type CustomProperty = {
3
3
  $on?(type: string, callback: (e: any) => void): () => void;
4
4
  $set?(props: Partial<CustomPropertyProps>): void;
5
5
  };
6
- declare const CustomProperty: import("svelte").Component<import("./types.d.ts").CustomPropertyProps, {}, "">;
6
+ declare const CustomProperty: import("svelte").Component<CustomPropertyProps, {}, "">;
7
+ import type { CustomPropertyProps } from './types.d.ts';
7
8
  //# sourceMappingURL=CustomProperty.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"CustomProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/CustomProperty.svelte.js"],"names":[],"mappings":";;;;;AA6BA,6GAA6D"}
1
+ {"version":3,"file":"CustomProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/CustomProperty.svelte.js"],"names":[],"mappings":";;;;;AA4BA,sFAA6D;yCApBnB,cAAc"}
package/dist/Node.svelte CHANGED
@@ -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>
@@ -1 +1 @@
1
- {"version":3,"file":"Node.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Node.svelte.js"],"names":[],"mappings":";;;;;AA8BA,yFAAmD"}
1
+ {"version":3,"file":"Node.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Node.svelte.js"],"names":[],"mappings":";;;;;AAkCA,yFAAmD"}
@@ -1,7 +1,7 @@
1
1
  <script>
2
2
  import { getContext, setContext } from 'svelte';
3
3
  import UnknownNode from './UnknownNode.svelte';
4
- import { snake_to_pascal } from './utils.js';
4
+ import { serialize_path, snake_to_pascal } from './utils.js';
5
5
  import DefaultNodeGap from './NodeGap.svelte';
6
6
  import DefaultNodeGapMarkers from './NodeGapMarkers.svelte';
7
7
 
@@ -14,53 +14,61 @@
14
14
  /** @type {NodeArrayPropertyProps} */
15
15
  let { path, tag = 'div', class: css_class, style = '', ...rest } = $props();
16
16
 
17
- let nodes = $derived(
18
- svedit.session
19
- .get(path)
20
- .map(/** @param {string} node_id */ (node_id) => svedit.session.get(node_id))
17
+ // Pre-joined once per path change; reused by data-path and by every
18
+ // NodeGap's should_position_gap call to avoid N+1 joins per render.
19
+ let path_str = $derived(serialize_path(path));
20
+
21
+ let node_ids = $derived(svedit.session.get(path));
22
+ let nodes = $derived(node_ids.map((id) => svedit.session.get(id)));
23
+
24
+ // Mirrors the AnnotatedTextProperty pattern: a `focused` class follows
25
+ // the model selection. Used by the CSS rule below to hide the empty-array
26
+ // NodeGap while the caret is in the placeholder, so its <br> isn't a
27
+ // second arrow-key stop (issue #260).
28
+ let is_focused = $derived(
29
+ svedit.session.selection?.type === 'node'
30
+ && serialize_path(svedit.session.selection.path) === path_str
21
31
  );
22
32
 
23
33
  setContext('node_array_meta', {
24
- get length() { return nodes.length; }
34
+ get length() {
35
+ return node_ids.length;
36
+ }
25
37
  });
38
+
26
39
  </script>
40
+
27
41
  <!-- 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 -->
28
- <svelte:element
29
- this={tag}
30
- class={css_class}
31
- data-type="node_array"
32
- data-path={path.join('.')}
33
- style="anchor-name: --{path.join('-')};{style ? ` ${style}` : ''}" {...rest}
42
+ <svelte:element
43
+ this={tag}
44
+ class={css_class}
45
+ class:focused={is_focused}
46
+ data-type="node_array"
47
+ data-path={path_str}
48
+ style="anchor-name: --{path_str};{style ? ` ${style}` : ''}"
49
+ {...rest}
34
50
  >
35
- {#if nodes.length === 0 && svedit.editable}
36
- <!--
37
- Experimental: We'll let .empty-node-array act like a node, so the existing
38
- code paths for selection mapping will work as expected.
39
-
40
- TODO: Need to figure out a way to make .empty-node-array customizable.
41
- -->
51
+ {#if node_ids.length === 0 && svedit.editable}
42
52
  <div
43
- class="node empty-node-array"
44
- data-path={[...path, 0].join('.')}
53
+ class="empty-node-placeholder"
54
+ data-path={serialize_path([...path, 0])}
45
55
  data-type="node"
46
- style="anchor-name: --{[...path, 0].join(
47
- '-'
48
- )}; min-height: 40px; min-width: 24px;"
49
- ></div>
50
- <!-- Sibling (not child) of .empty-node-array so its .svedit-selectable
51
- resolves anchor positioning against the shared containing block,
52
- not the placeholder which inherits .node positioning styles. -->
53
- <NodeGap array_path={path} offset={0} count={0} empty />
56
+ style="anchor-name: --{serialize_path([...path, 0])};"
57
+ >
58
+ <NodeGap
59
+ array_path={path}
60
+ offset={0}
61
+ count={0}
62
+ empty
63
+ />
64
+ </div>
54
65
  {/if}
55
66
  {#each nodes as node, index (index)}
56
- {#if svedit.editable}
57
- <NodeGap
58
- array_path={path}
59
- offset={index}
60
- count={nodes.length}
61
- positioned={svedit.is_near_viewport?.([...path, index]) ?? true}
62
- />
63
- {/if}
67
+ <NodeGap
68
+ array_path={path}
69
+ offset={index}
70
+ count={nodes.length}
71
+ />
64
72
  {@const Component = svedit.session.config.node_components[snake_to_pascal(node.type)]}
65
73
  {#if Component}
66
74
  <Component path={[...path, index]} />
@@ -68,15 +76,30 @@
68
76
  <UnknownNode path={[...path, index]} />
69
77
  {/if}
70
78
  {/each}
71
- {#if svedit.editable && nodes.length > 0}
79
+ {#if node_ids.length > 0}
72
80
  <NodeGap
73
81
  array_path={path}
74
- offset={nodes.length}
75
- count={nodes.length}
76
- positioned={svedit.is_near_viewport?.([...path, nodes.length - 1]) ?? true}
82
+ offset={node_ids.length}
83
+ count={node_ids.length}
77
84
  />
78
85
  {/if}
79
86
  {#if svedit.editable && NodeGapMarkers}
80
- <NodeGapMarkers {path} />
87
+ <NodeGapMarkers {path} count={node_ids.length} />
81
88
  {/if}
82
89
  </svelte:element>
90
+
91
+ <style>
92
+ /* position: relative makes this the containing block for the gap's
93
+ .svedit-selectable, which fills it via inset:0. You may override to
94
+ position: absolute (e.g. so an empty array doesn't occupy flow space)
95
+ — still a containing block; then also set position: relative on the
96
+ parent node-array container so this placeholder's inset:0 resolves
97
+ against it. */
98
+ :where(.empty-node-placeholder) {
99
+ position: relative;
100
+ inset: 0;
101
+ min-height: 40px;
102
+ min-width: 24px;
103
+ cursor: pointer;
104
+ }
105
+ </style>
@@ -1 +1 @@
1
- {"version":3,"file":"NodeArrayProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/NodeArrayProperty.svelte.js"],"names":[],"mappings":";;;;;AAkEA,mHAAgE"}
1
+ {"version":3,"file":"NodeArrayProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/NodeArrayProperty.svelte.js"],"names":[],"mappings":";;;;;AA4EA,mHAAgE"}
@@ -11,13 +11,6 @@
11
11
  <div class="caret" role="none"></div>
12
12
 
13
13
  <style>
14
- @keyframes node-caret-blink {
15
- 0%, 60% { opacity: 1; }
16
- 68% { opacity: 0; }
17
- 88% { opacity: 0; }
18
- 100% { opacity: 1; }
19
- }
20
-
21
14
  .caret {
22
15
  --_R: var(--row, 1);
23
16
  --_C: calc(1 - var(--row, 1));