svedit 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -4
- package/dist/AnnotatedTextProperty.svelte +41 -8
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.d.ts +0 -1
- package/dist/Command.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.js +18 -3
- package/dist/CustomProperty.svelte +20 -9
- package/dist/CustomProperty.svelte.d.ts +2 -1
- package/dist/CustomProperty.svelte.d.ts.map +1 -1
- package/dist/Node.svelte +7 -5
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +65 -42
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/NodeCaret.svelte +0 -7
- package/dist/NodeGap.svelte +179 -81
- package/dist/NodeGap.svelte.d.ts +0 -2
- package/dist/NodeGap.svelte.d.ts.map +1 -1
- package/dist/NodeGapMarkers.svelte +251 -84
- package/dist/NodeGapMarkers.svelte.d.ts +2 -0
- package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
- package/dist/NodeSelectionMarkers.svelte +4 -3
- package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.d.ts +12 -1
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +40 -3
- package/dist/Svedit.svelte +340 -147
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +24 -5
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +103 -61
- package/dist/doc_utils.d.ts +64 -3
- package/dist/doc_utils.d.ts.map +1 -1
- package/dist/doc_utils.js +276 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -1
- package/dist/node_visibility.svelte.d.ts +9 -0
- package/dist/node_visibility.svelte.d.ts.map +1 -0
- package/dist/node_visibility.svelte.js +718 -0
- package/dist/styles/svedit-animations.css +8 -0
- package/dist/styles/svedit-colors.css +1 -0
- package/dist/types.d.ts +46 -3
- package/dist/utils.d.ts +52 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +109 -3
- package/package.json +3 -2
- package/src/lib/AnnotatedTextProperty.svelte +41 -8
- package/src/lib/Command.svelte.js +18 -3
- package/src/lib/CustomProperty.svelte +20 -9
- package/src/lib/Node.svelte +7 -5
- package/src/lib/NodeArrayProperty.svelte +65 -42
- package/src/lib/NodeCaret.svelte +0 -7
- package/src/lib/NodeGap.svelte +179 -81
- package/src/lib/NodeGapMarkers.svelte +251 -84
- package/src/lib/NodeSelectionMarkers.svelte +4 -3
- package/src/lib/Session.svelte.js +40 -3
- package/src/lib/Svedit.svelte +340 -147
- package/src/lib/Transaction.svelte.js +103 -61
- package/src/lib/doc_utils.js +276 -10
- package/src/lib/index.js +5 -1
- package/src/lib/node_visibility.svelte.js +718 -0
- package/src/lib/styles/svedit-animations.css +8 -0
- package/src/lib/styles/svedit-colors.css +1 -0
- package/src/lib/types.d.ts +46 -3
- package/src/lib/utils.js +109 -3
- package/dist/node_gap_computation.svelte.d.ts +0 -5
- package/dist/node_gap_computation.svelte.d.ts.map +0 -1
- package/dist/node_gap_computation.svelte.js +0 -621
- package/src/lib/node_gap_computation.svelte.js +0 -621
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dual-observer viewport visibility tracking for node arrays.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* CSS anchor positioning is O(N) — the browser must resolve anchor()
|
|
7
|
+
* functions for every positioned element on every layout pass. Gap
|
|
8
|
+
* markers (NodeGapMarkers) also grow at O(N). At ~200+ nodes this
|
|
9
|
+
* causes frame drops during scroll/resize; at 500+ it's unusable.
|
|
10
|
+
*
|
|
11
|
+
* ## Design principle: structurally stable DOM
|
|
12
|
+
*
|
|
13
|
+
* NodeGap elements are always present in the DOM when editable. The
|
|
14
|
+
* `.positioned` class (which activates anchor() resolution) is gated
|
|
15
|
+
* by viewport visibility. Off-screen NodeGaps remain as zero-size
|
|
16
|
+
* absolute elements with no anchor cost.
|
|
17
|
+
*
|
|
18
|
+
* ## Two observers + a scroll listener
|
|
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`.
|
|
23
|
+
*
|
|
24
|
+
* **Viewport IO** (`rootMargin: 0`, threshold `[0, 1]`) — toggles
|
|
25
|
+
* view classes (`.in-view`, `.fully-in-view`, …) on node elements.
|
|
26
|
+
* OPT-IN via `session.config.view_classes`. A separate observer is
|
|
27
|
+
* required because the overscan IO fires its thresholds while nodes
|
|
28
|
+
* are still 500px outside the real viewport.
|
|
29
|
+
*
|
|
30
|
+
* **Document scroll listener** (capture, passive) — fills `edge_map`,
|
|
31
|
+
* a per-array `{first, last}` flag indicating whether the leading/
|
|
32
|
+
* trailing node has reached the matching container edge within
|
|
33
|
+
* EDGE_TOLERANCE_PX. IntersectionObserver doesn't re-fire on scrolls
|
|
34
|
+
* inside non-root scroll containers (the intersection ratio doesn't
|
|
35
|
+
* cross any threshold), so this is the only reliable way to track
|
|
36
|
+
* horizontal-overflow scroll position. The handler is hot but cheap:
|
|
37
|
+
* a single `.closest()` early-out (page scroll → null → returns
|
|
38
|
+
* immediately) and a RAF flush that reads only scrollLeft / scrollWidth /
|
|
39
|
+
* clientWidth on the dirty array.
|
|
40
|
+
*
|
|
41
|
+
* ## Reactivity
|
|
42
|
+
*
|
|
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
|
|
46
|
+
* per-key granularity, so only consumers whose specific dependencies
|
|
47
|
+
* changed re-evaluate.
|
|
48
|
+
*
|
|
49
|
+
* View classes are toggled imperatively (no Svelte consumers — they're
|
|
50
|
+
* pure CSS hooks for app code).
|
|
51
|
+
*
|
|
52
|
+
* @module
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
56
|
+
import { untrack } from 'svelte';
|
|
57
|
+
import { PATH_SEPARATOR } from './utils.js';
|
|
58
|
+
|
|
59
|
+
const NODE_SELECTOR = '[data-type="node"][data-path]';
|
|
60
|
+
const GAP_SELECTOR = '.node-gap[data-gap-array-path]';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Overscan margin around the viewport (px). Nodes within this distance
|
|
64
|
+
* of the viewport get anchor positioning activated and gap markers
|
|
65
|
+
* computed. 500px provides enough buffer for fast scrolling without
|
|
66
|
+
* a measurable FPS hit.
|
|
67
|
+
*/
|
|
68
|
+
const OVERSCAN_PX = 500;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Distance (in CSS pixels) the leading/trailing edge of an edge node is
|
|
72
|
+
* allowed to overshoot the array container's matching edge while still
|
|
73
|
+
* counting as "at the edge". Picks up small overflow from sub-pixel
|
|
74
|
+
* layout and gives a forgiving hit area near the boundary so a 1-2px
|
|
75
|
+
* jitter from scroll/animation doesn't cause the edge gap to flicker.
|
|
76
|
+
*/
|
|
77
|
+
const EDGE_TOLERANCE_PX = 10;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 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.
|
|
83
|
+
*/
|
|
84
|
+
class VisibilityRegistry {
|
|
85
|
+
/** Per-node "in overscan zone" flag. NodeGap reads via `.has(path)`. */
|
|
86
|
+
near_map = new SvelteMap();
|
|
87
|
+
|
|
88
|
+
/**
|
|
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.
|
|
92
|
+
*
|
|
93
|
+
* @type {Map<string, SvelteSet<number>>}
|
|
94
|
+
*/
|
|
95
|
+
#array_indices = new Map();
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Per-array edge-proximity state. `first` is true when the first
|
|
99
|
+
* node's leading edge is within EDGE_TOLERANCE_PX of the array
|
|
100
|
+
* 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.
|
|
105
|
+
*
|
|
106
|
+
* NodeGap and NodeGapMarkers read this to gate edge gaps so they
|
|
107
|
+
* only render when the edge node has actually reached the matching
|
|
108
|
+
* end of its container.
|
|
109
|
+
*
|
|
110
|
+
* @type {SvelteMap<string, { first: boolean, last: boolean }>}
|
|
111
|
+
*/
|
|
112
|
+
edge_map = new SvelteMap();
|
|
113
|
+
|
|
114
|
+
/** @type {IntersectionObserver | null} */
|
|
115
|
+
#io = null;
|
|
116
|
+
|
|
117
|
+
/** @type {IntersectionObserver | null} */
|
|
118
|
+
#view_io = null;
|
|
119
|
+
|
|
120
|
+
/** @type {Set<HTMLElement>} */
|
|
121
|
+
#pending_view_sync = new Set();
|
|
122
|
+
#pending_view_raf = 0;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* When true, a second IO (rootMargin 0, actual viewport) toggles
|
|
126
|
+
* `.in-view` / `.seen` / `.fully-in-view` / `.visible-top` /
|
|
127
|
+
* `.visible-bottom` on node elements — useful for scrollytelling
|
|
128
|
+
* and reveal animations.
|
|
129
|
+
*
|
|
130
|
+
* A separate observer is required because the overscan IO (rootMargin
|
|
131
|
+
* 500px) fires its thresholds while nodes are still outside the real
|
|
132
|
+
* viewport — by the time a node scrolls into view, both thresholds
|
|
133
|
+
* have already been crossed and no further callback fires.
|
|
134
|
+
*
|
|
135
|
+
* Opt out via `session.config.view_classes = false` to skip the
|
|
136
|
+
* second observer entirely.
|
|
137
|
+
*/
|
|
138
|
+
view_classes = true;
|
|
139
|
+
|
|
140
|
+
/**
|
|
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.
|
|
149
|
+
*
|
|
150
|
+
* Disabling makes anchor positioning O(N) over every node in the
|
|
151
|
+
* document and is intended for debugging only; large documents
|
|
152
|
+
* (hundreds of nodes) can become unresponsive without the culling.
|
|
153
|
+
*
|
|
154
|
+
* Opt out via `session.config.visibility_culling = false`.
|
|
155
|
+
*/
|
|
156
|
+
visibility_culling = true;
|
|
157
|
+
|
|
158
|
+
start() {
|
|
159
|
+
if (typeof window === 'undefined') return;
|
|
160
|
+
// Overscan IO: threshold [0] is enough — #process_near only reads
|
|
161
|
+
// entry.isIntersecting (a boolean) and writes are change-detected,
|
|
162
|
+
// so extra firings at threshold[1] would be no-ops.
|
|
163
|
+
if (this.visibility_culling && !this.#io) {
|
|
164
|
+
this.#io = new IntersectionObserver((entries) => this.#process_near(entries), {
|
|
165
|
+
rootMargin: `${OVERSCAN_PX}px`,
|
|
166
|
+
threshold: [0]
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
// View IO: threshold [0, 1] is load-bearing — #apply_view_classes
|
|
170
|
+
// toggles .fully-in-view, which flips when the node crosses the
|
|
171
|
+
// fully-visible boundary at ratio 1.
|
|
172
|
+
if (this.view_classes && !this.#view_io) {
|
|
173
|
+
this.#view_io = new IntersectionObserver((entries) => this.#process_view(entries), {
|
|
174
|
+
threshold: [0, 1]
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
stop() {
|
|
180
|
+
this.#io?.disconnect();
|
|
181
|
+
this.#io = null;
|
|
182
|
+
this.#view_io?.disconnect();
|
|
183
|
+
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();
|
|
188
|
+
this.edge_map.clear();
|
|
189
|
+
for (const set of this.#array_indices.values()) set.clear();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Returns the (lazily created, stable) SvelteSet for an array path.
|
|
194
|
+
* Callers should hold the returned reference for the lifetime of the
|
|
195
|
+
* subscription — the same set is returned on subsequent calls so
|
|
196
|
+
* Svelte's per-set tracking remains intact across re-evaluations.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} array_path_str
|
|
199
|
+
* @returns {SvelteSet<number>}
|
|
200
|
+
*/
|
|
201
|
+
get_array_indices(array_path_str) {
|
|
202
|
+
let set = this.#array_indices.get(array_path_str);
|
|
203
|
+
if (!set) {
|
|
204
|
+
set = new SvelteSet();
|
|
205
|
+
this.#array_indices.set(array_path_str, set);
|
|
206
|
+
}
|
|
207
|
+
return set;
|
|
208
|
+
}
|
|
209
|
+
|
|
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) {
|
|
228
|
+
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
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (any_change) this.sync_gaps_around_node(el);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
this.#view_io?.observe(el);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @param {HTMLElement} el */
|
|
250
|
+
unobserve(el) {
|
|
251
|
+
this.#io?.unobserve(el);
|
|
252
|
+
this.#view_io?.unobserve(el);
|
|
253
|
+
const path = el.dataset?.path;
|
|
254
|
+
if (path) this.#forget(path);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
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
|
|
263
|
+
*/
|
|
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
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
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
|
|
283
|
+
*/
|
|
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);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Update edge_map for an array from its scrollLeft/scrollWidth/
|
|
293
|
+
* clientWidth (and the y-axis equivalents). Returns true when the
|
|
294
|
+
* state actually changed. Uses only the array's own scroll metrics
|
|
295
|
+
* — no BCRs, no per-node measurements, no querying children — so
|
|
296
|
+
* each call is ~6 property reads on one element regardless of how
|
|
297
|
+
* many nodes the array has.
|
|
298
|
+
*
|
|
299
|
+
* For a non-overflowing array (scrollWidth === clientWidth, etc.)
|
|
300
|
+
* both checks pass, so first/last gaps stay visible as expected.
|
|
301
|
+
* For an overflowing array, first is true only when scrolled to
|
|
302
|
+
* the start and last only when scrolled to the end (within
|
|
303
|
+
* EDGE_TOLERANCE_PX).
|
|
304
|
+
*
|
|
305
|
+
* Writes to edge_map are wrapped in untrack() so this can be
|
|
306
|
+
* called transitively from $effect contexts without self-
|
|
307
|
+
* invalidation.
|
|
308
|
+
*
|
|
309
|
+
* @param {Element} array_el
|
|
310
|
+
* @returns {boolean}
|
|
311
|
+
*/
|
|
312
|
+
sync_edge_state(array_el) {
|
|
313
|
+
if (!array_el) return false;
|
|
314
|
+
const path = /** @type {HTMLElement} */ (array_el).dataset.path;
|
|
315
|
+
if (!path) return false;
|
|
316
|
+
|
|
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
|
+
|
|
324
|
+
const style = getComputedStyle(array_el);
|
|
325
|
+
const clips_x = style.overflowX !== 'visible';
|
|
326
|
+
const clips_y = style.overflowY !== 'visible';
|
|
327
|
+
|
|
328
|
+
// Only use scroll metrics on axes where this array actually clips/scrolls.
|
|
329
|
+
// Normal wrapping/grid node arrays often have harmless layout overflow
|
|
330
|
+
// (scrollWidth > clientWidth) while overflow remains visible; treating
|
|
331
|
+
// 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);
|
|
338
|
+
|
|
339
|
+
return untrack(() => {
|
|
340
|
+
const prev = this.edge_map.get(path);
|
|
341
|
+
if (prev && prev.first === first && prev.last === last) return false;
|
|
342
|
+
this.edge_map.set(path, { first, last });
|
|
343
|
+
return true;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
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
|
+
/**
|
|
423
|
+
* @param {HTMLElement} el
|
|
424
|
+
* @param {boolean} in_viewport
|
|
425
|
+
* @param {DOMRectReadOnly} bcr
|
|
426
|
+
* @param {number} vh
|
|
427
|
+
*/
|
|
428
|
+
#apply_view_classes(el, in_viewport, bcr, vh) {
|
|
429
|
+
const cl = el.classList;
|
|
430
|
+
cl.toggle('in-view', in_viewport);
|
|
431
|
+
if (in_viewport) {
|
|
432
|
+
cl.add('seen');
|
|
433
|
+
const top_clipped = bcr.top < -0.5;
|
|
434
|
+
const bottom_clipped = bcr.bottom > vh + 0.5;
|
|
435
|
+
cl.toggle('fully-in-view', !top_clipped && !bottom_clipped);
|
|
436
|
+
cl.toggle('visible-top', !top_clipped && bottom_clipped);
|
|
437
|
+
cl.toggle('visible-bottom', top_clipped && !bottom_clipped);
|
|
438
|
+
} else {
|
|
439
|
+
cl.remove('fully-in-view', 'visible-top', 'visible-bottom');
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
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
|
+
/**
|
|
456
|
+
* @param {string} path
|
|
457
|
+
* @returns {{ array_path: string, index: number } | null}
|
|
458
|
+
*/
|
|
459
|
+
#split_path(path) {
|
|
460
|
+
const sep = path.lastIndexOf(PATH_SEPARATOR);
|
|
461
|
+
if (sep < 0) return null;
|
|
462
|
+
const index = parseInt(path.slice(sep + PATH_SEPARATOR.length), 10);
|
|
463
|
+
if (Number.isNaN(index)) return null;
|
|
464
|
+
return { array_path: path.slice(0, sep), index };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Overscan IO callback. Manages near_map and array_indices, then
|
|
469
|
+
* imperatively toggles .positioned on adjacent gaps.
|
|
470
|
+
*
|
|
471
|
+
* @param {IntersectionObserverEntry[]} entries
|
|
472
|
+
*/
|
|
473
|
+
#process_near(entries) {
|
|
474
|
+
for (const entry of entries) {
|
|
475
|
+
const el = /** @type {HTMLElement} */ (entry.target);
|
|
476
|
+
const path = el.dataset.path;
|
|
477
|
+
if (!path) continue;
|
|
478
|
+
|
|
479
|
+
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);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Viewport IO callback. Toggles view classes on node elements.
|
|
506
|
+
* Runs on a separate observer with rootMargin 0 so thresholds
|
|
507
|
+
* fire at actual viewport edges, not the overscan boundary.
|
|
508
|
+
*
|
|
509
|
+
* @param {IntersectionObserverEntry[]} entries
|
|
510
|
+
*/
|
|
511
|
+
#process_view(entries) {
|
|
512
|
+
const vh = window.innerHeight;
|
|
513
|
+
for (const entry of entries) {
|
|
514
|
+
const el = /** @type {HTMLElement} */ (entry.target);
|
|
515
|
+
if (!el.dataset.path) continue;
|
|
516
|
+
this.#apply_view_classes(el, entry.isIntersecting, entry.boundingClientRect, vh);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
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
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Install the visibility registry on the svedit context and wire up
|
|
530
|
+
* the IntersectionObserver + MutationObserver lifecycle to editable
|
|
531
|
+
* state.
|
|
532
|
+
*
|
|
533
|
+
* @param {object} svedit - Svedit context (session, editable, canvas_el)
|
|
534
|
+
*/
|
|
535
|
+
export function create_node_visibility(svedit) {
|
|
536
|
+
const registry = new VisibilityRegistry();
|
|
537
|
+
registry.view_classes = svedit.session?.config?.view_classes !== false;
|
|
538
|
+
registry.visibility_culling = svedit.session?.config?.visibility_culling !== false;
|
|
539
|
+
svedit.visibility_registry = registry;
|
|
540
|
+
|
|
541
|
+
$effect(() => {
|
|
542
|
+
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
|
+
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
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Scroll listener for nested scroll containers. IntersectionObserver
|
|
575
|
+
* doesn't reliably re-fire when the user scrolls inside a non-root
|
|
576
|
+
* scrollable element (Chrome's IO samples on viewport scroll, not on
|
|
577
|
+
* intermediate scroll-container scrolls), so we listen for scroll
|
|
578
|
+
* events on the document in capture phase. The handler is hot but
|
|
579
|
+
* cheap: a single `.closest()` check exits in O(1) for the common
|
|
580
|
+
* case (page scroll → target is document/body, not an array) and
|
|
581
|
+
* coalesces array scrolls into one RAF that reads only scrollLeft /
|
|
582
|
+
* scrollWidth / clientWidth — no per-node BCRs.
|
|
583
|
+
*/
|
|
584
|
+
/** @type {Set<Element>} */
|
|
585
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive RAF queue.
|
|
586
|
+
const pending_scroll_arrays = new Set();
|
|
587
|
+
let scroll_raf = 0;
|
|
588
|
+
function flush_scroll_sync() {
|
|
589
|
+
scroll_raf = 0;
|
|
590
|
+
for (const arr of pending_scroll_arrays) {
|
|
591
|
+
if (arr.isConnected) registry.sync_array_edge_gaps(arr);
|
|
592
|
+
}
|
|
593
|
+
pending_scroll_arrays.clear();
|
|
594
|
+
}
|
|
595
|
+
/** @param {Event} event */
|
|
596
|
+
function on_scroll(event) {
|
|
597
|
+
const target = /** @type {Element | null} */ (event.target);
|
|
598
|
+
const arr = target?.closest?.('[data-type="node_array"]');
|
|
599
|
+
if (!arr) return;
|
|
600
|
+
pending_scroll_arrays.add(arr);
|
|
601
|
+
if (!scroll_raf) scroll_raf = requestAnimationFrame(flush_scroll_sync);
|
|
602
|
+
}
|
|
603
|
+
document.addEventListener('scroll', on_scroll, { capture: true, passive: true });
|
|
604
|
+
|
|
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
|
+
return () => {
|
|
657
|
+
document.removeEventListener('scroll', on_scroll, true);
|
|
658
|
+
cancelAnimationFrame(raf);
|
|
659
|
+
cancelAnimationFrame(scroll_raf);
|
|
660
|
+
pending_scroll_arrays.clear();
|
|
661
|
+
mo?.disconnect();
|
|
662
|
+
registry.stop();
|
|
663
|
+
};
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
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.
|
|
675
|
+
*
|
|
676
|
+
* @param {VisibilityRegistry} registry
|
|
677
|
+
* @param {string} array_path_str
|
|
678
|
+
* @param {number} offset
|
|
679
|
+
* @param {boolean} is_last
|
|
680
|
+
* @param {boolean} empty
|
|
681
|
+
*/
|
|
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
|
+
}
|
|
695
|
+
|
|
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
|
+
}
|
|
707
|
+
|
|
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
|
+
}
|
|
712
|
+
|
|
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
|
+
});
|
|
718
|
+
}
|