svedit 0.7.0 → 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
  }
@@ -573,15 +577,15 @@ ${fallback_html}`;
573
577
 
574
578
  let plain_text,
575
579
  pasted_json,
576
- pasted_images = [];
580
+ pasted_media = [];
577
581
 
578
582
  // NOTE: For some reason, await navigator.clipboard.read()
579
583
  const clipboard_items = event.clipboardData?.items || [];
580
584
  for (const item of clipboard_items || []) {
581
- if (item.type.startsWith('image/')) {
585
+ if (item.type.startsWith('image/') || item.type.startsWith('video/') || item.type.startsWith('audio/')) {
582
586
  const blob = item.getAsFile();
583
587
  const data_url = URL.createObjectURL(blob);
584
- pasted_images.push({
588
+ pasted_media.push({
585
589
  blob,
586
590
  data_url,
587
591
  type: item.type,
@@ -590,11 +594,12 @@ ${fallback_html}`;
590
594
  }
591
595
  }
592
596
 
593
- if (pasted_images.length > 0) {
594
- pasted_json = await session.config.handle_image_paste(session, pasted_images);
595
- console.log('pasted_json_after_image_paste', pasted_json);
597
+ if (pasted_media.length > 0) {
598
+ const handle_media_paste = session.config.handle_media_paste || session.config.handle_image_paste
599
+ pasted_json = await handle_media_paste(session, pasted_media);
600
+ console.log('pasted_json_after_media_paste', pasted_json);
596
601
  // NOTE: If no pasted_json is returned from the custom handler, we assume that content creation has been
597
- // handled inside handle_image_paste already.
602
+ // handled inside handle_media_paste already.
598
603
  if (!pasted_json) return;
599
604
  } else {
600
605
  // First try to extract svedit data from HTML format
@@ -763,6 +768,23 @@ ${fallback_html}`;
763
768
  canvas_el?.focus();
764
769
  }
765
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
+
766
788
  /**
767
789
  * Extracts a NodeSelection from the current DOM selection.
768
790
  *
@@ -782,56 +804,30 @@ ${fallback_html}`;
782
804
  if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
783
805
  if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
784
806
 
785
- // EDGE CASE: Let's first check if we are in a node gap for node carets
786
- let gap_after_el =
787
- focus_node && focus_node?.closest('[data-type="gap-after"]');
788
- if (gap_after_el && focus_node === anchor_node) {
789
- // Find the node that this gap belongs to
790
- let node = /** @type {HTMLElement} */ (gap_after_el.closest('[data-type="node"]'));
791
- if (!node) {
792
- console.log('No corresponding node found for gap-after');
793
- return null;
794
- }
795
- const node_path = /** @type {DocumentPath} */ (node.dataset.path.split('.'));
796
- const node_index = parseInt(String(node_path.at(-1)), 10);
797
-
798
- return {
799
- type: 'node',
800
- path: node_path.slice(0, -1),
801
- anchor_offset: node_index + 1,
802
- focus_offset: node_index + 1
803
- };
804
- }
805
-
806
- // EDGE CASE: Let's check if we are in a gap-before for node_arrays.
807
- let gap_before_el = focus_node.closest('[data-type="gap-before"]');
808
- if (gap_before_el && focus_node === anchor_node) {
809
- const node_array_el = /** @type {HTMLElement} */ (
810
- gap_before_el.closest('[data-type="node_array"]')
811
- );
812
- 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);
813
815
  return {
814
816
  type: 'node',
815
- path: node_array_path,
816
- anchor_offset: 0,
817
- focus_offset: 0
817
+ path: array_path,
818
+ anchor_offset: gap_offset,
819
+ focus_offset: gap_offset
818
820
  };
819
821
  }
820
822
 
821
- let focus_root = /** @type {HTMLElement} */ (
822
- focus_node.closest('[data-path][data-type="node"]')
823
- );
823
+ let focus_root = __resolve_node_from_gap(focus_node)
824
+ ?? /** @type {HTMLElement} */ (focus_node.closest('[data-path][data-type="node"]'));
824
825
  if (!focus_root) return null;
825
826
 
826
- let anchor_root = /** @type {HTMLElement} */ (
827
- anchor_node.closest('[data-path][data-type="node"]')
828
- );
827
+ let anchor_root = __resolve_node_from_gap(anchor_node)
828
+ ?? /** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
829
829
  if (!anchor_root) return null;
830
830
 
831
- if (!(focus_root && anchor_root)) {
832
- return null;
833
- }
834
-
835
831
  let focus_root_path = focus_root.dataset.path.split('.');
836
832
  let anchor_root_path = anchor_root.dataset.path.split('.');
837
833
  let focus_node_depth = focus_root_path.length;
@@ -871,6 +867,10 @@ ${fallback_html}`;
871
867
  }
872
868
  }
873
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
+
874
874
  let anchor_offset = parseInt(anchor_root_path.at(-1));
875
875
  let focus_offset = parseInt(focus_root_path.at(-1));
876
876
 
@@ -1093,106 +1093,68 @@ ${fallback_html}`;
1093
1093
  }
1094
1094
 
1095
1095
  function __get_node_element(node_array_path, node_offset) {
1096
- const node_array_el = canvas_el.querySelector(
1097
- `[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"]`
1098
1098
  );
1099
- if (!node_array_el) return null;
1100
-
1101
- const node_elements = node_array_el.children;
1102
- if (node_elements.length === 0) return null;
1103
-
1104
- return node_elements[node_offset];
1105
1099
  }
1106
1100
 
1107
1101
  function __render_node_selection() {
1108
1102
  const selection = /** @type {NodeSelection} */ (session.selection);
1109
1103
  const node_array_path = selection.path.join('.');
1110
- let is_collapsed = selection.anchor_offset === selection.focus_offset;
1111
- let is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
1112
-
1113
- // We need to translate the cusor offset to node offsets now
1114
- 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;
1115
1106
 
1116
- if (is_collapsed) {
1117
- anchor_node_offset = Math.max(0, selection.anchor_offset - 1);
1118
- focus_node_offset = Math.max(0, selection.focus_offset - 1);
1119
- } else if (is_backward) {
1120
- anchor_node_offset = selection.anchor_offset - 1;
1121
- focus_node_offset = selection.focus_offset;
1122
- } else {
1123
- anchor_node_offset = selection.anchor_offset;
1124
- focus_node_offset = selection.focus_offset - 1;
1125
- }
1126
-
1127
- const anchor_node = __get_node_element(node_array_path, anchor_node_offset);
1128
- 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;
1129
1111
 
1130
- if (!anchor_node || !focus_node) return;
1131
1112
  const dom_selection = window.getSelection();
1132
1113
  const range = window.document.createRange();
1133
1114
 
1134
- if (is_collapsed) {
1135
- // Caret position in between two nodes or at the very beginning/end of a node_array
1136
- // IMPORTANT: We need to look for direct children of anchor_node to find the right node gap.
1137
- const gap_selector = selection.anchor_offset === 0
1138
- ? '.gap-before'
1139
- : '.gap-after';
1140
- // In empty arrays the node gap is a sibling of the placeholder, not a child
1141
- const gap_el =
1142
- anchor_node.querySelector(`:scope > ${gap_selector}`) ??
1143
- anchor_node.parentElement?.querySelector(`:scope > ${gap_selector}`);
1115
+ const gap_selector = (offset) =>
1116
+ `[data-gap-array-path="${node_array_path}"][data-gap-offset="${offset}"]`;
1144
1117
 
1118
+ if (is_collapsed) {
1119
+ const gap_el = node_array_el.querySelector(gap_selector(selection.anchor_offset));
1145
1120
  if (!gap_el) return;
1146
- range.setStart(gap_el, 1);
1147
- 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);
1148
1128
  dom_selection.removeAllRanges();
1149
1129
  dom_selection.addRange(range);
1150
1130
  } else {
1151
- // 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
+
1152
1138
  if (is_backward) {
1153
- // Use the last selectable you find
1154
- const anchor_node_selectable = [...anchor_node.querySelectorAll('.svedit-selectable')].at(
1155
- -1
1156
- );
1157
- // Use the first selectable you find
1158
- const focus_node_selectable = [...focus_node.querySelectorAll('.svedit-selectable')].at(0);
1159
-
1160
- range.setStart(focus_node_selectable, 1);
1161
- range.setEnd(anchor_node_selectable, 1);
1162
1139
  dom_selection.removeAllRanges();
1163
- dom_selection.addRange(range);
1164
- // Phew, this was a hard nut to crack. But with that code the direction can be reversed.
1165
- dom_selection.setBaseAndExtent(anchor_node_selectable, 1, focus_node_selectable, 1);
1140
+ dom_selection.setBaseAndExtent(anchor_sel, 1, focus_sel, 1);
1166
1141
  } else {
1167
- // Use the first selectable you find
1168
- const anchor_node_selectable = [...anchor_node.querySelectorAll('.svedit-selectable')].at(
1169
- 0
1170
- );
1171
- // Use the last selectable you find
1172
- const focus_node_selectable = [...focus_node.querySelectorAll('.svedit-selectable')].at(-1);
1173
-
1174
- range.setStart(anchor_node_selectable, 1);
1175
- range.setEnd(focus_node_selectable, 1);
1142
+ range.setStart(anchor_sel, 1);
1143
+ range.setEnd(focus_sel, 1);
1176
1144
  dom_selection.removeAllRanges();
1177
1145
  dom_selection.addRange(range);
1178
1146
  }
1179
1147
  }
1180
1148
 
1181
- // Ensure the node_array is focused
1182
- const node_array_el = canvas_el.querySelector(
1183
- `[data-path="${node_array_path}"][data-type="node_array"]`
1184
- );
1185
- if (node_array_el) {
1186
- node_array_el.focus();
1187
- // 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) {
1188
1155
  setTimeout(() => {
1189
- (is_backward ? focus_node : anchor_node).scrollIntoView({
1190
- block: 'nearest',
1191
- inline: 'nearest'
1192
- });
1156
+ scroll_node.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1193
1157
  }, 0);
1194
- } else {
1195
- console.log('No container element found!');
1196
1158
  }
1197
1159
  }
1198
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
  *
@@ -1,6 +1,19 @@
1
1
  import { split_annotated_text, join_annotated_text, get_char_length } from './utils.js';
2
2
  import { get_default_node_type } from './doc_utils.js';
3
3
 
4
+ /**
5
+ * Set multiple properties on a node via a transaction.
6
+ *
7
+ * @param {object} tr - The transaction
8
+ * @param {Array} path - Path to the node
9
+ * @param {Record<string, any>} properties - Key/value pairs to set
10
+ */
11
+ export function set_properties(tr, path, properties) {
12
+ for (const [key, value] of Object.entries(properties)) {
13
+ tr.set([...path, key], value);
14
+ }
15
+ }
16
+
4
17
  export function break_text_node(tr) {
5
18
  // Keep a reference of the original selection (before any transforms are applied)
6
19
  const selection = tr.selection;