svedit 0.7.1 → 0.9.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.
@@ -66,6 +66,12 @@
66
66
  * --node-caret-blink-duration
67
67
  * --node-caret-animation
68
68
  * --node-caret-row-inline-position
69
+ * --node-caret-boundary (anchor-name of a parent element; edge
70
+ * gaps clamp to its edges instead of 0px.
71
+ * Prevents overlap when the node array has
72
+ * neighbors. See NodeGap.svelte for details.)
73
+ * --node-caret-boundary-x (per-axis override; clamps left/right only)
74
+ * --node-caret-boundary-y (per-axis override; clamps top/bottom only)
69
75
  *
70
76
  * Row/column detection uses var(--row, 1) with the * 99999 multiplier
71
77
  * trick throughout. Shorthand:
@@ -262,9 +268,11 @@
262
268
 
263
269
  /* Edge first: column = above first node, row = left of first node */
264
270
  .gap-edge.first {
271
+ --_b-t: anchor(var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) top, 0px);
272
+ --_b-l: anchor(var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) left, 0px);
265
273
  top: min(
266
274
  calc(anchor(var(--_a) top) + var(--_C) * 99999px),
267
- calc(max(0px, calc(anchor(var(--_a) top) - var(--_gm))) + var(--_R) * 99999px)
275
+ calc(max(var(--_b-t), calc(anchor(var(--_a) top) - var(--_gm))) + var(--_R) * 99999px)
268
276
  );
269
277
  bottom: min(
270
278
  calc(anchor(var(--_a) bottom) + var(--_C) * 99999px),
@@ -272,7 +280,7 @@
272
280
  );
273
281
  left: min(
274
282
  calc(anchor(var(--_a) left) + var(--_R) * 99999px),
275
- calc(max(0px, calc(anchor(var(--_a) left) - var(--_gm))) + var(--_C) * 99999px)
283
+ calc(max(var(--_b-l), calc(anchor(var(--_a) left) - var(--_gm))) + var(--_C) * 99999px)
276
284
  );
277
285
  right: min(
278
286
  calc(anchor(var(--_a) right) + var(--_R) * 99999px),
@@ -280,23 +288,40 @@
280
288
  );
281
289
  }
282
290
 
283
- /* Edge last: column = below last node, row = right of last node */
291
+ /* Edge last: column = below last node, row = right of last node.
292
+ top/left also clamp to boundary - gm so that min-height/min-width
293
+ (which win over bottom/right in overconstrained abs-pos) cannot
294
+ push the element past the boundary. */
284
295
  .gap-edge.last {
296
+ --_b-b: anchor(var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) bottom, 0px);
297
+ --_b-r: anchor(var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) right, 0px);
298
+ --_b-bt: anchor(var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) bottom, 99999px);
299
+ --_b-rl: anchor(var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) right, 99999px);
285
300
  top: min(
286
301
  calc(anchor(var(--_a) top) + var(--_C) * 99999px),
287
- calc(anchor(var(--_a) bottom) + var(--_R) * 99999px)
302
+ calc(
303
+ min(
304
+ anchor(var(--_a) bottom),
305
+ calc(var(--_b-bt) - var(--_gm))
306
+ ) + var(--_R) * 99999px
307
+ )
288
308
  );
289
309
  bottom: min(
290
310
  calc(anchor(var(--_a) bottom) + var(--_C) * 99999px),
291
- calc(anchor(var(--_a) bottom) - var(--_gm) + var(--_R) * 99999px)
311
+ calc(max(var(--_b-b), calc(anchor(var(--_a) bottom) - var(--_gm))) + var(--_R) * 99999px)
292
312
  );
293
313
  left: min(
294
314
  calc(anchor(var(--_a) left) + var(--_R) * 99999px),
295
- calc(anchor(var(--_a) right) + var(--_C) * 99999px),
315
+ calc(
316
+ min(
317
+ anchor(var(--_a) right),
318
+ calc(var(--_b-rl) - var(--_gm))
319
+ ) + var(--_C) * 99999px
320
+ ),
296
321
  calc(100% - var(--_gm) + var(--_C) * 99999px)
297
322
  );
