svedit 0.11.0 → 0.12.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 (59) hide show
  1. package/README.md +230 -64
  2. package/dist/Command.svelte.d.ts +21 -5
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +86 -30
  5. package/dist/KeyMapper.svelte.d.ts.map +1 -1
  6. package/dist/KeyMapper.svelte.js +18 -6
  7. package/dist/Node.svelte +29 -1
  8. package/dist/Node.svelte.d.ts.map +1 -1
  9. package/dist/NodeArrayProperty.svelte +119 -12
  10. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  11. package/dist/NodeGap.svelte +48 -6
  12. package/dist/NodeGap.svelte.d.ts.map +1 -1
  13. package/dist/Session.svelte.d.ts +31 -14
  14. package/dist/Session.svelte.d.ts.map +1 -1
  15. package/dist/Session.svelte.js +155 -77
  16. package/dist/Svedit.svelte +110 -89
  17. package/dist/Svedit.svelte.d.ts.map +1 -1
  18. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  19. package/dist/TextProperty.svelte.d.ts +7 -0
  20. package/dist/TextProperty.svelte.d.ts.map +1 -0
  21. package/dist/Transaction.svelte.d.ts +78 -40
  22. package/dist/Transaction.svelte.d.ts.map +1 -1
  23. package/dist/Transaction.svelte.js +412 -234
  24. package/dist/doc_utils.d.ts +96 -21
  25. package/dist/doc_utils.d.ts.map +1 -1
  26. package/dist/doc_utils.js +440 -168
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/node_visibility.svelte.d.ts +30 -4
  30. package/dist/node_visibility.svelte.d.ts.map +1 -1
  31. package/dist/node_visibility.svelte.js +327 -363
  32. package/dist/paste_utils.d.ts +3 -2
  33. package/dist/paste_utils.d.ts.map +1 -1
  34. package/dist/paste_utils.js +16 -13
  35. package/dist/transforms.svelte.d.ts.map +1 -1
  36. package/dist/transforms.svelte.js +5 -6
  37. package/dist/types.d.ts +112 -42
  38. package/dist/utils.d.ts +89 -13
  39. package/dist/utils.d.ts.map +1 -1
  40. package/dist/utils.js +320 -85
  41. package/package.json +4 -1
  42. package/src/lib/Command.svelte.js +86 -30
  43. package/src/lib/KeyMapper.svelte.js +18 -6
  44. package/src/lib/Node.svelte +29 -1
  45. package/src/lib/NodeArrayProperty.svelte +119 -12
  46. package/src/lib/NodeGap.svelte +48 -6
  47. package/src/lib/Session.svelte.js +155 -77
  48. package/src/lib/Svedit.svelte +110 -89
  49. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  50. package/src/lib/Transaction.svelte.js +412 -234
  51. package/src/lib/doc_utils.js +440 -168
  52. package/src/lib/index.js +1 -1
  53. package/src/lib/node_visibility.svelte.js +327 -363
  54. package/src/lib/paste_utils.js +16 -13
  55. package/src/lib/transforms.svelte.js +5 -6
  56. package/src/lib/types.d.ts +112 -42
  57. package/src/lib/utils.js +320 -85
  58. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  59. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
@@ -15,11 +15,31 @@
15
15
  * by viewport visibility. Off-screen NodeGaps remain as zero-size
16
16
  * absolute elements with no anchor cost.
17
17
  *
18
- * ## Two observers + a scroll listener
18
+ * ## Element registration via attachments
19
19
  *
