svedit 0.12.0 → 0.13.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 (86) hide show
  1. package/README.md +33 -21
  2. package/dist/Command.svelte.d.ts +38 -30
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +271 -326
  5. package/dist/CustomProperty.svelte +3 -5
  6. package/dist/CustomProperty.svelte.d.ts +3 -6
  7. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  8. package/dist/KeyMapper.svelte.d.ts +19 -6
  9. package/dist/KeyMapper.svelte.d.ts.map +1 -1
  10. package/dist/KeyMapper.svelte.js +95 -112
  11. package/dist/Node.svelte +16 -13
  12. package/dist/Node.svelte.d.ts +3 -5
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +39 -22
  15. package/dist/NodeArrayProperty.svelte.d.ts +3 -5
  16. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  17. package/dist/NodeCaret.svelte +1 -1
  18. package/dist/NodeCaret.svelte.d.ts +6 -14
  19. package/dist/NodeCaret.svelte.d.ts.map +1 -1
  20. package/dist/NodeGap.svelte +12 -6
  21. package/dist/NodeGap.svelte.d.ts +7 -14
  22. package/dist/NodeGap.svelte.d.ts.map +1 -1
  23. package/dist/NodeGapMarkers.svelte +19 -10
  24. package/dist/NodeGapMarkers.svelte.d.ts +6 -11
  25. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
  26. package/dist/NodeSelectionMarkers.svelte +20 -6
  27. package/dist/NodeSelectionMarkers.svelte.d.ts +2 -5
  28. package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
  29. package/dist/Session.svelte.d.ts +81 -129
  30. package/dist/Session.svelte.d.ts.map +1 -1
  31. package/dist/Session.svelte.js +542 -648
  32. package/dist/Svedit.svelte +144 -138
  33. package/dist/Svedit.svelte.d.ts +30 -10
  34. package/dist/Svedit.svelte.d.ts.map +1 -1
  35. package/dist/TextProperty.svelte +32 -18
  36. package/dist/TextProperty.svelte.d.ts +3 -5
  37. package/dist/TextProperty.svelte.d.ts.map +1 -1
  38. package/dist/Transaction.svelte.d.ts +64 -127
  39. package/dist/Transaction.svelte.d.ts.map +1 -1
  40. package/dist/Transaction.svelte.js +839 -1001
  41. package/dist/UnknownNode.svelte +4 -3
  42. package/dist/UnknownNode.svelte.d.ts +5 -9
  43. package/dist/UnknownNode.svelte.d.ts.map +1 -1
  44. package/dist/doc_utils.d.ts +67 -155
  45. package/dist/doc_utils.d.ts.map +1 -1
  46. package/dist/doc_utils.js +619 -863
  47. package/dist/index.d.ts +20 -17
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -17
  50. package/dist/node_visibility.svelte.d.ts +110 -9
  51. package/dist/node_visibility.svelte.d.ts.map +1 -1
  52. package/dist/node_visibility.svelte.js +473 -544
  53. package/dist/paste_utils.d.ts +11 -44
  54. package/dist/paste_utils.d.ts.map +1 -1
  55. package/dist/paste_utils.js +111 -152
  56. package/dist/transforms.svelte.d.ts +9 -7
  57. package/dist/transforms.svelte.d.ts.map +1 -1
  58. package/dist/transforms.svelte.js +125 -150
  59. package/dist/types.d.ts +277 -309
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +1 -0
  62. package/dist/utils.d.ts +40 -119
  63. package/dist/utils.d.ts.map +1 -1
  64. package/dist/utils.js +346 -473
  65. package/package.json +69 -70
  66. package/src/lib/{Command.svelte.js → Command.svelte.ts} +29 -17
  67. package/src/lib/CustomProperty.svelte +3 -5
  68. package/src/lib/{KeyMapper.svelte.js → KeyMapper.svelte.ts} +28 -11
  69. package/src/lib/Node.svelte +16 -13
  70. package/src/lib/NodeArrayProperty.svelte +39 -22
  71. package/src/lib/NodeCaret.svelte +1 -1
  72. package/src/lib/NodeGap.svelte +12 -6
  73. package/src/lib/NodeGapMarkers.svelte +19 -10
  74. package/src/lib/NodeSelectionMarkers.svelte +20 -6
  75. package/src/lib/{Session.svelte.js → Session.svelte.ts} +130 -127
  76. package/src/lib/Svedit.svelte +144 -138
  77. package/src/lib/TextProperty.svelte +32 -18
  78. package/src/lib/{Transaction.svelte.js → Transaction.svelte.ts} +139 -143
  79. package/src/lib/UnknownNode.svelte +4 -3
  80. package/src/lib/{doc_utils.js → doc_utils.ts} +247 -206
  81. package/src/lib/{index.js → index.ts} +47 -0
  82. package/src/lib/{node_visibility.svelte.js → node_visibility.svelte.ts} +68 -92
  83. package/src/lib/{paste_utils.js → paste_utils.ts} +47 -58
  84. package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +39 -21
  85. package/src/lib/{types.d.ts → types.ts} +143 -66
  86. package/src/lib/{utils.js → utils.ts} +110 -129
@@ -84,11 +84,9 @@
84
84
  *
85
85
  * @module
86
86
  */
87
-
88
87
  import { SvelteMap, SvelteSet } from 'svelte/reactivity';
89
88
  import { tick, untrack } from 'svelte';
90
89
  import { PATH_SEPARATOR } from './utils.js';
91
-
92
90
  /**
93
91
  * Overscan margin around the viewport (px). Nodes within this distance
94
92
  * of the viewport get anchor positioning activated and gap markers
@@ -96,7 +94,6 @@ import { PATH_SEPARATOR } from './utils.js';
96
94
  * a measurable FPS hit.
97
95
  */
98
96
  const OVERSCAN_PX = 500;
99
-
100
97
  /**
101
98
  * Distance (in CSS pixels) the leading/trailing edge of an edge node is
102
99
  * allowed to overshoot the array container's matching edge while still
@@ -105,7 +102,6 @@ const OVERSCAN_PX = 500;
105
102
  * jitter from scroll/animation doesn't cause the edge gap to flicker.
106
103
  */
