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.
@@ -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
 
@@ -1 +1 @@
1
- {"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;AAo3CA;;eAAqD;iCAv1C1C,cAAc"}
1
+ {"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;AAm2CA;;eAAqD;iCAt0C1C,cAAc"}
@@ -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
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svedit",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -1,53 +1,32 @@
1
1
  <script>
2
2
  import { getContext } from 'svelte';
3
- import DefaultNodeGap from './NodeGap.svelte';
4
3
 
5
4
  /** @import { NodeProps } from './types.d.ts'; */
6
5
 
7
6
  const svedit = getContext('svedit');
8
- let NodeGap = $derived(svedit.session.config.system_components?.NodeGap ?? DefaultNodeGap);
9
7
 
10
8
  /** @type {NodeProps} */
11
9
  let { path, children, tag = 'div', class: css_class, style = '', ...rest } = $props();
12
10
 
13
11
  let node = $derived(svedit.session.get(path));
14
12
 
15
- // NodeArrayProperty sets this context — its presence means we're in a node_array.
16
- // Using context avoids expensive session.inspect() and session.get() calls
17
- // that would create O(N) reactive subscriptions to the parent array.
18
13
  const node_array_meta = getContext('node_array_meta');
19
- const is_inside_node_array = !!node_array_meta;
20
- let child_index = $derived(parseInt(String(path.at(-1)), 10));
21
- let is_first_node_array_child = $derived(is_inside_node_array && child_index === 0);
22
- let is_last_node_array_child = $derived(
23
- is_inside_node_array && child_index === node_array_meta.length - 1
24
- );
25
-
26
- // Drives lazy anchor positioning for node gaps. Gaps are always in the
27
- // DOM (stable structure for selection), but only anchor-positioned when
28
- // near the viewport to avoid O(N) layout cost.
29
- let is_near_viewport = $derived(
30
- svedit.is_near_viewport?.(path) ?? true
31
- );
14
+ let child_index = $derived(node_array_meta ? parseInt(String(path.at(-1)), 10) : -1);
15
+ let is_first = $derived(node_array_meta && child_index === 0);
16
+ let is_last = $derived(node_array_meta && child_index === node_array_meta.length - 1);
32
17
  </script>
33
18
 
34
19
  <svelte:element
35
20
  id={node.id}
36
21
  this={tag}
37
- class={css_class}
22
+ class="{css_class}{is_first ? ' first' : ''}{is_last ? ' last' : ''}"
38
23
  data-node-id={node.id}
39
24
  data-path={path.join('.')}
40
25
  data-type="node"
41
26
  style="anchor-name: --{path.join('-')};{style}"
42
27
  {...rest}
43
28
  >