20
- * **Overscan IO** (`rootMargin: 500px`, threshold `[0, 1]`) — fills
21
- * `near_map` and per-array `array_indices`. Drives `.positioned`
22
- * activation on adjacent gaps via `sync_gaps_around_node`.
20
+ * Node elements (Node.svelte and the empty-array placeholder) register
21
+ * themselves with `{@attach registry.track_node(path)}`; node-array
22
+ * containers use `{@attach registry.track_array(path)}`. Attachments
23
+ * re-run whenever Svelte recreates the element or its path changes, so
24
+ * observation and per-path state follow the DOM's actual lifecycle —
25
+ * including DOM recreation without a document change (e.g. dev-mode
26
+ * HMR component swaps), which a document-driven reconciliation pass
27
+ * would miss.
28
+ *
29
+ * During keyed updates several elements can change paths at once
30
+ * (e.g. 0→1 while a new element claims 0), and Svelte doesn't
31
+ * guarantee attachment ordering across elements. Per-path ownership
32
+ * maps make unregistration collision-safe: a teardown only clears a
33
+ * path's state if that element still owns the path.
34
+ *
35
+ * ## Two observers
36
+ *
37
+ * **Overscan IO** (`rootMargin: 500px`, threshold `0`) — fills the
38
+ * per-array `array_indices` sets. NodeGap derives its `.positioned`
39
+ * class reactively from them. Registration seeds
40
+ * the state synchronously from one getBoundingClientRect (so there's
41
+ * no unpositioned flash on mount); the IO owns subsequent viewport
42
+ * transitions, including layout-shift-induced ones.
23
43
  *
24
44
  * **Viewport IO** (`rootMargin: 0`, threshold `[0, 1]`) — toggles
25
45
  * view classes (`.in-view`, `.fully-in-view`, …) on node elements.
@@ -38,13 +58,26 @@
38
58
  * immediately) and a RAF flush that reads only scrollLeft / scrollWidth /
39
59
  * clientWidth on the dirty array.
40
60
  *
61
+ * **Document changes** — content changes alter an array's scroll
62
+ * extent without recreating the array element, so after Svelte
63
+ * updates the DOM all registered arrays re-sync their edge state in
64
+ * one batch.
65
+ *
66
+ * **Array ResizeObserver** — box-size changes (window resize, sidebar
67
+ * toggle, responsive breakpoint) can flip an array between fitting
68
+ * and overflowing without a scroll event or document change; a shared
69
+ * ResizeObserver on registered arrays re-syncs their edge state.
70
+ *
41
71
  * ## Reactivity
42
72
  *
43
- * State lives in SvelteMap (near_map, edge_map) and per-array SvelteSet
44
- * (array_indices). Consumers (NodeGap via sync_gap_class, NodeGapMarkers
45
- * via $derived) read via .has()/.get() — Svelte's runtime tracks at
73
+ * State lives in per-array SvelteSets (array_indices, the single
74
+ * source of truth for node nearness) and a SvelteMap (edge_map).
75
+ * Consumers (NodeGap and NodeGapMarkers, both via
76
+ * $derived) read via .has()/.get() — Svelte's runtime tracks at
46
77
  * per-key granularity, so only consumers whose specific dependencies
47
- * changed re-evaluate.
78
+ * changed re-evaluate. Because the `.positioned` class is applied
79
+ * declaratively by NodeGap, it survives DOM recreation that happens
80
+ * without a document change (e.g. dev-mode HMR component swaps).
48
81
  *
49
82
  * View classes are toggled imperatively (no Svelte consumers — they're
50
83
  * pure CSS hooks for app code).
@@ -53,12 +86,9 @@
53
86
  */
54
87
 
55
88
  import { SvelteMap, SvelteSet } from 'svelte/reactivity';
56
- import { untrack } from 'svelte';
89
+ import { tick, untrack } from 'svelte';
57
90
  import { PATH_SEPARATOR } from './utils.js';
58
91
 
59
- const NODE_SELECTOR = '[data-type="node"][data-path]';
60
- const GAP_SELECTOR = '.node-gap[data-gap-array-path]';
61
-
62
92
  /**
63
93
  * Overscan margin around the viewport (px). Nodes within this distance
64
94
  * of the viewport get anchor positioning activated and gap markers
@@ -78,17 +108,17 @@ const EDGE_TOLERANCE_PX = 10;
78
108
 
79
109
  /**
80
110
  * Single source of truth for "which node DOM elements are near or in
81
- * the viewport". Owns the IntersectionObserver and the reactive maps
82
- * consumed by NodeGap and NodeGapMarkers.
111
+ * the viewport". Owns the IntersectionObservers and the reactive maps
112
+ * consumed by NodeGap and NodeGapMarkers. Elements register through
113
+ * the track_node / track_array attachments.
83
114
  */