107
104
  const EDGE_TOLERANCE_PX = 10;
108
-
109
105
  /**
110
106
  * Single source of truth for "which node DOM elements are near or in
111
107
  * the viewport". Owns the IntersectionObservers and the reactive maps
@@ -113,449 +109,391 @@ const EDGE_TOLERANCE_PX = 10;
113
109
  * the track_node / track_array attachments.
114
110
  */
115
111
  class VisibilityRegistry {
116
- /**
117
- * Per-array set of near ("in overscan zone") child indices — the
118
- * single source of truth for node nearness. NodeGap reads via
119
- * `.has(index)`, NodeGapMarkers iterates the set for its array.
120
- * Stable references — once a SvelteSet is created for an array path
121
- * it survives re-renders so consumers stay subscribed.
122
- *
123
- * @type {Map<string, SvelteSet<number>>}
124
- */
125
- #array_indices = new Map();
126
-
127
- /**
128
- * Per-array edge-proximity state. `first` is true when the first
129
- * node's leading edge is within EDGE_TOLERANCE_PX of the array
130
- * container's leading edge; `last` is the symmetric trailing-edge
131
- * check on the last node. Recomputed on scroll, on array box
132
- * resize and after document changes because IntersectionObserver
133
- * only fires on intersection-ratio threshold crossings and would
134
- * otherwise miss horizontal-overflow scroll changes that don't
135
- * change the visibility ratio.
136
- *
137
- * NodeGap and NodeGapMarkers read this to gate edge gaps so they
138
- * only render when the edge node has actually reached the matching
139
- * end of its container.
140
- *
141
- * @type {SvelteMap<string, { first: boolean, last: boolean }>}
142
- */
143
- edge_map = new SvelteMap();
144
-
145
- /** @type {IntersectionObserver | null} */
146
- #io = null;
147
-
148
- /** @type {IntersectionObserver | null} */
149
- #view_io = null;
150
-
151
- /**
152
- * Re-syncs edge state when a registered array's box changes size
153
- * (window resize, sidebar toggle, responsive breakpoint, late-
154
- * loading content). Size changes can flip an array between fitting
155
- * and overflowing without firing a scroll event or a document
156
- * change, so neither of the other two triggers would catch them.
157
- * Writes are change-detected, so redundant firings are no-ops.
158
- *
159
- * @type {ResizeObserver | null}
160
- */
161
- #array_ro = null;
162
-
163
- /**
164
- * Registered node elements and the path each was registered under.
165
- * The attachment closure carries the path, so a stale data-path
166
- * attribute can never desync state.
167
- *
168
- * @type {Map<HTMLElement, string>}
169
- */
170
- #registered_paths = new Map();
171
-
172
- /**
173
- * Which element currently owns a node path. Guards unregistration
174
- * against keyed-update collisions (see module doc).
175
- *
176
- * @type {Map<string, HTMLElement>}
177
- */
178
- #path_owners = new Map();
179
-
180
- /** @type {Set<HTMLElement>} */
181
- #near_nodes = new Set();
182
-
183
- /** Registered node-array container elements. @type {Set<Element>} */
184
- #array_els = new Set();
185
-
186
- /** Which element currently owns an array path. @type {Map<string, Element>} */
187
- #array_owners = new Map();
188
-
189
- /**
190
- * When true, a second IO (rootMargin 0, actual viewport) toggles
191
- * `.in-view` / `.seen` / `.fully-in-view` / `.visible-top` /
192
- * `.visible-bottom` on node elements — useful for scrollytelling
193
- * and reveal animations.
194
- *
195
- * A separate observer is required because the overscan IO (rootMargin
196
- * 500px) fires its thresholds while nodes are still outside the real
197
- * viewport — by the time a node scrolls into view, both thresholds
198
- * have already been crossed and no further callback fires.
199
- *
200
- * Opt out via `session.config.view_classes = false` to skip the
201
- * second observer entirely.
202
- */
203
- view_classes = true;
204
-
205
- /**
206
- * When true, the overscan IO populates array_indices and NodeGap gates
207
- * `.positioned` by viewport proximity. When false, the overscan IO
208
- * is skipped and every registered node counts as near, so every gap
209
- * is `.positioned` and every marker renders.
210
- *
211
- * Disabling makes anchor positioning O(N) over every node in the
212
- * document and is intended for debugging only; large documents
213
- * (hundreds of nodes) can become unresponsive without the culling.
214
- *
215
- * Opt out via `session.config.visibility_culling = false`.
216
- */
217
- visibility_culling = true;
218
-
219
- start() {
220
- if (typeof window === 'undefined') return;
221
- // Overscan IO: threshold [0] is enough — #process_near only reads
222
- // entry.isIntersecting (a boolean) and writes are change-detected,
223
- // so extra firings at threshold[1] would be no-ops.
224
- if (this.visibility_culling && !this.#io) {
225
- this.#io = new IntersectionObserver((entries) => this.#process_near(entries), {
226
- rootMargin: `${OVERSCAN_PX}px`,
227
- threshold: [0]
228
- });
229
- }
230
- // View IO: threshold [0, 1] is load-bearing — #apply_view_classes
231
- // toggles .fully-in-view, which flips when the node crosses the
232
- // fully-visible boundary at ratio 1.
233
- if (this.view_classes && !this.#view_io) {
234
- this.#view_io = new IntersectionObserver((entries) => this.#process_view(entries), {
235
- threshold: [0, 1]
236
- });
237
- }
238
- if (!this.#array_ro) {
239
- this.#array_ro = new ResizeObserver((entries) => {
240
- for (const entry of entries) this.sync_edge_state(entry.target);
241
- });
242
- }
243
- }
244
-
245
- stop() {
246
- this.#io?.disconnect();
247
- this.#io = null;
248
- this.#view_io?.disconnect();
249
- this.#view_io = null;
250
- this.#array_ro?.disconnect();
251
- this.#array_ro = null;
252
- this.#registered_paths.clear();
253
- this.#path_owners.clear();
254
- this.#near_nodes.clear();
255
- this.#array_els.clear();
256
- this.#array_owners.clear();
257
- this.edge_map.clear();
258
- for (const set of this.#array_indices.values()) set.clear();
259
- }
260
-
261
- /**
262
- * Returns the (lazily created, stable) SvelteSet for an array path.
263
- * Callers should hold the returned reference for the lifetime of the
264
- * subscription — the same set is returned on subsequent calls so
265
- * Svelte's per-set tracking remains intact across re-evaluations.
266
- *
267
- * IMPORTANT: hold the returned set in its own $derived and read
268
- * membership from a different one (see NodeGap / NodeGapMarkers).
269
- * Svelte does not track dependencies on state created inside the
270
- * currently-evaluating reaction, so a consumer that lazily creates
271
- * the set and reads `.has()` in the same derived never subscribes
272
- * and never re-evaluates.
273
- *
274
- * @param {string} array_path_str
275
- * @returns {SvelteSet<number>}
276
- */
277
- get_array_indices(array_path_str) {
278
- let set = this.#array_indices.get(array_path_str);
279
- if (!set) {
280
- set = new SvelteSet();
281
- this.#array_indices.set(array_path_str, set);
282
- }
283
- return set;
284
- }
285
-
286
- /**
287
- * Attachment factory for node elements (Node.svelte and the
288
- * empty-array placeholder). Registers the element under `path`,
289
- * seeds its near / view-class state synchronously from one BCR, and
290
- * hands the element to the IntersectionObservers for subsequent
291
- * viewport transitions. Re-runs when the element is recreated or
292
- * its path changes; the teardown unregisters collision-safely.
293
- *
294
- * @param {string} path
295
- */
296
- track_node(path) {
297
- return (/** @type {HTMLElement} */ el) => {
298
- this.#register_node(el, path);
299
- return () => this.#unregister_node(el, path);
300
- };
301
- }
302
-
303
- /**
304
- * Attachment factory for node-array container elements. Registered
305
- * arrays get their edge state synced on mount, on scroll (via the
306
- * document scroll listener) and after document changes.
307
- *
308
- * @param {string} path
309
- */
310
- track_array(path) {
311
- return (/** @type {Element} */ el) => {
312
- this.start();
313
- this.#array_els.add(el);
314
- this.#array_owners.set(path, el);
315
- this.#array_ro?.observe(el);
316
- // Sync synchronously — the RO's initial callback fires a frame
317
- // later and would flash edge gaps un-positioned on mount.
318
- this.sync_edge_state(el);
319
- return () => {
320
- this.#array_els.delete(el);
321
- this.#array_ro?.unobserve(el);
322
- untrack(() => {
323
- if (this.#array_owners.get(path) === el) {
324
- this.#array_owners.delete(path);
325
- this.edge_map.delete(path);
326
- }
327
- });
328
- };
329
- };
330
- }
331
-
332
- /**
333
- * Whether an element is a registered node-array container of this
334
- * registry instance. Scopes the document-level scroll listener to
335
- * this editor without any DOM traversal.
336
- *
337
- * @param {Element} el
338
- */
339
- has_array(el) {
340
- return this.#array_els.has(el);
341
- }
342
-
343
- /**
344
- * Re-sync edge state for every registered array. Called after
345
- * document changes: content changes alter scroll extents without
346
- * recreating the array element, so the attachments can't see them.
347
- */
348
- sync_all_edge_states() {
349
- for (const el of this.#array_els) {
350
- if (el.isConnected) this.sync_edge_state(el);
351
- }
352
- }
353
-
354
- /**
355
- * @param {HTMLElement} el
356
- * @param {string} path
357
- */
358
- #register_node(el, path) {
359
- this.start();
360
- untrack(() => {
361
- this.#registered_paths.set(el, path);
362
- this.#path_owners.set(path, el);
363
- this.#io?.observe(el);
364
- this.#view_io?.observe(el);
365
-
366
- // Seed state synchronously so freshly mounted (or recreated)
367
- // elements don't wait a frame for the IO's initial callback —
368
- // that would flash gaps un-positioned. The IO owns all later
369
- // transitions.
370
- const needs_rect = this.visibility_culling || this.view_classes;
371
- const bcr = needs_rect ? el.getBoundingClientRect() : null;
372
- const vh = window.innerHeight;
373
- const vw = window.innerWidth;
374
- const is_near =
375
- !this.visibility_culling ||
376
- (bcr.bottom > -OVERSCAN_PX &&
377
- bcr.top < vh + OVERSCAN_PX &&
378
- bcr.right > -OVERSCAN_PX &&
379
- bcr.left < vw + OVERSCAN_PX);
380
- if (is_near) this.#add_near_node(el, path);
381
-
382
- if (this.view_classes) {
383
- const in_viewport = bcr.bottom > 0 && bcr.top < vh && bcr.right > 0 && bcr.left < vw;
384
- this.#apply_view_classes(el, in_viewport, bcr, vh);
385
- }
386
- });
387
- }
388
-
389
- /**
390
- * @param {HTMLElement} el
391
- * @param {string} path
392
- */
393
- #unregister_node(el, path) {
394
- this.#registered_paths.delete(el);
395
- this.#near_nodes.delete(el);
396
- this.#io?.unobserve(el);
397
- this.#view_io?.unobserve(el);
398
- untrack(() => {
399
- // Ownership guard: during keyed updates another element may
400
- // have already claimed this path — its state must survive.
401
- if (this.#path_owners.get(path) === el) {
402
- this.#path_owners.delete(path);
403
- const split = this.#split_path(path);
404
- if (split) this.#array_indices.get(split.array_path)?.delete(split.index);
405
- }
406
- });
407
- }
408
-
409
- /**
410
- * @param {HTMLElement} el
411
- * @param {string} path
412
- */
413
- #add_near_node(el, path) {
414
- this.#near_nodes.add(el);
415
- const split = this.#split_path(path);
416
- if (split) this.get_array_indices(split.array_path).add(split.index);
417
- }
418
-
419
- /**
420
- * @param {HTMLElement} el
421
- * @param {string} path
422
- */
423
- #remove_near_node(el, path) {
424
- this.#near_nodes.delete(el);
425
- if (this.#path_owners.get(path) !== el) return;
426
- const split = this.#split_path(path);
427
- if (split) this.#array_indices.get(split.array_path)?.delete(split.index);
428
- }
429
-
430
- /**
431
- * Update edge_map for an array from its scrollLeft/scrollWidth/
432
- * clientWidth (and the y-axis equivalents). Returns true when the
433
- * state actually changed. Uses only the array's own scroll metrics
434
- * — no BCRs, no per-node measurements, no querying children — so
435
- * each call is ~6 property reads on one element regardless of how
436
- * many nodes the array has.
437
- *
438
- * For a non-overflowing array (scrollWidth === clientWidth, etc.)
439
- * both checks pass, so first/last gaps stay visible as expected.
440
- * For an overflowing array, first is true only when scrolled to
441
- * the start and last only when scrolled to the end (within
442
- * EDGE_TOLERANCE_PX).
443
- *
444
- * Writes to edge_map are wrapped in untrack() so this can be
445
- * called transitively from $effect contexts without self-
446
- * invalidation.
447
- *
448
- * @param {Element} array_el
449
- * @returns {boolean}
450
- */
451
- sync_edge_state(array_el) {
452
- if (!array_el) return false;
453
- const path = /** @type {HTMLElement} */ (array_el).dataset.path;
454
- if (!path) return false;
455
-
456
- // Check computed overflow BEFORE touching scroll metrics. Reading
457
- // scrollLeft/scrollWidth forces a synchronous layout when the DOM is
458
- // dirty, while getComputedStyle only forces style recalc. Non-clipping
459
- // arrays — the common case (a plain column body) — resolve to
460
- // first=last=true without any scroll-metric read.
461
- const style = getComputedStyle(array_el);
462
- const clips_x = style.overflowX !== 'visible';
463
- const clips_y = style.overflowY !== 'visible';
464
-
465
- // Only use scroll metrics on axes where this array actually clips/scrolls.
466
- // Normal wrapping/grid node arrays often have harmless layout overflow
467
- // (scrollWidth > clientWidth) while overflow remains visible; treating
468
- // that as scroll position would incorrectly hide edge gaps.
469
- let first = true;
470
- let last = true;
471
- if (clips_x) {
472
- const sl = array_el.scrollLeft;
473
- first = sl <= EDGE_TOLERANCE_PX;
474
- last = sl + array_el.clientWidth >= array_el.scrollWidth - EDGE_TOLERANCE_PX;
475
- }
476
- if (clips_y) {
477
- const st = array_el.scrollTop;
478
- first = first && st <= EDGE_TOLERANCE_PX;
479
- last = last && st + array_el.clientHeight >= array_el.scrollHeight - EDGE_TOLERANCE_PX;
480
- }
481
-
482
- return untrack(() => {
483
- const prev = this.edge_map.get(path);
484
- if (prev && prev.first === first && prev.last === last) return false;
485
- this.edge_map.set(path, { first, last });
486
- return true;
487
- });
488
- }
489
-
490
- /**
491
- * @param {HTMLElement} el
492
- * @param {boolean} in_viewport
493
- * @param {DOMRectReadOnly} bcr
494
- * @param {number} vh
495
- */
496
- #apply_view_classes(el, in_viewport, bcr, vh) {
497
- const cl = el.classList;
498
- cl.toggle('in-view', in_viewport);
499
- if (in_viewport) {
500
- cl.add('seen');
501
- const top_clipped = bcr.top < -0.5;
502
- const bottom_clipped = bcr.bottom > vh + 0.5;
503
- cl.toggle('fully-in-view', !top_clipped && !bottom_clipped);
504
- cl.toggle('visible-top', !top_clipped && bottom_clipped);
505
- cl.toggle('visible-bottom', top_clipped && !bottom_clipped);
506
- } else {
507
- cl.remove('fully-in-view', 'visible-top', 'visible-bottom');
508
- }
509
- }
510
-
511
- /**
512
- * @param {string} path
513
- * @returns {{ array_path: string, index: number } | null}
514
- */
515
- #split_path(path) {
516
- const sep = path.lastIndexOf(PATH_SEPARATOR);
517
- if (sep < 0) return null;
518
- const index = parseInt(path.slice(sep + PATH_SEPARATOR.length), 10);
519
- if (Number.isNaN(index)) return null;
520
- return { array_path: path.slice(0, sep), index };
521
- }
522
-
523
- /**
524
- * Overscan IO callback. Manages the array_indices sets; NodeGap
525
- * reacts to the map writes and updates .positioned declaratively.
526
- *
527
- * @param {IntersectionObserverEntry[]} entries
528
- */
529
- #process_near(entries) {
530
- for (const entry of entries) {
531
- const el = /** @type {HTMLElement} */ (entry.target);
532
- const path = this.#registered_paths.get(el);
533
- if (!path || !el.isConnected) continue;
534
-
535
- const is_near = entry.isIntersecting;
536
- if (is_near === this.#near_nodes.has(el)) continue;
537
- if (is_near) this.#add_near_node(el, path);
538
- else this.#remove_near_node(el, path);
539
- }
540
- }
541
-
542
- /**
543
- * Viewport IO callback. Toggles view classes on node elements.
544
- * Runs on a separate observer with rootMargin 0 so thresholds
545
- * fire at actual viewport edges, not the overscan boundary.
546
- *
547
- * @param {IntersectionObserverEntry[]} entries
548
- */
549
- #process_view(entries) {
550
- const vh = window.innerHeight;
551
- for (const entry of entries) {
552
- const el = /** @type {HTMLElement} */ (entry.target);
553
- if (!this.#registered_paths.has(el) || !el.isConnected) continue;
554
- this.#apply_view_classes(el, entry.isIntersecting, entry.boundingClientRect, vh);
555
- }
556
- }
112
+ /**
113
+ * Per-array set of near ("in overscan zone") child indices — the
114
+ * single source of truth for node nearness. NodeGap reads via
115
+ * `.has(index)`, NodeGapMarkers iterates the set for its array.
116
+ * Stable references — once a SvelteSet is created for an array path
117
+ * it survives re-renders so consumers stay subscribed.
118
+ *
119
+ */
120
+ #array_indices = new Map();
121
+ /**
122
+ * Per-array edge-proximity state. `first` is true when the first
123
+ * node's leading edge is within EDGE_TOLERANCE_PX of the array
124
+ * container's leading edge; `last` is the symmetric trailing-edge
125
+ * check on the last node. Recomputed on scroll, on array box
126
+ * resize and after document changes because IntersectionObserver
127
+ * only fires on intersection-ratio threshold crossings and would
128
+ * otherwise miss horizontal-overflow scroll changes that don't
129
+ * change the visibility ratio.
130
+ *
131
+ * NodeGap and NodeGapMarkers read this to gate edge gaps so they
132
+ * only render when the edge node has actually reached the matching
133
+ * end of its container.
134
+ *
135
+ */
136
+ edge_map = new SvelteMap();
137
+ #io = null;
138
+ #view_io = null;
139
+ /**
140
+ * Re-syncs edge state when a registered array's box changes size
141
+ * (window resize, sidebar toggle, responsive breakpoint, late-
142
+ * loading content). Size changes can flip an array between fitting
143
+ * and overflowing without firing a scroll event or a document
144
+ * change, so neither of the other two triggers would catch them.
145
+ * Writes are change-detected, so redundant firings are no-ops.
146
+ *
147
+ */
148
+ #array_ro = null;
149
+ /**
150
+ * Registered node elements and the path each was registered under.
151
+ * The attachment closure carries the path, so a stale data-path
152
+ * attribute can never desync state.
153
+ *
154
+ */
155
+ #registered_paths = new Map();
156
+ /**
157
+ * Which element currently owns a node path. Guards unregistration
158
+ * against keyed-update collisions (see module doc).
159
+ *
160
+ */
161
+ #path_owners = new Map();
162
+ #near_nodes = new Set();
163
+ /** Registered node-array container elements. */
164
+ #array_els = new Set();
165
+ /** Which element currently owns an array path. */
166
+ #array_owners = new Map();
167
+ /**
168
+ * When true, a second IO (rootMargin 0, actual viewport) toggles
169
+ * `.in-view` / `.seen` / `.fully-in-view` / `.visible-top` /
170
+ * `.visible-bottom` on node elements — useful for scrollytelling
171
+ * and reveal animations.
172
+ *
173
+ * A separate observer is required because the overscan IO (rootMargin
174
+ * 500px) fires its thresholds while nodes are still outside the real
175
+ * viewport — by the time a node scrolls into view, both thresholds
176
+ * have already been crossed and no further callback fires.
177
+ *
178
+ * Opt out via `session.config.view_classes = false` to skip the
179
+ * second observer entirely.
180
+ */
181
+ view_classes = true;
182
+ /**
183
+ * When true, the overscan IO populates array_indices and NodeGap gates
184
+ * `.positioned` by viewport proximity. When false, the overscan IO
185
+ * is skipped and every registered node counts as near, so every gap
186
+ * is `.positioned` and every marker renders.
187
+ *
188
+ * Disabling makes anchor positioning O(N) over every node in the
189
+ * document and is intended for debugging only; large documents
190
+ * (hundreds of nodes) can become unresponsive without the culling.
191
+ *
192
+ * Opt out via `session.config.visibility_culling = false`.
193
+ */
194
+ visibility_culling = true;
195
+ start() {
196
+ if (typeof window === 'undefined')
197
+ return;
198
+ // Overscan IO: threshold [0] is enough — #process_near only reads
199
+ // entry.isIntersecting (a boolean) and writes are change-detected,
200
+ // so extra firings at threshold[1] would be no-ops.
201
+ if (this.visibility_culling && !this.#io) {
202
+ this.#io = new IntersectionObserver((entries) => this.#process_near(entries), {
203
+ rootMargin: `${OVERSCAN_PX}px`,
204
+ threshold: [0]
205
+ });
206
+ }
207
+ // View IO: threshold [0, 1] is load-bearing — #apply_view_classes
208
+ // toggles .fully-in-view, which flips when the node crosses the
209
+ // fully-visible boundary at ratio 1.
210
+ if (this.view_classes && !this.#view_io) {
211
+ this.#view_io = new IntersectionObserver((entries) => this.#process_view(entries), {
212
+ threshold: [0, 1]
213
+ });
214
+ }
215
+ if (!this.#array_ro) {
216
+ this.#array_ro = new ResizeObserver((entries) => {
217
+ for (const entry of entries)
218
+ this.sync_edge_state(entry.target);
219
+ });
220
+ }
221
+ }
222
+ stop() {
223
+ this.#io?.disconnect();
224
+ this.#io = null;
225
+ this.#view_io?.disconnect();
226
+ this.#view_io = null;
227
+ this.#array_ro?.disconnect();
228
+ this.#array_ro = null;
229
+ this.#registered_paths.clear();
230
+ this.#path_owners.clear();
231
+ this.#near_nodes.clear();
232
+ this.#array_els.clear();
233
+ this.#array_owners.clear();
234
+ this.edge_map.clear();
235
+ for (const set of this.#array_indices.values())
236
+ set.clear();
237
+ }
238
+ /**
239
+ * Returns the (lazily created, stable) SvelteSet for an array path.
240
+ * Callers should hold the returned reference for the lifetime of the
241
+ * subscription — the same set is returned on subsequent calls so
242
+ * Svelte's per-set tracking remains intact across re-evaluations.
243
+ *
244
+ * IMPORTANT: hold the returned set in its own $derived and read
245
+ * membership from a different one (see NodeGap / NodeGapMarkers).
246
+ * Svelte does not track dependencies on state created inside the
247
+ * currently-evaluating reaction, so a consumer that lazily creates
248
+ * the set and reads `.has()` in the same derived never subscribes
249
+ * and never re-evaluates.
250
+ *
251
+ */
252
+ get_array_indices(array_path_str) {
253
+ let set = this.#array_indices.get(array_path_str);
254
+ if (!set) {
255
+ set = new SvelteSet();
256
+ this.#array_indices.set(array_path_str, set);
257
+ }
258
+ return set;
259
+ }
260
+ /**
261
+ * Attachment factory for node elements (Node.svelte and the
262
+ * empty-array placeholder). Registers the element under `path`,
263
+ * seeds its near / view-class state synchronously from one BCR, and
264
+ * hands the element to the IntersectionObservers for subsequent
265
+ * viewport transitions. Re-runs when the element is recreated or
266
+ * its path changes; the teardown unregisters collision-safely.
267
+ *
268
+ */
269
+ track_node(path) {
270
+ return (el) => {
271
+ this.#register_node(el, path);
272
+ return () => this.#unregister_node(el, path);
273
+ };
274
+ }
275
+ /**
276
+ * Attachment factory for node-array container elements. Registered
277
+ * arrays get their edge state synced on mount, on scroll (via the
278
+ * document scroll listener) and after document changes.
279
+ *
280
+ */
281
+ track_array(path) {
282
+ return (el) => {
283
+ this.start();
284
+ this.#array_els.add(el);
285
+ this.#array_owners.set(path, el);
286
+ this.#array_ro?.observe(el);
287
+ // Sync synchronously — the RO's initial callback fires a frame
288
+ // later and would flash edge gaps un-positioned on mount.
289
+ this.sync_edge_state(el);
290
+ return () => {
291
+ this.#array_els.delete(el);
292
+ this.#array_ro?.unobserve(el);
293
+ untrack(() => {
294
+ if (this.#array_owners.get(path) === el) {
295
+ this.#array_owners.delete(path);
296
+ this.edge_map.delete(path);
297
+ }
298
+ });
299
+ };
300
+ };
301
+ }
302
+ /**
303
+ * Whether an element is a registered node-array container of this
304
+ * registry instance. Scopes the document-level scroll listener to
305
+ * this editor without any DOM traversal.
306
+ *
307
+ */
308
+ has_array(el) {
309
+ return this.#array_els.has(el);
310
+ }
311
+ /**
312
+ * Re-sync edge state for every registered array. Called after
313
+ * document changes: content changes alter scroll extents without
314
+ * recreating the array element, so the attachments can't see them.
315
+ */
316
+ sync_all_edge_states() {
317
+ for (const el of this.#array_els) {
318
+ if (el.isConnected)
319
+ this.sync_edge_state(el);
320
+ }
321
+ }
322
+ #register_node(el, path) {
323
+ this.start();
324
+ untrack(() => {
325
+ this.#registered_paths.set(el, path);
326
+ this.#path_owners.set(path, el);
327
+ this.#io?.observe(el);
328
+ this.#view_io?.observe(el);
329
+ // Seed state synchronously so freshly mounted (or recreated)
330
+ // elements don't wait a frame for the IO's initial callback —
331
+ // that would flash gaps un-positioned. The IO owns all later
332
+ // transitions.
333
+ const needs_rect = this.visibility_culling || this.view_classes;
334
+ const bcr = needs_rect ? el.getBoundingClientRect() : null;
335
+ const vh = window.innerHeight;
336
+ const vw = window.innerWidth;
337
+ const is_near = !this.visibility_culling ||
338
+ (bcr.bottom > -OVERSCAN_PX &&
339
+ bcr.top < vh + OVERSCAN_PX &&
340
+ bcr.right > -OVERSCAN_PX &&
341
+ bcr.left < vw + OVERSCAN_PX);
342
+ if (is_near)
343
+ this.#add_near_node(el, path);
344
+ if (this.view_classes) {
345
+ const in_viewport = bcr.bottom > 0 && bcr.top < vh && bcr.right > 0 && bcr.left < vw;
346
+ this.#apply_view_classes(el, in_viewport, bcr, vh);
347
+ }
348
+ });
349
+ }
350
+ #unregister_node(el, path) {
351
+ this.#registered_paths.delete(el);
352
+ this.#near_nodes.delete(el);
353
+ this.#io?.unobserve(el);
354
+ this.#view_io?.unobserve(el);
355
+ untrack(() => {
356
+ // Ownership guard: during keyed updates another element may
357
+ // have already claimed this path — its state must survive.
358
+ if (this.#path_owners.get(path) === el) {
359
+ this.#path_owners.delete(path);
360
+ const split = this.#split_path(path);
361
+ if (split)
362
+ this.#array_indices.get(split.array_path)?.delete(split.index);
363
+ }
364
+ });
365
+ }
366
+ #add_near_node(el, path) {
367
+ this.#near_nodes.add(el);
368
+ const split = this.#split_path(path);
369
+ if (split)
370
+ this.get_array_indices(split.array_path).add(split.index);
371
+ }
372
+ #remove_near_node(el, path) {
373
+ this.#near_nodes.delete(el);
374
+ if (this.#path_owners.get(path) !== el)
375
+ return;
376
+ const split = this.#split_path(path);
377
+ if (split)
378
+ this.#array_indices.get(split.array_path)?.delete(split.index);
379
+ }
380
+ /**
381
+ * Update edge_map for an array from its scrollLeft/scrollWidth/
382
+ * clientWidth (and the y-axis equivalents). Returns true when the
383
+ * state actually changed. Uses only the array's own scroll metrics
384
+ * — no BCRs, no per-node measurements, no querying children — so
385
+ * each call is ~6 property reads on one element regardless of how
386
+ * many nodes the array has.
387
+ *
388
+ * For a non-overflowing array (scrollWidth === clientWidth, etc.)
389
+ * both checks pass, so first/last gaps stay visible as expected.
390
+ * For an overflowing array, first is true only when scrolled to
391
+ * the start and last only when scrolled to the end (within
392
+ * EDGE_TOLERANCE_PX).
393
+ *
394
+ * Writes to edge_map are wrapped in untrack() so this can be
395
+ * called transitively from $effect contexts without self-
396
+ * invalidation.
397
+ *
398
+ */
399
+ sync_edge_state(array_el) {
400
+ if (!array_el)
401
+ return false;
402
+ const path = array_el.dataset.path;
403
+ if (!path)
404
+ return false;
405
+ // Check computed overflow BEFORE touching scroll metrics. Reading
406
+ // scrollLeft/scrollWidth forces a synchronous layout when the DOM is
407
+ // dirty, while getComputedStyle only forces style recalc. Non-clipping
408
+ // arrays — the common case (a plain column body) — resolve to
409
+ // first=last=true without any scroll-metric read.
410
+ const style = getComputedStyle(array_el);
411
+ const clips_x = style.overflowX !== 'visible';
412
+ const clips_y = style.overflowY !== 'visible';
413
+ // Only use scroll metrics on axes where this array actually clips/scrolls.
414
+ // Normal wrapping/grid node arrays often have harmless layout overflow
415
+ // (scrollWidth > clientWidth) while overflow remains visible; treating
416
+ // that as scroll position would incorrectly hide edge gaps.
417
+ let first = true;
418
+ let last = true;
419
+ if (clips_x) {
420
+ const sl = array_el.scrollLeft;
421
+ first = sl <= EDGE_TOLERANCE_PX;
422
+ last = sl + array_el.clientWidth >= array_el.scrollWidth - EDGE_TOLERANCE_PX;
423
+ }
424
+ if (clips_y) {
425
+ const st = array_el.scrollTop;
426
+ first = first && st <= EDGE_TOLERANCE_PX;
427
+ last = last && st + array_el.clientHeight >= array_el.scrollHeight - EDGE_TOLERANCE_PX;
428
+ }
429
+ return untrack(() => {
430
+ const prev = this.edge_map.get(path);
431
+ if (prev && prev.first === first && prev.last === last)
432
+ return false;
433
+ this.edge_map.set(path, { first, last });
434
+ return true;
435
+ });
436
+ }
437
+ #apply_view_classes(el, in_viewport, bcr, vh) {
438
+ const cl = el.classList;
439
+ cl.toggle('in-view', in_viewport);
440
+ if (in_viewport) {
441
+ cl.add('seen');
442
+ const top_clipped = bcr.top < -0.5;
443
+ const bottom_clipped = bcr.bottom > vh + 0.5;
444
+ cl.toggle('fully-in-view', !top_clipped && !bottom_clipped);
445
+ cl.toggle('visible-top', !top_clipped && bottom_clipped);
446
+ cl.toggle('visible-bottom', top_clipped && !bottom_clipped);
447
+ }
448
+ else {
449
+ cl.remove('fully-in-view', 'visible-top', 'visible-bottom');
450
+ }
451
+ }
452
+ #split_path(path) {
453
+ const sep = path.lastIndexOf(PATH_SEPARATOR);
454
+ if (sep < 0)
455
+ return null;
456
+ const index = parseInt(path.slice(sep + PATH_SEPARATOR.length), 10);
457
+ if (Number.isNaN(index))
458
+ return null;
459
+ return { array_path: path.slice(0, sep), index };
460
+ }
461
+ /**
462
+ * Overscan IO callback. Manages the array_indices sets; NodeGap
463
+ * reacts to the map writes and updates .positioned declaratively.
464
+ *
465
+ */
466
+ #process_near(entries) {
467
+ for (const entry of entries) {
468
+ const el = entry.target;
469
+ const path = this.#registered_paths.get(el);
470
+ if (!path || !el.isConnected)
471
+ continue;
472
+ const is_near = entry.isIntersecting;
473
+ if (is_near === this.#near_nodes.has(el))
474
+ continue;
475
+ if (is_near)
476
+ this.#add_near_node(el, path);
477
+ else
478
+ this.#remove_near_node(el, path);
479
+ }
480
+ }
481
+ /**
482
+ * Viewport IO callback. Toggles view classes on node elements.
483
+ * Runs on a separate observer with rootMargin 0 so thresholds
484
+ * fire at actual viewport edges, not the overscan boundary.
485
+ *
486
+ */
487
+ #process_view(entries) {
488
+ const vh = window.innerHeight;
489
+ for (const entry of entries) {
490
+ const el = entry.target;
491
+ if (!this.#registered_paths.has(el) || !el.isConnected)
492
+ continue;
493
+ this.#apply_view_classes(el, entry.isIntersecting, entry.boundingClientRect, vh);
494
+ }
495
+ }
557
496
  }
