svedit 0.6.4 → 0.7.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.
Files changed (65) hide show
  1. package/README.md +117 -45
  2. package/dist/AnnotatedTextProperty.svelte +14 -17
  3. package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.d.ts +3 -3
  5. package/dist/Command.svelte.js +5 -5
  6. package/dist/CustomProperty.svelte +5 -4
  7. package/dist/Node.svelte +29 -14
  8. package/dist/Node.svelte.d.ts.map +1 -1
  9. package/dist/NodeArrayProperty.svelte +29 -9
  10. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  11. package/dist/NodeCaret.svelte +71 -0
  12. package/dist/NodeCaret.svelte.d.ts +27 -0
  13. package/dist/NodeCaret.svelte.d.ts.map +1 -0
  14. package/dist/NodeGap.svelte +249 -0
  15. package/dist/NodeGap.svelte.d.ts +20 -0
  16. package/dist/NodeGap.svelte.d.ts.map +1 -0
  17. package/dist/NodeGapMarkers.svelte +456 -0
  18. package/dist/NodeGapMarkers.svelte.d.ts +12 -0
  19. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -0
  20. package/dist/NodeSelectionMarkers.svelte +49 -0
  21. package/dist/NodeSelectionMarkers.svelte.d.ts +7 -0
  22. package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -0
  23. package/dist/Session.svelte.d.ts +6 -6
  24. package/dist/Session.svelte.d.ts.map +1 -1
  25. package/dist/Session.svelte.js +13 -69
  26. package/dist/Svedit.svelte +153 -88
  27. package/dist/Svedit.svelte.d.ts.map +1 -1
  28. package/dist/Transaction.svelte.d.ts +1 -1
  29. package/dist/Transaction.svelte.d.ts.map +1 -1
  30. package/dist/Transaction.svelte.js +8 -68
  31. package/dist/doc_utils.d.ts +12 -0
  32. package/dist/doc_utils.d.ts.map +1 -1
  33. package/dist/doc_utils.js +61 -2
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.js +4 -0
  36. package/dist/node_gap_computation.svelte.d.ts +5 -0
  37. package/dist/node_gap_computation.svelte.d.ts.map +1 -0
  38. package/dist/node_gap_computation.svelte.js +621 -0
  39. package/dist/styles/svedit-colors.css +8 -0
  40. package/dist/transforms.svelte.d.ts.map +1 -1
  41. package/dist/transforms.svelte.js +7 -10
  42. package/dist/types.d.ts +56 -68
  43. package/dist/utils.d.ts +0 -3
  44. package/dist/utils.d.ts.map +1 -1
  45. package/dist/utils.js +10 -10
  46. package/package.json +21 -21
  47. package/src/lib/AnnotatedTextProperty.svelte +14 -17
  48. package/src/lib/Command.svelte.js +5 -5
  49. package/src/lib/CustomProperty.svelte +5 -4
  50. package/src/lib/Node.svelte +29 -14
  51. package/src/lib/NodeArrayProperty.svelte +29 -9
  52. package/src/lib/NodeCaret.svelte +71 -0
  53. package/src/lib/NodeGap.svelte +249 -0
  54. package/src/lib/NodeGapMarkers.svelte +456 -0
  55. package/src/lib/NodeSelectionMarkers.svelte +49 -0
  56. package/src/lib/Session.svelte.js +13 -69
  57. package/src/lib/Svedit.svelte +153 -88
  58. package/src/lib/Transaction.svelte.js +8 -68
  59. package/src/lib/doc_utils.js +61 -2
  60. package/src/lib/index.js +4 -0
  61. package/src/lib/node_gap_computation.svelte.js +621 -0
  62. package/src/lib/styles/svedit-colors.css +8 -0
  63. package/src/lib/transforms.svelte.js +7 -10
  64. package/src/lib/types.d.ts +56 -68
  65. package/src/lib/utils.js +10 -10