84
115
  class VisibilityRegistry {
85
- /** Per-node "in overscan zone" flag. NodeGap reads via `.has(path)`. */
86
- near_map = new SvelteMap();
87
-
88
116
  /**
89
- * Per-array set of near child indices. NodeGapMarkers iterates the
90
- * set for its array. Stable references — once a SvelteSet is created
91
- * for an array path it survives re-renders so consumers stay subscribed.
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.
92
122
  *
93
123
  * @type {Map<string, SvelteSet<number>>}
94
124
  */
@@ -98,10 +128,11 @@ class VisibilityRegistry {
98
128
  * Per-array edge-proximity state. `first` is true when the first
99
129
  * node's leading edge is within EDGE_TOLERANCE_PX of the array
100
130
  * container's leading edge; `last` is the symmetric trailing-edge
101
- * check on the last node. Recomputed on scroll/resize via BCR
102
- * because IntersectionObserver only fires on intersection-ratio
103
- * threshold crossings and would otherwise miss horizontal-overflow
104
- * scroll changes that don't change the visibility ratio.
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.
105
136
  *
106
137
  * NodeGap and NodeGapMarkers read this to gate edge gaps so they
107
138
  * only render when the edge node has actually reached the matching
@@ -117,9 +148,43 @@ class VisibilityRegistry {
117
148
  /** @type {IntersectionObserver | null} */
118
149
  #view_io = null;
119
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
+
120
180
  /** @type {Set<HTMLElement>} */
121
- #pending_view_sync = new Set();
122
- #pending_view_raf = 0;
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();
123
188
 
124
189
  /**
125
190
  * When true, a second IO (rootMargin 0, actual viewport) toggles
@@ -138,14 +203,10 @@ class VisibilityRegistry {
138
203
  view_classes = true;
139
204
 
140
205
  /**
141
- * When true, the overscan IO populates near_map and gates
142
- * `.positioned` on gap elements by viewport proximity. When false,
143
- * the overscan IO is skipped entirely: observe() pre-populates
144
- * near_map / array_indices as if every node were always in the
145
- * overscan zone, so every gap is `.positioned` and every marker
146
- * renders. Useful for diagnosing whether a layout bug originates
147
- * in the IO culling logic vs. the underlying CSS / anchor
148
- * positioning.
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.
149
210
  *
150
211
  * Disabling makes anchor positioning O(N) over every node in the
151
212
  * document and is intended for debugging only; large documents
@@ -174,6 +235,11 @@ class VisibilityRegistry {
174
235
  threshold: [0, 1]
175
236
  });
176
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
+ }
177
243
  }
178
244
 
179
245
  stop() {
@@ -181,10 +247,13 @@ class VisibilityRegistry {
181
247
  this.#io = null;
182
248
  this.#view_io?.disconnect();
183
249
  this.#view_io = null;
184
- cancelAnimationFrame(this.#pending_view_raf);
185
- this.#pending_view_raf = 0;
186
- this.#pending_view_sync.clear();
187
- this.near_map.clear();
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();
188
257
  this.edge_map.clear();
189
258
  for (const set of this.#array_indices.values()) set.clear();
190
259
  }
@@ -195,6 +264,13 @@ class VisibilityRegistry {
195
264
  * subscription — the same set is returned on subsequent calls so
196
265
  * Svelte's per-set tracking remains intact across re-evaluations.
197
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
+ *
198
274
  * @param {string} array_path_str
199
275
  * @returns {SvelteSet<number>}
200
276
  */
@@ -207,85 +283,148 @@ class VisibilityRegistry {
207
283
  return set;
208
284
  }
209
285
 
210
- /** @param {HTMLElement} el */
211
- observe(el) {
212
- if (this.visibility_culling) {
213
- this.#io?.observe(el);
214
- } else {
215
- // No overscan IO: treat every node as always near with no
216
- // clipping. near_map / array_indices population mirrors what
217
- // the IO would write on its first callback, just without the
218
- // per-node viewport check. Sibling gaps still need a sync
219
- // pass because no IO callback will fire to drive it.
220
- //
221
- // untrack() is critical: the bootstrap loop calls observe()
222
- // from inside the create_node_visibility $effect, and the
223
- // .has() reads on reactive Map/Set would subscribe that
224
- // effect to the same maps we then write to — invalidating
225
- // itself and re-running the cleanup→start cycle infinitely.
226
- const path = el.dataset?.path;
227
- if (path) {
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);
228
322
  untrack(() => {
229
- let any_change = false;
230
- if (!this.near_map.has(path)) {
231
- this.near_map.set(path, true);
232
- any_change = true;
233
- }
234
- const split = this.#split_path(path);
235
- if (split) {
236
- const set = this.get_array_indices(split.array_path);
237
- if (!set.has(split.index)) {
238
- set.add(split.index);
239
- any_change = true;
240
- }
323
+ if (this.#array_owners.get(path) === el) {
324
+ this.#array_owners.delete(path);
325
+ this.edge_map.delete(path);
241
326
  }
242
- if (any_change) this.sync_gaps_around_node(el);
243
327
  });
244
- }
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);
245
351
  }
246
- this.#view_io?.observe(el);
247
352
  }
248
353
 
249
- /** @param {HTMLElement} el */
250
- unobserve(el) {
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);
251
396
  this.#io?.unobserve(el);
252
397
  this.#view_io?.unobserve(el);
253
- const path = el.dataset?.path;
254
- if (path) this.#forget(path);
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
+ });
255
407
  }