44
- {#if svedit.editable && is_first_node_array_child}
45
- <NodeGap {path} type="gap-before" positioned={is_near_viewport} />
46
- {/if}
47
29
  {@render children()}
48
- {#if svedit.editable && is_inside_node_array}
49
- <NodeGap {path} type="gap-after" last={is_last_node_array_child} positioned={is_near_viewport} />
50
- {/if}
51
30
  </svelte:element>
52
31
 
53
32
  <style>
@@ -20,8 +20,6 @@
20
20
  .map(/** @param {string} node_id */ (node_id) => svedit.session.get(node_id))
21
21
  );
22
22
 
23
- // Expose array metadata to child Node.svelte instances via context,
24
- // avoiding expensive session.inspect() / session.get() calls per node.
25
23
  setContext('node_array_meta', {
26
24
  get length() { return nodes.length; }
27
25
  });
@@ -52,9 +50,17 @@
52
50
  <!-- Sibling (not child) of .empty-node-array so its .svedit-selectable
53
51
  resolves anchor positioning against the shared containing block,
54
52
  not the placeholder which inherits .node positioning styles. -->
55
- <NodeGap path={[...path, 0]} type="gap-before" empty />
53
+ <NodeGap array_path={path} offset={0} count={0} empty />
56
54
  {/if}
57
55
  {#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}
58
64
  {@const Component = svedit.session.config.node_components[snake_to_pascal(node.type)]}
59
65
  {#if Component}
60
66
  <Component path={[...path, index]} />
@@ -62,6 +68,14 @@
62
68
  <UnknownNode path={[...path, index]} />
63
69
  {/if}
64
70
  {/each}
71
+ {#if svedit.editable && nodes.length > 0}
72
+ <NodeGap
73
+ array_path={path}
74
+ offset={nodes.length}
75
+ count={nodes.length}
76
+ positioned={svedit.is_near_viewport?.([...path, nodes.length - 1]) ?? true}
77
+ />
78
+ {/if}
65
79
  {#if svedit.editable && NodeGapMarkers}
66
80
  <NodeGapMarkers {path} />
67
81
  {/if}
@@ -2,6 +2,11 @@
2
2
  /**
3
3
  * Invisible keyboard caret selectable and gap hit area.
4
4
  *
5
+ * Rendered as a sibling of Node elements inside NodeArrayProperty,
6
+ * interleaved between nodes in DOM order. This ensures:
7
+ * - Correct DOM order for native drag-to-select across nodes
8
+ * - No containing-block issues from transform/filter/will-change on nodes
9
+ *
5
10
  * Always present in the DOM when editable (stable structure for
6
11
  * selection anchoring, scrollTo, etc.). Anchor positioning is
7
12
  * activated lazily via the `positioned` prop — only gaps near the
@@ -15,29 +20,37 @@
15
20
  *
16
21
  * Row/column detection uses var(--row, 1) with the * 99999 multiplier
17
22
  * trick — no @container style() queries, works in all browsers.
18
- *
19
- * Node.svelte does NOT set position: relative, so the selectable's
20
- * containing block is a higher ancestor that contains all sibling
21
- * nodes, making cross-sibling anchor references valid.
22
23
  */
23
- let { path, type, empty = false, last = false, positioned = true } = $props();
24
+ let { array_path, offset, count, empty = false, positioned = true } = $props();
25
+
26
+ let is_first = $derived(offset === 0);
27
+ let is_last = $derived(offset === count);
28
+ let type = $derived(is_first ? 'gap-before' : 'gap-after');
24
29
 
25
30
  let gap_style = $derived.by(() => {
26
- const p = path.join('-');
27
- const arr = path.slice(0, -1).join('-');
28
- const idx = parseInt(String(path.at(-1)), 10);
29
- return `--_pa:--${p};--_next:--${arr}-${idx + 1};--_container:--${arr}`;
31
+ const arr = array_path.join('-');
32
+ const prev_idx = offset - 1;
33
+ const pa = is_first ? `--${arr}-0` : `--${arr}-${prev_idx}`;
34
+ const next = `--${arr}-${offset}`;
35
+ const container = `--${arr}`;
36
+ return `--_pa:${pa};--_next:${next};--_container:${container}`;
30
37
  });
31
38
 
32
- let anchor_name = $derived(`--g-${path.join('-')}-${type}`);
39
+ let anchor_name = $derived.by(() => {
40
+ const arr = array_path.join('-');
41
+ if (is_first) return `--g-${arr}-0-gap-before`;
42
+ return `--g-${arr}-${offset - 1}-gap-after`;
43
+ });
33
44
  </script>
34
45
 
35
46
  <div
36
47
  class="node-gap {type}"
37
48
  class:empty
38
- class:last
49
+ class:last={is_last}
39
50
  class:positioned
40
51
  data-type={type}
52
+ data-gap-array-path={array_path.join('.')}
53
+ data-gap-offset={offset}
41
54
  style={gap_style}
42
55
  >
43
56
  <div class="svedit-selectable" style="anchor-name:{anchor_name}"><br /></div>
@@ -46,10 +59,10 @@
46
59
 
47
60
  <style>
48
61
  .node-gap {
49
- height: 0;
50
- overflow: visible;
51
- outline: none;
52
- pointer-events: none;
62
+ display: contents;
63
+ /* The native browser caret briefly appears inside .svedit-selectable for one frame
64
+ before the model-driven NodeCaret renders at the correct edge position.
65
+ Suppressing it here avoids a flash of the native caret. */
53
66
  caret-color: transparent;
54
67
  }
55
68
 
@@ -58,9 +71,7 @@
58
71
  /* ------------------------------------------------------------------ */
59
72
 
60
73
  .node-gap:not(.positioned) .svedit-selectable {
61
- content-visibility: hidden;
62
- contain-intrinsic-size: 0 0;
63
- position: static;
74
+ position: absolute;
64
75
  pointer-events: none;
65
76
  width: 0;
66
77
  height: 0;
@@ -73,8 +84,21 @@
73
84
 
74
85
  /*
75
86
  * Anchor references resolved from CSS variable names passed via
76
- * inline style (--_pa, --_next, --_container). Same pattern as
77
- * NodeGapMarkers — keeps JS minimal and anchor() in CSS.
87
+ * inline style (--_pa = parent anchor, --_next, --_container).
88
+ * Same pattern as NodeGapMarkers — keeps JS minimal and anchor() in CSS.
89
+ *
90
+ * --_pa references the anchor-name of the node this gap belongs to,
91
+ * allowing us to position relative to that node's edges.
92
+ *
93
+ * Edge gaps extend outward and clamp to the containing block edge
94
+ * (0px floor). This can overlap neighboring elements when the node
95
+ * array isn't alone in its parent. anchor() only sees the border
96
+ * box, so it can't detect margin, gap, or parent padding around
97
+ * the container. --node-caret-boundary lets consumers set an
98
+ * explicit clamp target (a parent element's anchor-name) so edge
99
+ * gaps stop at that boundary instead. --node-caret-boundary-x and
100
+ * --node-caret-boundary-y override per-axis, falling back to
101
+ * --node-caret-boundary when unset.
78
102
  */
79
103
  .node-gap.positioned {
80
104
  --_s-t: anchor(var(--_pa) top);
@@ -82,6 +106,10 @@
82
106
  --_s-l: anchor(var(--_pa) left);
83
107
  --_s-r: anchor(var(--_pa) right);
84
108
  }
109
+ .positioned.gap-before:not(.empty) {
110
+ --_b-t: anchor(var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) top, 0px);
111
+ --_b-l: anchor(var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) left, 0px);
112
+ }
85
113
  .positioned.gap-after:not(.last) {
86
114
  --_n-t: anchor(var(--_next) top);
87
115
  --_n-l: anchor(var(--_next) left);
@@ -93,6 +121,10 @@
93
121
  --_c-b: anchor(var(--_container) bottom);
94
122
  --_c-l: anchor(var(--_container) left);
95
123
  --_c-r: anchor(var(--_container) right);
124
+ --_b-b: anchor(var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) bottom, 0px);
125
+ --_b-r: anchor(var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) right, 0px);
126
+ --_b-bt: anchor(var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) bottom, 99999px);
127
+ --_b-rl: anchor(var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) right, 99999px);
96
128
  }
