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.
- package/README.md +91 -2
- package/dist/Node.svelte +4 -25
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +17 -3
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/NodeGap.svelte +64 -27
- package/dist/NodeGap.svelte.d.ts +6 -6
- package/dist/NodeGap.svelte.d.ts.map +1 -1
- package/dist/NodeGapMarkers.svelte +36 -10
- package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
- package/dist/Svedit.svelte +84 -122
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/node_gap_computation.svelte.js +1 -1
- package/dist/transforms.svelte.d.ts +8 -0
- package/dist/transforms.svelte.d.ts.map +1 -1
- package/dist/transforms.svelte.js +13 -0
- package/package.json +1 -1
- package/src/lib/Node.svelte +4 -25
- package/src/lib/NodeArrayProperty.svelte +17 -3
- package/src/lib/NodeGap.svelte +64 -27
- package/src/lib/NodeGapMarkers.svelte +36 -10
- package/src/lib/Svedit.svelte +84 -122
- package/src/lib/node_gap_computation.svelte.js +1 -1
- package/src/lib/transforms.svelte.js +13 -0
package/dist/Svedit.svelte
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
594
|
-
|
|
595
|
-
|
|
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
|
|
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:
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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:
|
|
816
|
-
anchor_offset:
|
|
817
|
-
focus_offset:
|
|
817
|
+
path: array_path,
|
|
818
|
+
anchor_offset: gap_offset,
|
|
819
|
+
focus_offset: gap_offset
|
|
818
820
|
};
|
|
819
821
|
}
|
|
820
822
|
|
|
821
|
-
let focus_root =
|
|
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 =
|
|
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
|
-
|
|
1097
|
-
`[data-path="${node_array_path}"][data-type="
|
|
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
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1168
|
-
|
|
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
|
-
|
|
1182
|
-
const
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
(
|
|
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
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;AA60CA;;eAAqD;iCAhzC1C,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
|
|
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,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set multiple properties on a node via a transaction.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} tr - The transaction
|
|
5
|
+
* @param {Array} path - Path to the node
|
|
6
|
+
* @param {Record<string, any>} properties - Key/value pairs to set
|
|
7
|
+
*/
|
|
8
|
+
export function set_properties(tr: object, path: any[], properties: Record<string, any>): void;
|
|
1
9
|
export function break_text_node(tr: any): boolean;
|
|
2
10
|
export function join_text_node(tr: any): boolean;
|
|
3
11
|
export function insert_default_node(tr: any): boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transforms.svelte.d.ts","sourceRoot":"","sources":["../src/lib/transforms.svelte.js"],"names":[],"mappings":"AAGA,kDAqDC;AAED,iDAoEC;AAED,sDAuBC"}
|
|
1
|
+
{"version":3,"file":"transforms.svelte.d.ts","sourceRoot":"","sources":["../src/lib/transforms.svelte.js"],"names":[],"mappings":"AAGA;;;;;;GAMG;AACH,mCAJW,MAAM,2BAEN,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,QAM7B;AAED,kDAqDC;AAED,iDAoEC;AAED,sDAuBC"}
|
|
@@ -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;
|
package/package.json
CHANGED
package/src/lib/Node.svelte
CHANGED
|
@@ -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
|
-
|
|
20
|
-
let
|
|
21
|
-
let
|
|
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
|
|
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}
|
package/src/lib/NodeGap.svelte
CHANGED
|
@@ -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 {
|
|
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
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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(
|
|
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(
|
|
261
|
+
max(var(--_b-l), var(--_s-l) - var(--_eg))
|
|
225
262
|
+ var(--_C) * 99999px
|
|
226
263
|
)
|
|
227
264
|
);
|