256
408
 
257
409
  /**
258
- * Imperatively toggle .positioned on a single gap element based on
259
- * current near_map / edge_map state. Reads dataset + classList for
260
- * gap context (no Svelte reactivity).
261
- *
262
- * @param {Element} gap_el
410
+ * @param {HTMLElement} el
411
+ * @param {string} path
263
412
  */
264
- sync_gap_class(gap_el) {
265
- const el = /** @type {HTMLElement} */ (gap_el);
266
- const arr = el.dataset.gapArrayPath;
267
- if (!arr) return;
268
- const offset = parseInt(el.dataset.gapOffset, 10);
269
- const is_last = el.classList.contains('last');
270
- const empty = el.classList.contains('empty');
271
- el.classList.toggle(
272
- 'positioned',
273
- should_position_gap_imperative(this, arr, offset, is_last, empty)
274
- );
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);
275
417
  }
276
418
 
277
419
  /**
278
- * Toggle .positioned on the two gap elements adjacent to a node
279
- * element. Used after near_map writes triggered by IO, MO, or
280
- * bootstrap. O(1) DOM sibling traversal.
281
- *
282
- * @param {Element} node_el
420
+ * @param {HTMLElement} el
421
+ * @param {string} path
283
422
  */
284
- sync_gaps_around_node(node_el) {
285
- const prev = node_el.previousElementSibling;
286
- if (prev?.classList.contains('node-gap')) this.sync_gap_class(prev);
287
- const next = node_el.nextElementSibling;
288
- if (next?.classList.contains('node-gap')) this.sync_gap_class(next);
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);
289
428
  }
290
429
 
291
430
  /**
@@ -314,13 +453,11 @@ class VisibilityRegistry {
314
453
  const path = /** @type {HTMLElement} */ (array_el).dataset.path;
315
454
  if (!path) return false;
316
455
 
317
- const sl = array_el.scrollLeft;
318
- const sw = array_el.scrollWidth;
319
- const cw = array_el.clientWidth;
320
- const st = array_el.scrollTop;
321
- const sh = array_el.scrollHeight;
322
- const ch = array_el.clientHeight;
323
-
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.
324
461
  const style = getComputedStyle(array_el);
325
462
  const clips_x = style.overflowX !== 'visible';
326
463
  const clips_y = style.overflowY !== 'visible';
@@ -329,12 +466,18 @@ class VisibilityRegistry {
329
466
  // Normal wrapping/grid node arrays often have harmless layout overflow
330
467
  // (scrollWidth > clientWidth) while overflow remains visible; treating
331
468
  // that as scroll position would incorrectly hide edge gaps.
332
- const first =
333
- (!clips_x || sl <= EDGE_TOLERANCE_PX) &&
334
- (!clips_y || st <= EDGE_TOLERANCE_PX);
335
- const last =
336
- (!clips_x || sl + cw >= sw - EDGE_TOLERANCE_PX) &&
337
- (!clips_y || st + ch >= sh - EDGE_TOLERANCE_PX);
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
+ }
338
481
 
