svedit 0.7.1 → 0.8.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>
@@ -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
  }
@@ -764,6 +768,23 @@ ${fallback_html}`;
764
768
  canvas_el?.focus();
765
769
  }
766
770
 
771
+ /**
772
+ * When a DOM selection endpoint lands in a sibling NodeGap (not inside
773
+ * a node), resolves the adjacent node element for the walk-up algorithm.
774
+ * @param {HTMLElement} el
775
+ * @returns {HTMLElement | null}
776
+ */
777
+ function __resolve_node_from_gap(el) {
778
+ const gap = /** @type {HTMLElement | null} */ (el.closest('[data-gap-array-path]'));
779
+ if (!gap) return null;
780
+ const array_path = gap.dataset.gapArrayPath;
781
+ const offset = parseInt(gap.dataset.gapOffset, 10);
782
+ const node_idx = offset > 0 ? offset - 1 : 0;
783
+ return /** @type {HTMLElement | null} */ (
784
+ canvas_el.querySelector(`[data-path="${array_path}.${node_idx}"][data-type="node"]`)
785
+ );
786
+ }
787
+
767
788
  /**
768
789
  * Extracts a NodeSelection from the current DOM selection.
769
790
  *
@@ -783,56 +804,30 @@ ${fallback_html}`;
783
804
  if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
784
805
  if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
785
806
 
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('.');
807
+ // EDGE CASE: Collapsed selection inside a node gap (gap-after or gap-before).
808
+ // Gaps are siblings of nodes with data-gap-array-path and data-gap-offset.
809
+ const gap_el = /** @type {HTMLElement | null} */ (
810
+ focus_node.closest('[data-gap-array-path]')
811
+ );
812
+ if (gap_el && focus_node === anchor_node) {
813
+ const array_path = gap_el.dataset.gapArrayPath.split('.');
814
+ const gap_offset = parseInt(gap_el.dataset.gapOffset, 10);
814
815
  return {
815
816
  type: 'node',
816
- path: node_array_path,
817
- anchor_offset: 0,
818
- focus_offset: 0
817
+ path: array_path,
818
+ anchor_offset: gap_offset,
819
+ focus_offset: gap_offset
819
820
  };
820
821
  }
821
822
 
822
- let focus_root = /** @type {HTMLElement} */ (
823
- focus_node.closest('[data-path][data-type="node"]')
824
- );
823
+ let focus_root = __resolve_node_from_gap(focus_node)
824
+ ?? /** @type {HTMLElement} */ (focus_node.closest('[data-path][data-type="node"]'));
825
825
  if (!focus_root) return null;
826
826
 
827
- let anchor_root = /** @type {HTMLElement} */ (
828
- anchor_node.closest('[data-path][data-type="node"]')
829
- );
827
+ let anchor_root = __resolve_node_from_gap(anchor_node)
828
+ ?? /** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
830
829
  if (!anchor_root) return null;
831
830
 
832
- if (!(focus_root && anchor_root)) {
833
- return null;
834
- }
835
-
836
831
  let focus_root_path = focus_root.dataset.path.split('.');
837
832
  let anchor_root_path = anchor_root.dataset.path.split('.');
838
833
  let focus_node_depth = focus_root_path.length;
@@ -872,6 +867,10 @@ ${fallback_html}`;
872
867
  }
873
868
  }
874
869
 
870
+ // If both paths walked up to the root node, there's no common parent node_array
871
+ // (e.g. selecting between nav and page body). The resulting path would be empty.
872
+ if (anchor_root_path.length <= 1) return null;
873
+
875
874
  let anchor_offset = parseInt(anchor_root_path.at(-1));
876
875
  let focus_offset = parseInt(focus_root_path.at(-1));
877
876
 
@@ -1094,106 +1093,68 @@ ${fallback_html}`;
1094
1093
  }
1095
1094
 
1096
1095
  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"]`
1096
+ return canvas_el.querySelector(
1097
+ `[data-path="${node_array_path}.${node_offset}"][data-type="node"]`
1099
1098
  );
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
1099
  }
1107
1100
 
1108
1101
  function __render_node_selection() {
1109
1102
  const selection = /** @type {NodeSelection} */ (session.selection);
1110
1103
  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;
1104
+ const is_collapsed = selection.anchor_offset === selection.focus_offset;
1105
+ const is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
1116
1106
 
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
- }
1127
-
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);
1107
+ const node_array_el = canvas_el.querySelector(
1108
+ `[data-path="${node_array_path}"][data-type="node_array"]`
1109
+ );
1110
+ if (!node_array_el) return;
1130
1111
 
1131
- if (!anchor_node || !focus_node) return;
1132
1112
  const dom_selection = window.getSelection();
1133
1113
  const range = window.document.createRange();
1134
1114
 
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}`);
1115
+ const gap_selector = (offset) =>
1116
+ `[data-gap-array-path="${node_array_path}"][data-gap-offset="${offset}"]`;
1145
1117
 
1118
+ if (is_collapsed) {
1119
+ const gap_el = node_array_el.querySelector(gap_selector(selection.anchor_offset));
1146
1120
  if (!gap_el) return;
1147
- range.setStart(gap_el, 1);
1148
- range.setEnd(gap_el, 1);
1121
+ // Target .svedit-selectable (has a box), not gap_el which is
1122
+ // display:contents and would cause the browser to normalize
1123
+ // the range into the parent, breaking read-back.
1124
+ const selectable = gap_el.querySelector('.svedit-selectable');
1125
+ if (!selectable) return;
1126
+ range.setStart(selectable, 1);
1127
+ range.setEnd(selectable, 1);
1149
1128
  dom_selection.removeAllRanges();
1150
1129
  dom_selection.addRange(range);
1151
1130
  } else {
1152
- // Expanded selection (one or more nodes are fully selectd)
1131
+ const anchor_gap = node_array_el.querySelector(gap_selector(selection.anchor_offset));
1132
+ const focus_gap = node_array_el.querySelector(gap_selector(selection.focus_offset));
1133
+ if (!anchor_gap || !focus_gap) return;
1134
+ const anchor_sel = anchor_gap.querySelector('.svedit-selectable');
1135
+ const focus_sel = focus_gap.querySelector('.svedit-selectable');
1136
+ if (!anchor_sel || !focus_sel) return;
1137
+
1153
1138
  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
1139
  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);
1140
+ dom_selection.setBaseAndExtent(anchor_sel, 1, focus_sel, 1);
1167
1141
  } 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);
1142
+ range.setStart(anchor_sel, 1);
1143
+ range.setEnd(focus_sel, 1);
1177
1144
  dom_selection.removeAllRanges();
1178
1145
  dom_selection.addRange(range);
1179
1146
  }
1180
1147
  }
1181
1148
 
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
1149
+ node_array_el.focus();
1150
+ const scroll_node_offset = is_collapsed
1151
+ ? Math.max(0, selection.anchor_offset - 1)
1152
+ : (is_backward ? selection.focus_offset : selection.anchor_offset);
1153
+ const scroll_node = __get_node_element(node_array_path, scroll_node_offset);
1154
+ if (scroll_node) {
1189
1155
  setTimeout(() => {
1190
- (is_backward ? focus_node : anchor_node).scrollIntoView({
1191
- block: 'nearest',
1192
- inline: 'nearest'
1193
- });
1156
+ scroll_node.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1194
1157
  }, 0);
1195
- } else {
1196
- console.log('No container element found!');
1197
1158
  }
1198
1159
  }
1199
1160
 
@@ -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
  *