97
129
 
98
130
  .positioned .svedit-selectable {
@@ -171,28 +203,33 @@
171
203
  );
172
204
  min-height: calc(var(--_gm) * var(--_C));
173
205
  min-width: calc(var(--_gm) * var(--_R));
206
+ /* min-height: max(calc(var(--_gm) * var(--_C)), calc(anchor-size(var(--_pa) height, 100%) * var(--_R)));
207
+ min-width: max(calc(var(--_gm) * var(--_R)), calc(anchor-size(var(--_pa) width, 100%) * var(--_C))); */
174
208
  }
175
209
 
176
- /* After last node: col extends down, row extends right */
210
+ /* After last node: col extends down, row extends right.
211
+ top also clamps to boundary_bottom - eg so that min-height
212
+ (which wins over bottom in overconstrained abs-pos) cannot
213
+ push the element past the boundary. */
177
214
  .positioned.gap-after.last .svedit-selectable {
178
215
  top: min(
179
- calc(var(--_s-b) + var(--_R) * 99999px),
216
+ calc(min(var(--_s-b), calc(var(--_b-bt) - var(--_eg))) + var(--_R) * 99999px),
180
217
  calc(var(--_s-t) + var(--_C) * 99999px)
181
218
  );
182
219
  bottom: min(
183
- calc(var(--_s-b) - var(--_eg) + var(--_R) * 99999px),
220
+ calc(max(var(--_b-b), var(--_s-b) - var(--_eg)) + var(--_R) * 99999px),
184
221
  calc(var(--_s-b) + var(--_C) * 99999px)
185
222
  );
186
223
  left: min(
187
224
  calc(var(--_s-l) + var(--_R) * 99999px),
188
- calc(var(--_s-r) + var(--_C) * 99999px),
225
+ calc(min(var(--_s-r), calc(var(--_b-rl) - var(--_eg))) + var(--_C) * 99999px),
189
226
  calc(100% - var(--_eg) + var(--_C) * 99999px)
190
227
  );
191
228
  right: min(
192
229
  calc(var(--_s-r) + var(--_R) * 99999px),
193
230
  calc(
194
231
  max(
195
- 0px,
232
+ var(--_b-r),
196
233
  min(
197
234
  calc(var(--_c-r) - var(--_eg)),
198
235
  calc(var(--_s-r) - var(--_eg))
@@ -209,7 +246,7 @@
209
246
  .positioned.gap-before:not(.empty) .svedit-selectable {
210
247
  top: min(
211
248
  calc(
212
- max(0px, var(--_s-t) - var(--_eg))
249
+ max(var(--_b-t), var(--_s-t) - var(--_eg))
213
250
  + var(--_R) * 99999px
214
251
  ),
215
252
  calc(var(--_s-t) + var(--_C) * 99999px)
@@ -221,7 +258,7 @@
221
258
  left: min(
222
259
  calc(var(--_s-l) + var(--_R) * 99999px),
223
260
  calc(
224
- max(0px, var(--_s-l) - var(--_eg))
261
+ max(var(--_b-l), var(--_s-l) - var(--_eg))
225
262
  + var(--_C) * 99999px
226
263
  )
227
264
  );