339
482
  return untrack(() => {
340
483
  const prev = this.edge_map.get(path);
@@ -344,81 +487,6 @@ class VisibilityRegistry {
344
487
  });
345
488
  }
346
489
 
347
- /**
348
- * Sync edge state and toggle .positioned on first/last gaps. Early-
349
- * exits when edge_map is unchanged — for the structural-mutation
350
- * path where the edge gap may need re-evaluation regardless of
351
- * edge_map, see resync_array_edge_gaps below.
352
- *
353
- * @param {Element} array_el
354
- */
355
- sync_array_edge_gaps(array_el) {
356
- if (!this.sync_edge_state(array_el)) return;
357
- this.#sync_first_last_gaps(array_el);
358
- }
359
-
360
- /**
361
- * Force-resync first/last gaps after a structural mutation. The
362
- * trailing gap is a reused Svelte component whose `.positioned`
363
- * state may need re-deriving even when edge_map didn't change.
364
- *
365
- * @param {Element} array_el
366
- */
367
- resync_array_edge_gaps(array_el) {
368
- this.sync_edge_state(array_el);
369
- this.#sync_first_last_gaps(array_el);
370
- }
371
-
372
- /** @param {Element} array_el */
373
- #sync_first_last_gaps(array_el) {
374
- const first_gap = array_el.querySelector(':scope > .node-gap.gap-before:not(.empty)');
375
- const last_gap = array_el.querySelector(':scope > .node-gap.gap-after.last');
376
- if (first_gap) this.sync_gap_class(first_gap);
377
- if (last_gap) this.sync_gap_class(last_gap);
378
- }
379
-
380
- /**
381
- * Queue a node for deferred view-class sync. Unobserves from the
382
- * viewport IO immediately to prevent its initial callback from
383
- * racing with the RAF, then re-observes in #flush_view_sync after
384
- * classes are applied via BCR. Multiple MO-triggered insertions
385
- * coalesce into a single RAF / layout pass.
386
- *
387
- * @param {HTMLElement} el
388
- */
389
- schedule_view_sync(el) {
390
- if (!this.view_classes) return;
391
- this.#view_io?.unobserve(el);
392
- this.#pending_view_sync.add(el);
393
- if (!this.#pending_view_raf) {
394
- this.#pending_view_raf = requestAnimationFrame(() => this.#flush_view_sync());
395
- }
396
- }
397
-
398
- #flush_view_sync() {
399
- this.#pending_view_raf = 0;
400
- const vh = window.innerHeight;
401
- const vw = window.innerWidth;
402
- // Read all BCRs first, then write all classes. A read-write-read
403
- // interleave (the natural single-pass shape) forces a layout
404
- // flush on every iteration when the toggled classes affect
405
- // layout — which user apps are free to do via `.in-view` /
406
- // `.fully-in-view` / etc. Two passes batch the layout reads into
407
- // one flush regardless of what the consumer wires up.
408
- /** @type {Array<{el: HTMLElement, bcr: DOMRectReadOnly}>} */
409
- const items = [];
410
- for (const el of this.#pending_view_sync) {
411
- if (el.isConnected) items.push({ el, bcr: el.getBoundingClientRect() });
412
- }
413
- for (const { el, bcr } of items) {
414
- const in_viewport =
415
- bcr.bottom > 0 && bcr.top < vh && bcr.right > 0 && bcr.left < vw;
416
- this.#apply_view_classes(el, in_viewport, bcr, vh);
417
- this.#view_io?.observe(el);
418
- }
419
- this.#pending_view_sync.clear();
420
- }
421
-
422
490
  /**
423
491
  * @param {HTMLElement} el
424
492
  * @param {boolean} in_viewport
@@ -440,18 +508,6 @@ class VisibilityRegistry {
440
508
  }
441
509
  }
442
510
 
443
- /** @param {string} path */
444
- #forget(path) {
445
- this.near_map.delete(path);
446
- const split = this.#split_path(path);
447
- if (split) {
448
- const set = this.#array_indices.get(split.array_path);
449
- set?.delete(split.index);
450
- // Keep the SvelteSet alive — its NodeGapMarkers consumer may
451
- // still be mounted. Recreating would orphan the subscription.
452
- }
453
- }
454
-
455
511
  /**
456
512
  * @param {string} path
457
513
  * @returns {{ array_path: string, index: number } | null}
@@ -465,39 +521,21 @@ class VisibilityRegistry {
465
521
  }
466
522
 
467
523
  /**
468
- * Overscan IO callback. Manages near_map and array_indices, then
469
- * imperatively toggles .positioned on adjacent gaps.
524
+ * Overscan IO callback. Manages the array_indices sets; NodeGap
525
+ * reacts to the map writes and updates .positioned declaratively.
470
526
  *
471
527
  * @param {IntersectionObserverEntry[]} entries
472
528
  */