558
-
559
497
  /**
560
498
  * Install the visibility registry on the svedit context.
561
499
  *
@@ -564,78 +502,74 @@ class VisibilityRegistry {
564
502
  * The IntersectionObservers own viewport changes; document changes
565
503
  * only trigger an edge-state re-sync over the registered arrays.
566
504
  *
567
- * @param {object} svedit - Svedit context (session, editable)
505
+ * @param svedit - Svedit context (session, editable)
568
506
  */
569
507
  export function create_node_visibility(svedit) {
570
- const registry = new VisibilityRegistry();
571
- registry.view_classes = svedit.session?.config?.view_classes !== false;
572
- registry.visibility_culling = svedit.session?.config?.visibility_culling !== false;
573
- svedit.visibility_registry = registry;
574
-
575
- $effect(() => {
576
- if (typeof window === 'undefined') return;
577
- registry.start();
578
-
579
- /**
580
- * Scroll listener for nested scroll containers. IntersectionObserver
581
- * doesn't reliably re-fire when the user scrolls inside a non-root
582
- * scrollable element (Chrome's IO samples on viewport scroll, not on
583
- * intermediate scroll-container scrolls), so we listen for scroll
584
- * events on the document in capture phase. The handler is hot but
585
- * cheap: a single `.closest()` check exits in O(1) for the common
586
- * case (page scroll → target is document/body, not an array) and
587
- * coalesces array scrolls into one RAF that reads only scrollLeft /
588
- * scrollWidth / clientWidth — no per-node BCRs.
589
- */
590
- /** @type {Set<Element>} */
591
- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive RAF queue.
592
- const pending_scroll_arrays = new Set();
593
- let scroll_raf = 0;
594
- function flush_scroll_sync() {
595
- scroll_raf = 0;
596
- for (const arr of pending_scroll_arrays) {
597
- if (arr.isConnected) registry.sync_edge_state(arr);
598
- }
599
- pending_scroll_arrays.clear();
600
- }
601
- /** @param {Event} event */
602
- function on_scroll(event) {
603
- const target = /** @type {Element | null} */ (event.target);
604
- const arr = target?.closest?.('[data-type="node_array"]');
605
- if (!arr || !registry.has_array(arr)) return;
606
- pending_scroll_arrays.add(arr);
607
- if (!scroll_raf) scroll_raf = requestAnimationFrame(flush_scroll_sync);
608
- }
609
- document.addEventListener('scroll', on_scroll, { capture: true, passive: true });
610
-
611
- return () => {
612
- document.removeEventListener('scroll', on_scroll, true);
613
- cancelAnimationFrame(scroll_raf);
614
- pending_scroll_arrays.clear();
615
- registry.stop();
616
- };
617
- });
618
-
619
- $effect(() => {
620
- if (typeof window === 'undefined') return;
621
-
622
- // Structural inputs whose changes can alter array scroll extents
623
- // without recreating array elements. Selection intentionally does
624
- // not participate; NodeGapMarkers tracks it directly for caret state.
625
- svedit.session.doc;
626
- svedit.editable;
627
-
628
- let cancelled = false;
629
- tick().then(() => {
630
- if (!cancelled) registry.sync_all_edge_states();
631
- });
632
-
633
- return () => {
634
- cancelled = true;
635
- };
636
- });
508
+ const registry = new VisibilityRegistry();
509
+ registry.view_classes = svedit.session?.config?.view_classes !== false;
510
+ registry.visibility_culling = svedit.session?.config?.visibility_culling !== false;
511
+ svedit.visibility_registry = registry;
512
+ $effect(() => {
513
+ if (typeof window === 'undefined')
514
+ return;
515
+ registry.start();
516
+ /**
517
+ * Scroll listener for nested scroll containers. IntersectionObserver
518
+ * doesn't reliably re-fire when the user scrolls inside a non-root
519
+ * scrollable element (Chrome's IO samples on viewport scroll, not on
520
+ * intermediate scroll-container scrolls), so we listen for scroll
521
+ * events on the document in capture phase. The handler is hot but
522
+ * cheap: a single `.closest()` check exits in O(1) for the common
523
+ * case (page scroll → target is document/body, not an array) and
524
+ * coalesces array scrolls into one RAF that reads only scrollLeft /
525
+ * scrollWidth / clientWidth — no per-node BCRs.
526
+ */
527
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive RAF queue.
528
+ const pending_scroll_arrays = new Set();
529
+ let scroll_raf = 0;
530
+ function flush_scroll_sync() {
531
+ scroll_raf = 0;
532
+ for (const arr of pending_scroll_arrays) {
533
+ if (arr.isConnected)
534
+ registry.sync_edge_state(arr);
535
+ }
536
+ pending_scroll_arrays.clear();
537
+ }
538
+ function on_scroll(event) {
539
+ const target = event.target;
540
+ const arr = target?.closest?.('[data-type="node_array"]');
541
+ if (!arr || !registry.has_array(arr))
542
+ return;
543
+ pending_scroll_arrays.add(arr);
544
+ if (!scroll_raf)
545
+ scroll_raf = requestAnimationFrame(flush_scroll_sync);
546
+ }
547
+ document.addEventListener('scroll', on_scroll, { capture: true, passive: true });
548
+ return () => {
549
+ document.removeEventListener('scroll', on_scroll, true);
550
+ cancelAnimationFrame(scroll_raf);
551
+ pending_scroll_arrays.clear();
552
+ registry.stop();
553
+ };
554
+ });
555
+ $effect(() => {
556
+ if (typeof window === 'undefined')
557
+ return;
558
+ // Structural inputs whose changes can alter array scroll extents
559
+ // without recreating array elements. Selection intentionally does
560
+ // not participate; NodeGapMarkers tracks it directly for caret state.
561
+ svedit.session.doc;
562
+ svedit.editable;
563
+ let cancelled = false;
564
+ tick().then(() => {
565
+ if (!cancelled)
566
+ registry.sync_all_edge_states();
567
+ });
568
+ return () => {
569
+ cancelled = true;
570
+ };
571
+ });
637
572
  }