298
323
  right: max(
299
- calc(0px + var(--_C) * -99999px),
324
+ calc(var(--_b-r) + var(--_C) * -99999px),
300
325
  calc(anchor(var(--_a) right) + var(--_R) * -99999px),
301
326
  calc(anchor(var(--_a) right) - var(--_gm) + var(--_C) * -99999px),
302
327
  calc(anchor(var(--_c) right) + var(--_C) * -99999px)
@@ -314,6 +339,7 @@
314
339
  left: min(
315
340
  calc(anchor(var(--_a) left) + var(--_R) * 99999px),
316
341
  calc(anchor(var(--_a) right) + var(--_C) * 99999px),
342
+ calc(var(--_b-rl) - var(--_gm) + var(--_C) * 99999px),
317
343
  calc(
318
344
  anchor(var(--_a) right)
319
345
  + (max(0px, anchor(var(--_s) left) - anchor(var(--_f) right))) / 2
@@ -341,7 +367,7 @@
341
367
  );
342
368
  right: max(
343
369
  calc(anchor(var(--_a) right) + var(--_R) * -99999px),
344
- calc(0px + var(--_C) * -99999px),
370
+ calc(var(--_b-r) + var(--_C) * -99999px),
345
371
  calc(
346
372
  anchor(var(--_a) right)
347
373
  - (
@@ -450,7 +476,7 @@
450
476
  outline: 0.1px solid green;
451
477
  }
452
478
  .gap-marker {
453
- outline: 0.1px solid blue;
479
+ outline: 1px solid blue;
454
480
  outline-offset: 0.5px;
455
481
  } */
456
- </style>
482
+ </style>
@@ -145,14 +145,14 @@
145
145
  if (
146
146
  ['deleteContentBackward', 'deleteWordBackward', 'deleteContent'].includes(event.inputType)
147
147
  ) {
148
- session.apply(session.tr.delete_selection('backward'));
148
+ delete_at_selection('backward');
149
149
  event.preventDefault();
150
150
  event.stopPropagation();
151
151
  return;
152
152
  }
153
153
 
154
154
  if (['deleteContentForward', 'deleteWordForward'].includes(event.inputType)) {
155
- session.apply(session.tr.delete_selection('forward'));
155
+ delete_at_selection('forward');
156
156
  event.preventDefault();
157
157
  event.stopPropagation();
158
158
  return;
@@ -269,6 +269,10 @@
269
269
  if (!canvas_el?.contains(range.commonAncestorContainer)) return;
270
270
  let selection = __get_selection_from_dom();
271
271
  if (selection) {
272
+ // Avoid assigning a new object reference when the selection is
273
+ // structurally identical — prevents a redundant $effect cycle
274
+ // (render_selection → scrollIntoView) on every DOM layout change.
275
+ if (JSON.stringify(selection) === JSON.stringify(session.selection)) return;
272
276
  session.selection = selection;
273
277
  }
274
278
  }
@@ -490,7 +494,24 @@ ${fallback_html}`;
490
494
  }
491
495
 
492
496
  if (delete_selection) {
493
- session.apply(session.tr.delete_selection());
497
+ delete_at_selection();
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Shared delete logic for cut, backspace, and forward-delete.
503
+ * For property selections, delegates to the config's handle_property_deletion hook.
504
+ * For text/node selections, uses the standard transaction delete_selection.
505
+ *
506
+ * @param {'backward' | 'forward'} [direction]
507
+ */
508
+ function delete_at_selection(direction = 'backward') {
509
+ if (session.selection?.type === 'property') {
510
+ if (session.config.handle_property_deletion) {
511
+ session.config.handle_property_deletion(session, session.selection.path);
512
+ }
513
+ } else {
514
+ session.apply(session.tr.delete_selection(direction));
494
515
  }
495
516
  }
496
517
 
@@ -764,6 +785,23 @@ ${fallback_html}`;
764
785
  canvas_el?.focus();
765
786
  }
766
787
 
788
+ /**
789
+ * When a DOM selection endpoint lands in a sibling NodeGap (not inside
790
+ * a node), resolves the adjacent node element for the walk-up algorithm.
791
+ * @param {HTMLElement} el
792
+ * @returns {HTMLElement | null}
793
+ */
794
+ function __resolve_node_from_gap(el) {
795
+ const gap = /** @type {HTMLElement | null} */ (el.closest('[data-gap-array-path]'));
796
+ if (!gap) return null;
797
+ const array_path = gap.dataset.gapArrayPath;
798
+ const offset = parseInt(gap.dataset.gapOffset, 10);
799
+ const node_idx = offset > 0 ? offset - 1 : 0;
800
+ return /** @type {HTMLElement | null} */ (
801
+ canvas_el.querySelector(`[data-path="${array_path}.${node_idx}"][data-type="node"]`)
802
+ );
803
+ }
804
+
767
805
  /**
768
806
  * Extracts a NodeSelection from the current DOM selection.
769
807
  *
@@ -783,56 +821,30 @@ ${fallback_html}`;
783
821
  if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
784
822
  if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
785
823
 
786
- // EDGE CASE: Let's first check if we are in a node gap for node carets
787
- let gap_after_el =
788
- focus_node && focus_node?.closest('[data-type="gap-after"]');
789
- if (gap_after_el && focus_node === anchor_node) {
790
- // Find the node that this gap belongs to
791
- let node = /** @type {HTMLElement} */ (gap_after_el.closest('[data-type="node"]'));
792
- if (!node) {
793
- console.log('No corresponding node found for gap-after');
794
- return null;
795
- }
796
- const node_path = /** @type {DocumentPath} */ (node.dataset.path.split('.'));
797
- const node_index = parseInt(String(node_path.at(-1)), 10);
798
-
799
- return {
800
- type: 'node',
801
- path: node_path.slice(0, -1),
802
- anchor_offset: node_index + 1,
803
- focus_offset: node_index + 1
804
- };
805
- }
806
-
807
- // EDGE CASE: Let's check if we are in a gap-before for node_arrays.
808
- let gap_before_el = focus_node.closest('[data-type="gap-before"]');
809
- if (gap_before_el && focus_node === anchor_node) {
810
- const node_array_el = /** @type {HTMLElement} */ (
811
- gap_before_el.closest('[data-type="node_array"]')
812
- );
813
- const node_array_path = node_array_el.dataset.path.split('.');
824
+ // EDGE CASE: Collapsed selection inside a node gap (gap-after or gap-before).
825
+ // Gaps are siblings of nodes with data-gap-array-path and data-gap-offset.
826
+ const gap_el = /** @type {HTMLElement | null} */ (
827
+ focus_node.closest('[data-gap-array-path]')
828
+ );
829
+ if (gap_el && focus_node === anchor_node) {
830
+ const array_path = gap_el.dataset.gapArrayPath.split('.');
831
+ const gap_offset = parseInt(gap_el.dataset.gapOffset, 10);
814
832
  return {
815
833
  type: 'node',
816
- path: node_array_path,
817
- anchor_offset: 0,
818
- focus_offset: 0
834
+ path: array_path,
835
+ anchor_offset: gap_offset,
836
+ focus_offset: gap_offset
819
837
  };
820
838
  }
821
839
 
822
- let focus_root = /** @type {HTMLElement} */ (
823
- focus_node.closest('[data-path][data-type="node"]')
824
- );
840
+ let focus_root = __resolve_node_from_gap(focus_node)
841
+ ?? /** @type {HTMLElement} */ (focus_node.closest('[data-path][data-type="node"]'));
825
842
  if (!focus_root) return null;
826
843
 
827
- let anchor_root = /** @type {HTMLElement} */ (
828
- anchor_node.closest('[data-path][data-type="node"]')
829
- );
844
+ let anchor_root = __resolve_node_from_gap(anchor_node)
845
+ ?? /** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
830
846
  if (!anchor_root) return null;
831
847
 
832
- if (!(focus_root && anchor_root)) {
833
- return null;
834
- }
835
-
836
848
  let focus_root_path = focus_root.dataset.path.split('.');
837
849
  let anchor_root_path = anchor_root.dataset.path.split('.');
838
850
  let focus_node_depth = focus_root_path.length;
@@ -872,6 +884,15 @@ ${fallback_html}`;
872
884
  }
873
885
  }
874
886
 
887
+ // If both paths walked up to the root node, there's no common parent node_array
888
+ // (e.g. selecting between nav and page body). The resulting path would be empty.
889
+ if (anchor_root_path.length <= 1) return null;
890
+
891
+ const parent_array_path = anchor_root_path.slice(0, -1);
892
+ // A node selection is only valid inside a node_array property.
893
+ const parent_property = session.inspect(parent_array_path);
894
+ if (!parent_property || parent_property.type !== 'node_array') return null;
895
+
875
896
  let anchor_offset = parseInt(anchor_root_path.at(-1));
876
897
  let focus_offset = parseInt(focus_root_path.at(-1));
877
898
 
@@ -911,7 +932,7 @@ ${fallback_html}`;
911
932
 
912
933
  return {
913
934
  type: 'node',
914
- path: anchor_root_path.slice(0, -1),
935
+ path: parent_array_path,
915
936
  anchor_offset: anchor_offset,
916
937
  focus_offset: focus_offset
917
938
  };
@@ -1094,106 +1115,68 @@ ${fallback_html}`;
1094
1115
  }
1095
1116
 
1096
1117
  function __get_node_element(node_array_path, node_offset) {
1097
- const node_array_el = canvas_el.querySelector(
1098
- `[data-path="${node_array_path}"][data-type="node_array"]`
1118
+ return canvas_el.querySelector(
1119
+ `[data-path="${node_array_path}.${node_offset}"][data-type="node"]`
1099
1120
  );
1100
- if (!node_array_el) return null;
1101
-
1102
- const node_elements = node_array_el.children;
1103
- if (node_elements.length === 0) return null;
1104
-
1105
- return node_elements[node_offset];
1106
1121
  }
1107
1122
 
1108
1123
  function __render_node_selection() {
1109
1124
  const selection = /** @type {NodeSelection} */ (session.selection);
1110
1125
  const node_array_path = selection.path.join('.');
1111
- let is_collapsed = selection.anchor_offset === selection.focus_offset;
1112
- let is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
1113
-
1114
- // We need to translate the cusor offset to node offsets now
1115
- let anchor_node_offset, focus_node_offset;
1116
-
1117
- if (is_collapsed) {
1118
- anchor_node_offset = Math.max(0, selection.anchor_offset - 1);
1119
- focus_node_offset = Math.max(0, selection.focus_offset - 1);
1120
- } else if (is_backward) {
1121
- anchor_node_offset = selection.anchor_offset - 1;
1122
- focus_node_offset = selection.focus_offset;
1123
- } else {
1124
- anchor_node_offset = selection.anchor_offset;
1125
- focus_node_offset = selection.focus_offset - 1;
1126
- }
1126
+ const is_collapsed = selection.anchor_offset === selection.focus_offset;
1127
+ const is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
1127
1128
 
1128
- const anchor_node = __get_node_element(node_array_path, anchor_node_offset);
1129
- const focus_node = __get_node_element(node_array_path, focus_node_offset);
1129
+ const node_array_el = canvas_el.querySelector(
1130
+ `[data-path="${node_array_path}"][data-type="node_array"]`
1131
+ );
1132
+ if (!node_array_el) return;
1130
1133
 
1131
- if (!anchor_node || !focus_node) return;
1132
1134
  const dom_selection = window.getSelection();
1133
1135
  const range = window.document.createRange();
1134
1136
 
1135
- if (is_collapsed) {
1136
- // Caret position in between two nodes or at the very beginning/end of a node_array
1137
- // IMPORTANT: We need to look for direct children of anchor_node to find the right node gap.
1138
- const gap_selector = selection.anchor_offset === 0
1139
- ? '.gap-before'
1140
- : '.gap-after';
1141
- // In empty arrays the node gap is a sibling of the placeholder, not a child
1142
- const gap_el =
1143
- anchor_node.querySelector(`:scope > ${gap_selector}`) ??
1144
- anchor_node.parentElement?.querySelector(`:scope > ${gap_selector}`);
1137
+ const gap_selector = (offset) =>
1138
+ `[data-gap-array-path="${node_array_path}"][data-gap-offset="${offset}"]`;
1145
1139
 
1140
+ if (is_collapsed) {
1141
+ const gap_el = node_array_el.querySelector(gap_selector(selection.anchor_offset));
1146
1142
  if (!gap_el) return;
1147
- range.setStart(gap_el, 1);
1148
- range.setEnd(gap_el, 1);
1143
+ // Target .svedit-selectable (has a box), not gap_el which is
1144
+ // display:contents and would cause the browser to normalize
1145
+ // the range into the parent, breaking read-back.
1146
+ const selectable = gap_el.querySelector('.svedit-selectable');
1147
+ if (!selectable) return;
1148
+ range.setStart(selectable, 1);
1149
+ range.setEnd(selectable, 1);
1149
1150
  dom_selection.removeAllRanges();
1150
1151
  dom_selection.addRange(range);
1151
1152
  } else {
1152
- // Expanded selection (one or more nodes are fully selectd)
1153
+ const anchor_gap = node_array_el.querySelector(gap_selector(selection.anchor_offset));
1154
+ const focus_gap = node_array_el.querySelector(gap_selector(selection.focus_offset));
1155
+ if (!anchor_gap || !focus_gap) return;
1156
+ const anchor_sel = anchor_gap.querySelector('.svedit-selectable');
1157
+ const focus_sel = focus_gap.querySelector('.svedit-selectable');
1158
+ if (!anchor_sel || !focus_sel) return;
1159
+
1153
1160
  if (is_backward) {
1154
- // Use the last selectable you find
1155
- const anchor_node_selectable = [...anchor_node.querySelectorAll('.svedit-selectable')].at(
1156
- -1
1157
- );
1158
- // Use the first selectable you find
1159
- const focus_node_selectable = [...focus_node.querySelectorAll('.svedit-selectable')].at(0);
1160
-
1161
- range.setStart(focus_node_selectable, 1);
1162
- range.setEnd(anchor_node_selectable, 1);
1163
1161
  dom_selection.removeAllRanges();
1164
- dom_selection.addRange(range);
1165
- // Phew, this was a hard nut to crack. But with that code the direction can be reversed.
1166
- dom_selection.setBaseAndExtent(anchor_node_selectable, 1, focus_node_selectable, 1);
1162
+ dom_selection.setBaseAndExtent(anchor_sel, 1, focus_sel, 1);
1167
1163
  } else {
1168
- // Use the first selectable you find
1169
- const anchor_node_selectable = [...anchor_node.querySelectorAll('.svedit-selectable')].at(
1170
- 0
1171
- );
1172
- // Use the last selectable you find
1173
- const focus_node_selectable = [...focus_node.querySelectorAll('.svedit-selectable')].at(-1);
1174
-
1175
- range.setStart(anchor_node_selectable, 1);
1176
- range.setEnd(focus_node_selectable, 1);
1164
+ range.setStart(anchor_sel, 1);
1165
+ range.setEnd(focus_sel, 1);
1177
1166
  dom_selection.removeAllRanges();
1178
1167
  dom_selection.addRange(range);
1179
1168
  }
1180
1169
  }
1181
1170
 
1182
- // Ensure the node_array is focused
1183
- const node_array_el = canvas_el.querySelector(
1184
- `[data-path="${node_array_path}"][data-type="node_array"]`
1185
- );
1186
- if (node_array_el) {
1187
- node_array_el.focus();
1188
- // Scroll the selection into view
1171
+ node_array_el.focus();
1172
+ const scroll_node_offset = is_collapsed
1173
+ ? Math.max(0, selection.anchor_offset - 1)
1174
+ : (is_backward ? selection.focus_offset : selection.anchor_offset);
1175
+ const scroll_node = __get_node_element(node_array_path, scroll_node_offset);
1176
+ if (scroll_node) {
1189
1177
  setTimeout(() => {
1190
- (is_backward ? focus_node : anchor_node).scrollIntoView({
1191
- block: 'nearest',
1192
- inline: 'nearest'
1193
- });
1178
+ scroll_node.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1194
1179
  }, 0);
1195
- } else {
1196
- console.log('No container element found!');
1197
1180
  }
1198
1181
  }
1199
1182
 
@@ -26,7 +26,7 @@
26
26
  *
27
27
  * The viewport tracker drives **lazy positioning**: only near-viewport
28
28
  * gaps receive CSS anchor positioning (via NodeGap's `positioned`
29
- * prop). Off-viewport gaps remain as zero-size static elements with
29
+ * prop). Off-viewport gaps remain as zero-size absolute elements with
30
30
  * no layout cost. Gap markers (NodeGapMarkers) are only computed for
31
31
  * visible indices.
32
32
  *