473
529
  #process_near(entries) {
474
530
  for (const entry of entries) {
475
531
  const el = /** @type {HTMLElement} */ (entry.target);
476
- const path = el.dataset.path;
477
- if (!path) continue;
532
+ const path = this.#registered_paths.get(el);
533
+ if (!path || !el.isConnected) continue;
478
534
 
479
535
  const is_near = entry.isIntersecting;
480
-
481
- let any_change = false;
482
-
483
- if (is_near) {
484
- if (!this.near_map.has(path)) {
485
- this.near_map.set(path, true);
486
- any_change = true;
487
- }
488
- } else if (this.near_map.has(path)) {
489
- this.near_map.delete(path);
490
- any_change = true;
491
- }
492
-
493
- const split = this.#split_path(path);
494
- if (split) {
495
- const set = this.get_array_indices(split.array_path);
496
- if (is_near) set.add(split.index);
497
- else set.delete(split.index);
498
- }
499
-
500
- if (any_change) this.sync_gaps_around_node(el);
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);
501
539
  }
502
540
  }
503
541
 
@@ -512,25 +550,21 @@ class VisibilityRegistry {
512
550
  const vh = window.innerHeight;
513
551
  for (const entry of entries) {
514
552
  const el = /** @type {HTMLElement} */ (entry.target);
515
- if (!el.dataset.path) continue;
553
+ if (!this.#registered_paths.has(el) || !el.isConnected) continue;
516
554
  this.#apply_view_classes(el, entry.isIntersecting, entry.boundingClientRect, vh);
517
555
  }
518
556
  }
519
-
520
- // #clip_bits and clip_map were removed: their only consumer was the
521
- // edge-gap visibility check, and they went stale on nested horizontal
522
- // scroll (IO doesn't re-fire when the intersection ratio doesn't
523
- // cross a threshold). Replaced by edge_map, derived from the array's
524
- // own scrollLeft / clientWidth / scrollWidth — one element, six
525
- // property reads per dirty array per RAF, no per-node BCRs.
526
557
  }
527
558
 
528
559
  /**
529
- * Install the visibility registry on the svedit context and wire up
530
- * the IntersectionObserver + MutationObserver lifecycle to editable
531
- * state.
560
+ * Install the visibility registry on the svedit context.
532
561
  *
533
- * @param {object} svedit - Svedit context (session, editable, canvas_el)
562
+ * Elements register themselves via the track_node / track_array
563
+ * attachments, so registration follows the DOM's actual lifecycle.
564
+ * The IntersectionObservers own viewport changes; document changes
565
+ * only trigger an edge-state re-sync over the registered arrays.
566
+ *
567
+ * @param {object} svedit - Svedit context (session, editable)
534
568
  */