@@ -0,0 +1,621 @@
1
+ /**
2
+ * Gap computation and viewport-aware positioning for node-array
3
+ * insertion markers. Called once from Svedit.svelte during init.
4
+ *
5
+ * @module
6
+ */
7
+
8
+ /**
9
+ * IntersectionObserver-based viewport tracking for node arrays.
10
+ *
11
+ * ## Why this exists
12
+ *
13
+ * CSS anchor positioning is O(N) — the browser must resolve anchor()
14
+ * functions for every positioned element on layout. Gap markers
15
+ * (NodeGapMarkers) also grow at O(N). At ~200+ nodes this causes
16
+ * noticeable frame drops during scroll and resize; at 500+ it's
17
+ * unusable.
18
+ *
19
+ * ## Design principle: structurally stable DOM
20
+ *
21
+ * NodeGap elements are **always present** in the DOM when editable,
22
+ * regardless of viewport position. This guarantees:
23
+ * - Selection anchors survive scrolling (no DOM removal mid-drag)
24
+ * - scrollTo can always target gap elements
25
+ * - ui = f(doc_state, editable_state) — no viewport-driven DOM changes
26
+ *
27
+ * The viewport tracker drives **lazy positioning**: only near-viewport
28
+ * gaps receive CSS anchor positioning (via NodeGap's `positioned`
29
+ * prop). Off-viewport gaps remain as zero-size static elements with
30
+ * no layout cost. Gap markers (NodeGapMarkers) are only computed for
31
+ * visible indices.
32
+ *
33
+ * ## Architecture
34
+ *
35
+ * Only **root-level** node_array children are observed (e.g. the
36
+ * direct children of `page.body`). Nested node_arrays (e.g. buttons
37
+ * inside a story, list items inside a list) are small and have their
38
+ * visibility inferred from their root ancestor — no per-element IO
39
+ * tracking needed. This keeps the IntersectionObserver budget small.
40
+ *
41
+ * The IntersectionObserver is created once and stays alive across
42
+ * document mutations (typing, node insertion, undo/redo). A
43
+ * MutationObserver watches the DOM for added/removed node elements
44
+ * and dynamically registers/unregisters them with the IO. This avoids
45
+ * the cost of tearing down and recreating the IO on every keystroke.
46
+ * The MutationObserver start is deferred by one `requestAnimationFrame`
47
+ * to skip the initial Svelte mount burst (which setup_observation
48
+ * already handles).
49
+ *
50
+ * ## Reactivity strategy
51
+ *
52
+ * A single debounced **version counter** drives both node gap
53
+ * positioning (`is_near_viewport`) and gap marker computation
54
+ * (`visible_child_indices`). A plain `Set<string>` tracks which root
55
+ * keys are near the viewport; `is_near_viewport` reads `version`
56
+ * (reactive dependency) then does a non-reactive `Set.has()` lookup.
57
+ *
58
+ * The 20ms debounce coalesces rapid IO callbacks into a single reactive
59
+ * version bump, preventing redundant O(N) `$derived` re-evaluations
60
+ * (~53ms total overlay latency ≈ 3 frames at 60fps). On initial mount,
61
+ * the `$effect` bumps `version` immediately (no debounce); Svelte
62
+ * flushes effects before the first browser paint so overlays appear
63
+ * with zero flash.
64
+ *
65
+ */
66
+
67
+ /**
68
+ * Overscan margin around the viewport (px). Nodes within this distance
69
+ * of the viewport get anchor positioning activated and gap markers
70
+ * computed. 500px provides enough buffer for fast scrolling (~8000px/s)
71
+ * while adding only 3 extra gap markers vs e.g. 250px — no measurable
72
+ * FPS impact at ≤1000 nodes.
73
+ *
74
+ * Tested values: 250 / 500 / 1000 / 2000. Higher values increase
75
+ * gap marker count and degrade scroll FPS at 6000+ nodes.
76
+ */
77
+ const DEFAULT_OVERSCAN_PX = 500;
78
+
79
+ const NODE_SELECTOR = '[data-type="node"][data-path]';
80
+
81
+ /**
82
+ * Cap on synchronous getBoundingClientRect calls during initial setup.
83
+ * Calling gBCR for every node in a 500+ node document would cause a
84
+ * significant layout thrash. By capping at 30, we get instant visibility
85
+ * data for above-the-fold content and let the IO handle the rest async.
86
+ */
87
+ const MAX_SYNC_CHECKS = 30;
88
+
89
+ /**
90
+ * Debounce before bumping the reactive `version` after IO visibility
91
+ * changes. 20ms coalesces rapid IO callbacks into one version bump,
92
+ * yielding consistent 60fps at ≤1000 nodes. Total perceived overlay
93
+ * latency is ~53ms (IO callback + 20ms + render ≈ 3 frames), well
94
+ * below the ~60ms threshold where delays become noticeable.
95
+ *
96
+ * Tested values: 0 / 5 / 10 / 20. Lower values (0–10) cause FPS
97
+ * drops at 1000 nodes (37–55fps avg vs 60fps with 20ms).
98
+ */
99
+ const VERSION_DEBOUNCE_MS = 20;
100
+
101
+ /**
102
+ * Split "root.prop.0" into { array_path: "root.prop", child_index: 0 }.
103
+ * @param {string} path
104
+ * @returns {{ array_path: string, child_index: number } | null}
105
+ */
106
+ function parse_node_path(path) {
107
+ const dot = path.lastIndexOf('.');
108
+ if (dot < 0) return null;
109
+ const child_index = parseInt(path.slice(dot + 1), 10);
110
+ if (Number.isNaN(child_index)) return null;
111
+ return { array_path: path.slice(0, dot), child_index };
112
+ }
113
+
114
+ /**
115
+ * Root-level array children have paths with exactly 2 dots
116
+ * (e.g. "page_1.body.0"). Deeper nodes (e.g. "page_1.body.5.buttons.0")
117
+ * have 4+ dots and are skipped — their visibility is inferred from
118
+ * their root ancestor being visible.
119
+ *
120
+ * This is the key insight that makes the IO budget manageable: in a
121
+ * 500-node document with 2 levels of nesting, only ~100 root nodes
122
+ * are observed rather than all 500.
123
+ *
124
+ * @param {string} path_str
125
+ * @returns {boolean}
126
+ */
127
+ function is_root_array_child(path_str) {
128
+ let dots = 0;
129
+ for (let i = 0; i < path_str.length; i++) {
130
+ if (path_str[i] === '.' && ++dots > 2) return false;
131
+ }
132
+ return dots === 2;
133
+ }
134
+
135
+ /**
136
+ * @param {object} svedit
137
+ * @returns {{
138
+ * readonly visible_child_indices: Map<string, Set<number>>,
139
+ * readonly doc_snapshot: object | null,
140
+ * is_near_viewport: (path: Array<string|number>) => boolean
141
+ * }}
142
+ */
143
+ function create_visibility_culler(svedit) {
144
+
145
+ /** @type {Map<string, Set<number>>} */
146
+ const index_map = new Map(); // eslint-disable-line svelte/prefer-svelte-reactivity
147
+ /**
148
+ * Read-optimized index for is_near_viewport (called 1000× per version
149
+ * bump). A single Set.has(string) is faster than Map.get + Set.has.
150
+ * @type {Set<string>}
151
+ */
152
+ const visible_roots = new Set(); // eslint-disable-line svelte/prefer-svelte-reactivity
153
+
154
+ let _ver = 0;
155
+ let version = $state.raw(0);
156
+ let doc_snapshot = $state.raw(null);
157
+ let version_timer = 0;
158
+
159
+ /**
160
+ * Shared IntersectionObserver callback. Mutates index_map and
161
+ * visible_roots in place, then debounces the reactive version bump.
162
+ *
163
+ * @param {IntersectionObserverEntry[]} entries
164
+ */
165
+ function process_entries(entries) {
166
+ let did_change = false;
167
+ for (const entry of entries) {
168
+ const el = /** @type {HTMLElement} */ (entry.target);
169
+ const path = el.dataset.path;
170
+ if (!path) continue;
171
+ const parsed = parse_node_path(path);
172
+ if (!parsed) continue;
173
+
174
+ if (entry.isIntersecting) {
175
+ visible_roots.add(path);
176
+ let set = index_map.get(parsed.array_path);
177
+ if (!set) index_map.set(parsed.array_path, set = new Set()); // eslint-disable-line svelte/prefer-svelte-reactivity
178
+ if (!set.has(parsed.child_index)) {
179
+ set.add(parsed.child_index);
180
+ did_change = true;
181
+ }
182
+ } else {
183
+ visible_roots.delete(path);
184
+ const set = index_map.get(parsed.array_path);
185
+ if (set && set.delete(parsed.child_index)) {
186
+ did_change = true;
187
+ if (set.size === 0) index_map.delete(parsed.array_path);
188
+ }
189
+ }
190
+ }
191
+ if (did_change) {
192
+ clearTimeout(version_timer);
193
+ version_timer = window.setTimeout(() => {
194
+ version = ++_ver;
195
+ doc_snapshot = svedit.session.doc;
196
+ }, VERSION_DEBOUNCE_MS);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Query existing DOM nodes, observe root-level ones with the IO,
202
+ * and sync-check the first batch for instant initial visibility.
203
+ *
204
+ * The sync check (getBoundingClientRect) ensures above-the-fold nodes
205
+ * are marked visible immediately, without waiting for the async IO
206
+ * callback. Capped at MAX_SYNC_CHECKS to avoid layout thrashing in
207
+ * large documents — the IO handles the rest asynchronously.
208
+ *
209
+ * @param {IntersectionObserver} io
210
+ */
211
+ function setup_observation(io) {
212
+ const vh = window.innerHeight;
213
+ const vw = window.innerWidth;
214
+ let sync_checked = 0;
215
+
216
+ for (const el of document.querySelectorAll(NODE_SELECTOR)) {
217
+ const node_el = /** @type {HTMLElement} */ (el);
218
+ const path = node_el.dataset.path;
219
+ if (!path || !is_root_array_child(path)) continue;
220
+
221
+ io.observe(node_el);
222
+
223
+ if (sync_checked < MAX_SYNC_CHECKS) {
224
+ const parsed = parse_node_path(path);
225
+ if (parsed) {
226
+ const rect = node_el.getBoundingClientRect();
227
+ const visible =
228
+ rect.bottom >= -DEFAULT_OVERSCAN_PX &&
229
+ rect.top <= vh + DEFAULT_OVERSCAN_PX &&
230
+ rect.right >= -DEFAULT_OVERSCAN_PX &&
231
+ rect.left <= vw + DEFAULT_OVERSCAN_PX;
232
+ if (visible) {
233
+ visible_roots.add(path);
234
+ let set = index_map.get(parsed.array_path);
235
+ if (!set) index_map.set(parsed.array_path, set = new Set()); // eslint-disable-line svelte/prefer-svelte-reactivity
236
+ set.add(parsed.child_index);
237
+ }
238
+ }
239
+ sync_checked++;
240
+ }
241
+ }
242
+ }
243
+
244
+ // Uses $effect (not $effect.pre) because setup_observation needs the
245
+ // DOM to be in the live document (querySelectorAll). Svelte flushes
246
+ // effects before the first browser paint, so the immediate version
247
+ // bump here makes overlays appear with zero flash.
248
+ //
249
+ // Depends only on `svedit.editable`, NOT `svedit.session.doc`. The IO
250
+ // stays alive across document mutations. A MutationObserver registers
251
+ // newly added DOM nodes; removals are handled by the IO itself.
252
+ $effect(() => {
253
+ if (!svedit.editable) {
254
+ index_map.clear();
255
+ version = ++_ver;
256
+ doc_snapshot = null;
257
+ return;
258
+ }
259
+
260
+ index_map.clear();
261
+
262
+ const io = new IntersectionObserver(process_entries, {
263
+ rootMargin: `${DEFAULT_OVERSCAN_PX}px`
264
+ });
265
+
266
+ setup_observation(io);
267
+ // Sync doc_snapshot here — the $effect.pre bridge (line ~309) won't
268
+ // re-run because doc didn't change, only editable did.
269
+ doc_snapshot = svedit.session.doc;
270
+ version = ++_ver;
271
+
272
+ const canvas = svedit.canvas_el || document.querySelector('.svedit-canvas');
273
+ /** @type {MutationObserver | null} */
274
+ let mo = null;
275
+
276
+ // The MO start is deferred by one rAF. During initial Svelte mount,
277
+ // hundreds of DOM mutations fire as components render. Processing
278
+ // these is wasted work since setup_observation() already handled
279
+ // the initial scan. Deferring lets the mount complete first.
280
+ let deferred_raf = 0;
281
+
282
+ if (canvas) {
283
+ mo = new MutationObserver((mutations) => {
284
+ for (const m of mutations) {
285
+ for (const added of m.addedNodes) {
286
+ if (added.nodeType !== Node.ELEMENT_NODE) continue;
287
+ const el = /** @type {HTMLElement} */ (added);
288
+ if (el.matches?.(NODE_SELECTOR) && is_root_array_child(el.dataset.path)) {
289
+ io.observe(el);
290
+ }
291
+ for (const child of el.querySelectorAll?.(NODE_SELECTOR) ?? []) {
292
+ const child_el = /** @type {HTMLElement} */ (child);
293
+ if (is_root_array_child(child_el.dataset.path)) {
294
+ io.observe(child_el);
295
+ }
296
+ }
297
+ }
298
+ }
299
+ });
300
+ deferred_raf = requestAnimationFrame(() => {
301
+ mo.observe(canvas, { childList: true, subtree: true });
302
+ });
303
+ }
304
+
305
+ return () => {
306
+ clearTimeout(version_timer);
307
+ cancelAnimationFrame(deferred_raf);
308
+ io.disconnect();
309
+ mo?.disconnect();
310
+ index_map.clear();
311
+ visible_roots.clear();
312
+ };
313
+ });
314
+
315
+ // Reactive bridge: ensures gap-building re-runs on structural doc
316
+ // changes (node add/delete) even when the IO doesn't fire — e.g.
317
+ // removed DOM elements silently leave the IntersectionObserver
318
+ // without an exit callback, so version never bumps for deletions.
319
+ $effect.pre(() => {
320
+ doc_snapshot = svedit.session.doc;
321
+ });
322
+
323
+ /**
324
+ * Viewport proximity check driving lazy anchor positioning.
325
+ * NodeGap elements are always in the DOM; this determines whether
326
+ * the expensive CSS anchor positioning is activated (positioned
327
+ * prop). Reads `version` (debounced 20ms) then does a non-reactive
328
+ * Set.has() lookup. On initial mount, version is bumped immediately
329
+ * (no debounce) so overlays appear in the first paint.
330
+ *
331
+ * @param {Array<string|number>} path
332
+ * @returns {boolean}
333
+ */
334
+ function is_near_viewport(path) {
335
+ if (path.length < 3) return true;
336
+ void version;
337
+ // Content under non-tracked arrays is always visible — only the main body array is large enough to cull.
338
+ const parent_array = `${path[0]}.${path[1]}`;
339
+ if (!index_map.has(parent_array)) return true;
340
+ const root_key = `${path[0]}.${path[1]}.${path[2]}`;
341
+ return visible_roots.has(root_key);
342
+ }
343
+
344
+ return {
345
+ get visible_child_indices() { version; return index_map; },
346
+ get doc_snapshot() { return doc_snapshot; },
347
+ is_near_viewport
348
+ };
349
+ }
350
+
351
+ /* --------------------------------------------------------------------- */
352
+ /* Gap computation */
353
+ /* --------------------------------------------------------------------- */
354
+
355
+ /**
356
+ * @param {object} svedit - Svedit context (session, editable, doc, schema, etc.)
357
+ */
358
+ export function create_gap_computation(svedit) {
359
+ const culler = create_visibility_culler(svedit);
360
+
361
+ svedit.is_near_viewport = culler.is_near_viewport;
362
+
363
+ let caret_gap_key = $derived.by(() => {
364
+ const s = svedit.session.selection;
365
+ if (s?.type !== 'node' || s.anchor_offset !== s.focus_offset) return null;
366
+ return `${s.path.join('.')}-gap-${s.anchor_offset}`;
367
+ });
368
+
369
+ /**
370
+ * Per-path reactive gap data. Each NodeGapMarkers instance
371
+ * subscribes to its own PathGapData signal, so when gaps change
372
+ * only the ~10-15 affected paths re-render — O(K) not O(M=1200).
373
+ */
374
+ class PathGapData {
375
+ gaps = $state.raw([]);
376
+ }
377
+
378
+ /** @type {Map<string, PathGapData>} */
379
+ const path_gap_signals = new Map(); // eslint-disable-line svelte/prefer-svelte-reactivity
380
+
381
+ /**
382
+ * @param {string} path_str
383
+ * @returns {PathGapData}
384
+ */
385
+ function get_or_create_gap_signal(path_str) {
386
+ let sig = path_gap_signals.get(path_str);
387
+ if (!sig) {
388
+ sig = new PathGapData();
389
+ path_gap_signals.set(path_str, sig);
390
+ }
391
+ return sig;
392
+ }
393
+
394
+ // Distribute gap data to per-path signals. Runs before DOM updates
395
+ // so NodeGapMarkers sees fresh data in the same render pass.
396
+ $effect.pre(() => {
397
+ const new_gaps = build_all_gaps();
398
+ const seen = new Set(); // eslint-disable-line svelte/prefer-svelte-reactivity
399
+ for (const [path_str, gaps] of new_gaps) {
400
+ seen.add(path_str);
401
+ get_or_create_gap_signal(path_str).gaps = gaps;
402
+ }
403
+ for (const [path_str, sig] of path_gap_signals) {
404
+ if (!seen.has(path_str) && sig.gaps.length > 0) {
405
+ sig.gaps = [];
406
+ }
407
+ }
408
+ });
409
+
410
+ svedit.insertion_gap_data = {
411
+ get_gaps: get_or_create_gap_signal,
412
+ get caret_gap_key() { return caret_gap_key; }
413
+ };
414
+
415
+ // -----------------------------------------------------------------------------
416
+ // gap building
417
+ // -----------------------------------------------------------------------------
418
+
419
+ /**
420
+ * @typedef {{
421
+ * key: string,
422
+ * path: Array<string|number>,
423
+ * offset: number,
424
+ * type: string,
425
+ * vars: string,
426
+ * is_first: boolean,
427
+ * is_last: boolean,
428
+ * has_pair: boolean
429
+ * }} gap_t
430
+ */
431
+
432
+ /**
433
+ * @returns {Map<string, Array<gap_t>>}
434
+ */
435
+ function build_all_gaps() {
436
+ if (!svedit.editable) return new Map();
437
+
438
+ /** @type {Map<string, Array<gap_t>>} */
439
+ const by_path = new Map(); // eslint-disable-line svelte/prefer-svelte-reactivity
440
+
441
+ // Wait until the culler's visibility snapshot matches the current doc.
442
+ // During the brief gap between a doc mutation and the next IO callback,
443
+ // the snapshot is stale — return empty to avoid computing gaps against
444
+ // outdated visibility data. The IO fires within one frame.
445
+ if (culler.doc_snapshot !== svedit.session.doc) return by_path;
446
+
447
+ // Build culled gaps for root-level arrays (only near-viewport children)
448
+ for (const [path_str, indices] of culler.visible_child_indices) {
449
+ const gaps = build_array_gaps_culled(path_str, indices);
450
+ if (gaps.length > 0) by_path.set(path_str, gaps);
451
+
452
+ // Walk visible root nodes to find nested node_arrays (e.g.
453
+ // buttons inside a story) and emit full gaps for them.
454
+ for (const child_idx of indices) {
455
+ collect_nested_array_gaps(path_str, child_idx, by_path);
456
+ }
457
+ }
458
+
459
+ // Walk the document root's `node` type properties (e.g. page.nav,
460
+ // page.footer) which aren't children of any node_array and thus
461
+ // aren't reached by the culler's visible_child_indices traversal.
462
+ const doc_id = svedit.session.document_id;
463
+ const doc_node = svedit.session.doc.nodes[doc_id];
464
+ if (doc_node) {
465
+ const doc_type_def = svedit.session.schema[doc_node.type];
466
+ if (doc_type_def?.properties) {
467
+ for (const [prop_name, prop_def] of Object.entries(doc_type_def.properties)) {
468
+ if (prop_def.type === 'node' && doc_node[prop_name]) {
469
+ collect_node_gaps(`${doc_id}.${prop_name}`, doc_node[prop_name], by_path);
470
+ }
471
+ }
472
+ }
473
+ }
474
+
475
+ return by_path;
476
+ }
477
+
478
+ /**
479
+ * Walk a visible node's schema to find nested node_arrays and add
480
+ * full (unculled) gaps for them. Recurses into node_array children
481
+ * and single node references (e.g. page.nav, page.footer).
482
+ * @param {string} array_path_str
483
+ * @param {number} child_index
484
+ * @param {Map<string, Array<gap_t>>} by_path
485
+ */
486
+ function collect_nested_array_gaps(array_path_str, child_index, by_path) {
487
+ const array_path = array_path_str.split('.');
488
+ const node_ids = svedit.session.get(array_path);
489
+ if (!Array.isArray(node_ids) || child_index >= node_ids.length) return;
490
+
491
+ collect_node_gaps(`${array_path_str}.${child_index}`, node_ids[child_index], by_path);
492
+ }
493
+
494
+ /**
495
+ * Walk a single node's properties for node_arrays and node refs.
496
+ * @param {string} node_path_str
497
+ * @param {string} node_id
498
+ * @param {Map<string, Array<gap_t>>} by_path
499
+ */
500
+ function collect_node_gaps(node_path_str, node_id, by_path) {
501
+ const node = svedit.session.doc.nodes[node_id];
502
+ if (!node) return;
503
+
504
+ const type_def = svedit.session.schema[node.type];
505
+ if (!type_def?.properties) return;
506
+
507
+ for (const [prop_name, prop_def] of Object.entries(type_def.properties)) {
508
+ const prop_path_str = `${node_path_str}.${prop_name}`;
509
+
510
+ if (prop_def.type === 'node') {
511
+ const ref_id = node[prop_name];
512
+ if (ref_id) collect_node_gaps(prop_path_str, ref_id, by_path);
513
+ } else if (prop_def.type === 'node_array') {
514
+ if (by_path.has(prop_path_str)) continue;
515
+
516
+ const ids = node[prop_name] || [];
517
+ const gaps = emit_gaps(prop_path_str, prop_path_str.split('.'), ids.length, () => true);
518
+ if (gaps.length > 0) by_path.set(prop_path_str, gaps);
519
+
520
+ for (let i = 0; i < ids.length; i++) {
521
+ collect_node_gaps(`${prop_path_str}.${i}`, ids[i], by_path);
522
+ }
523
+ }
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Build gaps for a node_array using visibility filter (culled path).
529
+ * @param {string} array_path_str
530
+ * @param {Set<number>} visible_indices
531
+ * @returns {Array<gap_t>}
532
+ */
533
+ function build_array_gaps_culled(array_path_str, visible_indices) {
534
+ const array_path = array_path_str.split('.');
535
+ try {
536
+ const info = svedit.session.inspect(array_path);
537
+ if (info.kind !== 'property' || info.type !== 'node_array') return [];
538
+ } catch { return []; }
539
+ const node_ids = svedit.session.get(array_path);
540
+ if (!Array.isArray(node_ids)) return [];
541
+
542
+ return emit_gaps(array_path_str, array_path, node_ids.length, (offset, count) => {
543
+ const prev_visible = offset > 0 && visible_indices.has(offset - 1);
544
+ const next_visible = offset < count && visible_indices.has(offset);
545
+ return prev_visible || next_visible;
546
+ });
547
+ }
548
+
549
+ /**
550
+ * Core gap emitter.
551
+ * @param {string} array_path_str
552
+ * @param {Array<string|number>} array_path
553
+ * @param {number} count
554
+ * @param {(offset: number, count: number) => boolean} is_visible
555
+ * @returns {Array<gap_t>}
556
+ */
557
+ function emit_gaps(array_path_str, array_path, count, is_visible) {
558
+ const anchor_prefix = `--${array_path.join('-')}`;
559
+ const g_prefix = `--g-${array_path.join('-')}`;
560
+ const container_var = `;--_c:${anchor_prefix}`;
561
+ const has_pair = count >= 2;
562
+ const pair_vars = has_pair
563
+ ? `;--_f:${anchor_prefix}-0;--_s:${anchor_prefix}-1`
564
+ : '';
565
+
566
+ /** @type {Array<gap_t>} */
567
+ const gaps = [];
568
+
569
+ if (count === 0) {
570
+ gaps.push({
571
+ key: `${array_path_str}-gap-0`,
572
+ path: array_path,
573
+ offset: 0,
574
+ type: 'gap-empty',
575
+ vars: `--_ct:${g_prefix}-0-gap-before;--_a:${anchor_prefix}-0${container_var}`,
576
+ is_first: true,
577
+ is_last: true,
578
+ has_pair: false
579
+ });
580
+ return gaps;
581
+ }
582
+
583
+ for (let offset = 0; offset <= count; offset++) {
584
+ if (!is_visible(offset, count)) continue;
585
+
586
+ const is_first = offset === 0;
587
+ const is_last = offset === count;
588
+ const g_anchor = offset === 0
589
+ ? `${g_prefix}-0-gap-before`
590
+ : `${g_prefix}-${offset - 1}-gap-after`;
591
+
592
+ let type, vars;
593
+
594
+ if (is_first || is_last) {
595
+ type = 'gap-edge';
596
+ const adjacent = is_first
597
+ ? `${anchor_prefix}-0`
598
+ : `${anchor_prefix}-${count - 1}`;
599
+ vars = `--_ct:${g_anchor};--_a:${adjacent}${container_var}${pair_vars}`;
600
+ } else {
601
+ type = 'gap-mid';
602
+ const p_anchor = `${anchor_prefix}-${offset - 1}`;
603
+ const n_anchor = `${anchor_prefix}-${offset}`;
604
+ vars = `--_ct:${g_anchor};--_p:${p_anchor};--_n:${n_anchor}${container_var}${pair_vars}`;
605
+ }
606
+
607
+ gaps.push({
608
+ key: `${array_path_str}-gap-${offset}`,
609
+ path: array_path,
610
+ offset,
611
+ type,
612
+ vars,
613
+ is_first,
614
+ is_last,
615
+ has_pair
616
+ });
617
+ }
618
+
619
+ return gaps;
620
+ }
621
+ }
@@ -0,0 +1,8 @@
1
+ @layer svedit-defaults {
2
+ :root {
3
+ --svedit-brand: oklch(60% 0.22 283);
4
+ --svedit-editing-stroke: var(--svedit-brand);
5
+ --svedit-editing-fill: oklch(from var(--svedit-brand) l c h / 0.1);
6
+ --svedit-canvas-stroke: color-mix(in oklch, currentColor 25%, transparent);
7
+ }
8
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"transforms.svelte.d.ts","sourceRoot":"","sources":["../src/lib/transforms.svelte.js"],"names":[],"mappings":"AAGA,kDAuDC;AAED,iDAqEC;AAED,sDAuBC"}
1
+ {"version":3,"file":"transforms.svelte.d.ts","sourceRoot":"","sources":["../src/lib/transforms.svelte.js"],"names":[],"mappings":"AAGA,kDAqDC;AAED,iDAoEC;AAED,sDAuBC"}
@@ -14,13 +14,11 @@ export function break_text_node(tr) {
14
14
  const node = tr.get(selection.path.slice(0, -1));
15
15
  if (tr.kind(node) !== 'text') return false;
16
16
  const is_inside_node_array = tr.inspect(selection.path.slice(0, -2))?.type === 'node_array';
17
- // console.log('is_inside_node_array', is_inside_node_array);
18
17
  if (!is_inside_node_array) return false; // Do nothing if we're not inside a node_array
19
18
  const node_array_prop = selection.path.at(-3);
20
- // console.log('node_array_prop', node_array_prop);
21
19
  // Get the node that owns the node_array property (e.g. a page.body)
22
20
  const node_array_node = tr.get(selection.path.slice(0, -3));
23
- // console.log('node_array_node', $state.snapshot(node_array_node));
21
+
24
22
 
25
23
  // Delete selection unless collapsed
26
24
  if (selection.anchor_offset !== selection.focus_offset) {
@@ -40,7 +38,7 @@ export function break_text_node(tr) {
40
38
  focus_offset: parseInt(tr.selection.path.at(-2), 10) + 1
41
39
  };
42
40
 
43
- // TODO: Only use default_node_type when cursor is at the end of
41
+ // TODO: Only use default_node_type when caret is at the end of
44
42
  const node_array_property_definition =
45
43
  tr.schema[node_array_node.type].properties[node_array_prop];
46
44
  const target_node_type = get_default_node_type(node_array_property_definition);
@@ -67,7 +65,6 @@ export function join_text_node(tr) {
67
65
  const node = tr.get(selection.path.slice(0, -1));
68
66
  if (tr.kind(node) !== 'text') return false;
69
67
  const is_inside_node_array = tr.inspect(selection.path.slice(0, -2))?.type === 'node_array';
70
- // console.log('is_inside_node_array', is_inside_node_array);
71
68
  if (!is_inside_node_array) return false; // Do nothing if we're not inside a node_array
72
69
 
73
70
  const node_index = parseInt(tr.selection.path.at(-2), 10);
@@ -103,8 +100,8 @@ export function join_text_node(tr) {
103
100
  const previous_text_path = [...tr.selection.path.slice(0, -2), node_index - 1];
104
101
  const joined_text = join_annotated_text(predecessor_node.content, node.content);
105
102
 
106
- // Calculate cursor position based on original predecessor content length
107
- const cursor_position = get_char_length(predecessor_node.content.text);
103
+ // Calculate caret position based on original predecessor content length
104
+ const caret_position = get_char_length(predecessor_node.content.text);
108
105
 
109
106
  // First set the joined content on the predecessor node (preserves annotations)
110
107
  tr.set([predecessor_node.id, 'content'], joined_text);
@@ -119,12 +116,12 @@ export function join_text_node(tr) {
119
116
 
120
117
  tr.delete_selection();
121
118
 
122
- // Finally set the cursor position at the join point using the pre-calculated position
119
+ // Finally set the caret position at the join point using the pre-calculated position
123
120
  tr.set_selection({
124
121
  type: 'text',
125
122
  path: [...previous_text_path, 'content'],
126
- anchor_offset: cursor_position,
127
- focus_offset: cursor_position
123
+ anchor_offset: caret_position,
124
+ focus_offset: caret_position
128
125
  });
129
126
  return true;
130
127
  }