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