535
569
  export function create_node_visibility(svedit) {
536
570
  const registry = new VisibilityRegistry();
@@ -540,35 +574,7 @@ export function create_node_visibility(svedit) {
540
574
 
541
575
  $effect(() => {
542
576
  if (typeof window === 'undefined') return;
543
-
544
- // Empty node-array placeholders only exist in edit mode, so re-run
545
- // the bootstrap when editability changes to observe them reliably.
546
- svedit.editable;
547
-
548
577
  registry.start();
549
- // Observe every existing node. NO getBoundingClientRect — it
550
- // would force layout on each call, and at 2000+ nodes the
551
- // bootstrap interleaves with Svelte's mount work, retriggering
552
- // layout for the growing DOM (~1.5 s of forced reflow when
553
- // traced). IO populates near_map / array_indices on its first
554
- // callback, ~1 frame after observe(). The lag is invisible:
555
- // un-positioned NodeGaps have zero layout presence.
556
- for (const el of document.querySelectorAll(NODE_SELECTOR)) {
557
- registry.observe(/** @type {HTMLElement} */ (el));
558
- }
559
-
560
- // Bootstrap edge_map for arrays already in the DOM. One-time
561
- // scrollLeft/clientWidth pass — cheap (one element, six property
562
- // reads each) and never repeated outside this mount.
563
- for (const arr of document.querySelectorAll('[data-type="node_array"]')) {
564
- registry.sync_array_edge_gaps(arr);
565
- }
566
-
567
- const canvas =
568
- untrack(() => svedit.canvas_el) || document.querySelector('.svedit-canvas');
569
- /** @type {MutationObserver | null} */
570
- let mo = null;
571
- let raf = 0;
572
578
 
573
579
  /**
574
580
  * Scroll listener for nested scroll containers. IntersectionObserver
@@ -588,7 +594,7 @@ export function create_node_visibility(svedit) {
588
594
  function flush_scroll_sync() {
589
595
  scroll_raf = 0;
590
596
  for (const arr of pending_scroll_arrays) {
591
- if (arr.isConnected) registry.sync_array_edge_gaps(arr);
597
+ if (arr.isConnected) registry.sync_edge_state(arr);
592
598
  }
593
599
  pending_scroll_arrays.clear();
594
600
  }
@@ -596,123 +602,81 @@ export function create_node_visibility(svedit) {
596
602
  function on_scroll(event) {
597
603
  const target = /** @type {Element | null} */ (event.target);
598
604
  const arr = target?.closest?.('[data-type="node_array"]');
599
- if (!arr) return;
605
+ if (!arr || !registry.has_array(arr)) return;
600
606
  pending_scroll_arrays.add(arr);
601
607
  if (!scroll_raf) scroll_raf = requestAnimationFrame(flush_scroll_sync);
602
608
  }
603
609
  document.addEventListener('scroll', on_scroll, { capture: true, passive: true });
604
610
 
605
- if (canvas) {
606
- mo = new MutationObserver((mutations) => {
607
- /** @type {Set<Element>} */
608
- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive per-mutation queue.
609
- const dirty_arrays = new Set();
610
- for (const m of mutations) {
611
- for (const added of m.addedNodes) {
612
- if (added.nodeType !== Node.ELEMENT_NODE) continue;
613
- const el = /** @type {HTMLElement} */ (added);
614
- if (el.matches(NODE_SELECTOR)) {
615
- registry.observe(el);
616
- registry.schedule_view_sync(el);
617
- const arr = el.closest('[data-type="node_array"]');
618
- if (arr) dirty_arrays.add(arr);
619
- }
620
- // Newly-mounted gaps need immediate .positioned sync —
621
- // the IO won't fire for the EXISTING adjacent node
622
- // (its intersection didn't change), so the gap would
623
- // stay un-positioned until the next scroll.
624
- if (el.matches(GAP_SELECTOR)) {
625
- registry.sync_gap_class(el);
626
- }
627
- for (const child of el.querySelectorAll(NODE_SELECTOR)) {
628
- const node_el = /** @type {HTMLElement} */ (child);
629
- registry.observe(node_el);
630
- registry.schedule_view_sync(node_el);
631
- const arr = node_el.closest('[data-type="node_array"]');
632
- if (arr) dirty_arrays.add(arr);
633
- }
634
- for (const child of el.querySelectorAll(GAP_SELECTOR)) {
635
- registry.sync_gap_class(child);
636
- }
637
- }
638
- for (const removed of m.removedNodes) {
639
- if (removed.nodeType !== Node.ELEMENT_NODE) continue;
640
- const el = /** @type {HTMLElement} */ (removed);
641
- if (el.matches(NODE_SELECTOR)) registry.unobserve(el);
642
- for (const child of el.querySelectorAll(NODE_SELECTOR)) {
643
- registry.unobserve(/** @type {HTMLElement} */ (child));
644
- }
645
- }
646
- }
647
- for (const arr of dirty_arrays) registry.resync_array_edge_gaps(arr);
648
- });
649
- // Defer one frame to skip the initial Svelte mount burst,
650
- // which the bootstrap above already covered.
651
- raf = requestAnimationFrame(() => {
652
- mo.observe(canvas, { childList: true, subtree: true });
653
- });
654
- }
655
-
656
611
  return () => {
657
612
  document.removeEventListener('scroll', on_scroll, true);
658
- cancelAnimationFrame(raf);
659
613
  cancelAnimationFrame(scroll_raf);
660
614
  pending_scroll_arrays.clear();
661
- mo?.disconnect();
662
615
  registry.stop();
663
616
  };
664
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
+ });
665
637
  }