638
-
639
573
  /**
640
574
  * Pure visibility check for a gap's `.positioned` class. NodeGap calls
641
575
  * this from a $derived, so the `near.has()` reads are tracked at
@@ -648,35 +582,30 @@ export function create_node_visibility(svedit) {
648
582
  * $derived (see the note there): membership reads on a set created
649
583
  * within the same derived are not tracked by Svelte.
650
584
  *
651
- * @param {SvelteSet<number>} near - near child indices of the array
652
- * @param {{ first: boolean, last: boolean } | undefined} edge_state
653
- * @param {number} offset
654
- * @param {boolean} is_last
655
- * @param {boolean} empty
585
+ * @param near - near child indices of the array
656
586
  */
657
587
  export function should_position_gap(near, edge_state, offset, is_last, empty) {
658
- if (!near) return false;
659
-
660
- if (empty) {
661
- return near.has(0);
662
- }
663
-
664
- // Edge gaps gate on nearness AND edge state. edge_state is the
665
- // scroll-aware check that the adjacent edge node has actually
666
- // reached the matching end of its array container (within
667
- // EDGE_TOLERANCE_PX). We can't rely on the IO here because it only
668
- // fires on intersection-ratio threshold crossings and misses scroll
669
- // changes within a horizontal-overflow container that don't change
670
- // the visibility ratio.
671
- if (offset === 0) {
672
- if (!near.has(0)) return false;
673
- return edge_state?.first === true;
674
- }
675
-
676
- if (is_last) {
677
- if (!near.has(offset - 1)) return false;
678
- return edge_state?.last === true;
679
- }
680
-
681
- return near.has(offset - 1) && near.has(offset);
588
+ if (!near)
589
+ return false;
590
+ if (empty) {
591
+ return near.has(0);
592
+ }
593
+ // Edge gaps gate on nearness AND edge state. edge_state is the
594
+ // scroll-aware check that the adjacent edge node has actually
595
+ // reached the matching end of its array container (within
596
+ // EDGE_TOLERANCE_PX). We can't rely on the IO here because it only
597
+ // fires on intersection-ratio threshold crossings and misses scroll
598
+ // changes within a horizontal-overflow container that don't change
599
+ // the visibility ratio.
600
+ if (offset === 0) {
601
+ if (!near.has(0))
602
+ return false;
603
+ return edge_state?.first === true;
604
+ }
605
+ if (is_last) {
606
+ if (!near.has(offset - 1))
607
+ return false;
608
+ return edge_state?.last === true;
609
+ }
610
+ return near.has(offset - 1) && near.has(offset);
682
611
  }