666
638
 
667
639
  /**
668
- * Non-reactive visibility check used by the imperative .positioned
669
- * toggle. Reads near_map / edge_map inside untrack() because this is
670
- * called from sync_gap_class which can run transitively from the
671
- * bootstrap $effect, and a subscription here would self-invalidate
672
- * (the same effect writes to these maps and would re-run forever).
673
- * `is_last` and `empty` come from the gap element's classList, so we
674
- * don't need `count` here.
640
+ * Pure visibility check for a gap's `.positioned` class. NodeGap calls
641
+ * this from a $derived, so the `near.has()` reads are tracked at
642
+ * per-key granularity — a gap re-evaluates only when its own
643
+ * dependencies change. Applying the class declaratively means it
644
+ * survives DOM recreation without a document change (e.g. dev-mode
645
+ * HMR component swaps).
675
646
  *
676
- * @param {VisibilityRegistry} registry
677
- * @param {string} array_path_str
647
+ * `near` must come from `get_array_indices` held in a SEPARATE
648
+ * $derived (see the note there): membership reads on a set created
649
+ * within the same derived are not tracked by Svelte.
650
+ *
651
+ * @param {SvelteSet<number>} near - near child indices of the array
652
+ * @param {{ first: boolean, last: boolean } | undefined} edge_state
678
653
  * @param {number} offset
679
654
  * @param {boolean} is_last
680
655
  * @param {boolean} empty
681
656
  */
682
- function should_position_gap_imperative(registry, array_path_str, offset, is_last, empty) {
683
- if (!registry) return false;
684
-
685
- // untrack() is critical: this function is called transitively from
686
- // the bootstrap $effect (via sync_array_edge_gaps → sync_gap_class),
687
- // and the reactive-Map `.has()` / `.get()` reads here would
688
- // otherwise subscribe that effect to the same maps the bootstrap
689
- // then writes to — infinite re-run cycle that empties near_map
690
- // every time the IO tries to populate it.
691
- return untrack(() => {
692
- if (empty) {
693
- return registry.near_map.has(`${array_path_str}${PATH_SEPARATOR}0`);
694
- }
657
+ export function should_position_gap(near, edge_state, offset, is_last, empty) {
658
+ if (!near) return false;
695
659
 
696
- // Edge gaps gate on near_map AND edge_map. edge_map is the
697
- // scroll-aware BCR-based check that the adjacent edge node has
698
- // actually reached the matching end of its array container
699
- // (within EDGE_TOLERANCE_PX). We can't rely on IO clip_map here
700
- // because IO only fires on intersection-ratio threshold
701
- // crossings and misses scroll changes within a horizontal-
702
- // overflow container that don't change the visibility ratio.
703
- if (offset === 0) {
704
- if (!registry.near_map.has(`${array_path_str}${PATH_SEPARATOR}0`)) return false;
705
- return registry.edge_map.get(array_path_str)?.first === true;
706
- }
660
+ if (empty) {
661
+ return near.has(0);
662
+ }
707
663
 
708
- if (is_last) {
709
- if (!registry.near_map.has(`${array_path_str}${PATH_SEPARATOR}${offset - 1}`)) return false;
710
- return registry.edge_map.get(array_path_str)?.last === true;
711
- }
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
+ }
712
675
 
713
- return (
714
- registry.near_map.has(`${array_path_str}${PATH_SEPARATOR}${offset - 1}`) &&
715
- registry.near_map.has(`${array_path_str}${PATH_SEPARATOR}${offset}`)
716
- );
717
- });
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);
718
682
  }