-
Notifications
You must be signed in to change notification settings - Fork 18
Expand file tree
/
Copy pathNodeGap.svelte
More file actions
424 lines (401 loc) · 16.9 KB
/
Copy pathNodeGap.svelte
File metadata and controls
424 lines (401 loc) · 16.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
<script module lang="ts">
function cycle_z_index(e: MouseEvent) {
const el = e.currentTarget as HTMLElement;
if (el.hasAttribute('data-sent-to-back')) {
document.querySelectorAll('.svedit-selectable[data-sent-to-back]').forEach((prev) => {
prev.removeAttribute('data-sent-to-back');
});
}
el.setAttribute('data-sent-to-back', '');
}
</script>
<script lang="ts">
import { serialize_path } from './utils.js';
import { getContext } from 'svelte';
import { should_position_gap } from './node_visibility.svelte.js';
import type { DocumentPath, SveditRenderContext } from './types.js';
/**
* ┌─────────────────────────────────────────────────────────────────┐
* │ MUST RULES — do not violate when modifying this file │
* ├─────────────────────────────────────────────────────────────────┤
* │ 1. Edge gaps (gap-before at offset 0 / gap-after.last) MUST │
* │ render OUTSIDE the node-array container — they extend into │
* │ the whitespace above/below (column) or left/right (row) of │
* │ the first/last node. They only clamp when the consumer │
* │ explicitly opts in via --node-caret-boundary(-x/-y). │
* │ 2. Gaps MUST NEVER overlap nodes. They render strictly in the │
* │ whitespace between nodes (mid gaps) or outside the node │
* │ array bounds (edge gaps). Never on top of a node. │
* └─────────────────────────────────────────────────────────────────┘
*
* Invisible keyboard caret selectable and gap hit area.
*
* Rendered as a sibling of Node elements inside NodeArrayProperty,
* interleaved between nodes in DOM order. This ensures:
* - Correct DOM order for native drag-to-select across nodes
* - No containing-block issues from transform/filter/will-change on nodes
*
* Always present in the DOM when editable (stable structure for
* selection anchoring, scrollTo, etc.). Anchor positioning is
* activated lazily via the `.positioned` class, derived reactively
* from the visibility registry's array_indices / edge_map. Svelte's
* reactive collections track reads at per-key granularity, so a
* registry write only
* re-evaluates the adjacent gaps. Applying the class declaratively
* (instead of imperative classList toggles) means it survives DOM
* recreation without a document change (e.g. dev-mode HMR).
*
* `position-visibility: anchors-visible` does NOT skip anchor
* resolution — the browser resolves all anchor() then hides the
* result. The `.positioned` class toggle is required to prevent
* O(N) anchor resolution on every layout pass.
*
* Row/column detection uses var(--row, 1) with the * 99999 multiplier
* trick — no @container style() queries, works in all browsers.
*
* If you override this component via `system_components.node_gap`, make
* sure your custom component also handles read-only mode by rendering a
* plain `.node-gap` placeholder without editable internals.
*/
const svedit = getContext<SveditRenderContext>('svedit');
let {
array_path,
offset,
count,
empty = false
}: { array_path: DocumentPath; offset: number; count: number; empty?: boolean } = $props();
let is_editable = $derived(svedit.editable);
let is_first = $derived(offset === 0);
let is_last = $derived(offset === count);
let type = $derived(is_first ? 'gap-before' : 'gap-after');
let path_str = $derived(serialize_path(array_path));
// Two-derived split is load-bearing: get_array_indices lazily
// creates the set, and Svelte doesn't track deps on state created
// inside the reading derived — so acquire here, read in `positioned`.
let near_indices = $derived(svedit.visibility_registry.get_array_indices(path_str));
let positioned = $derived(
is_editable &&
should_position_gap(
near_indices,
svedit.visibility_registry.edge_map.get(path_str),
offset,
is_last,
empty
)
);
let gap_style = $derived.by(() => {
if (!is_editable) return '';
const prev_idx = offset - 1;
const pa = is_first
? `--${serialize_path([...array_path, 0])}`
: `--${serialize_path([...array_path, prev_idx])}`;
const next = `--${serialize_path([...array_path, offset])}`;
const container = `--${serialize_path(array_path)}`;
return `--_pa:${pa};--_next:${next};--_container:${container}`;
});
let anchor_name = $derived.by(() => {
if (!is_editable) return '';
if (is_first) return `--g-${serialize_path([...array_path, 0])}-gap-before`;
return `--g-${serialize_path([...array_path, offset - 1])}-gap-after`;
});
</script>
{#if is_editable}
<div
class="node-gap"
class:gap-before={is_first}
class:gap-after={!is_first}
class:empty
class:last={is_last}
class:positioned
data-type={type}
data-gap-array-path={path_str}
data-gap-offset={offset}
style={gap_style}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="svedit-selectable" style="anchor-name:{anchor_name}" onpointerdown={cycle_z_index}>
<br />
</div>
</div>
{:else}
<div class="node-gap"></div>
{/if}
<style>
.node-gap {
display: contents;
/* The native browser caret briefly appears inside .svedit-selectable for one frame
before the model-driven NodeCaret renders at the correct edge position.
Suppressing it here avoids a flash of the native caret. */
caret-color: transparent;
}
/* ------------------------------------------------------------------ */
/* Un-positioned: no layout box at all */
/* ------------------------------------------------------------------ */
/*
* display: none is load-bearing for large documents. A zero-size
* absolutely-positioned selectable still belongs to the containing
* block's out-of-flow list, and the browser lays out EVERY such box
* on EVERY layout pass — measured ~430ms per pass at 2000 nodes
* (~3500 gaps) in Chrome vs ~23ms with the boxes removed. That cost
* hits every keystroke (the per-change reconcile reads a rect for
* every node, and selection rendering forces layout), every window
* resize frame, and every scroll-triggered layout.
*
* Off-screen gaps therefore contribute no layout box. DOM structure
* stays stable (the .node-gap wrapper and selectable elements remain),
* and DOM Ranges may still point into display:none elements, so
* programmatic node selections targeting off-screen gaps keep working.
* Empty-array gaps are excluded: they must stay clickable/visible even
* before reconcile positions them.
*/
:global(.node-gap:not(.positioned):not(.empty) .svedit-selectable) {
display: none;
}
:global(.node-gap:not(.positioned) .svedit-selectable) {
position: absolute;
pointer-events: none;
width: 0;
height: 0;
overflow: clip;
}
/* ------------------------------------------------------------------ */
/* Positioned: full anchor layout */
/* ------------------------------------------------------------------ */
/*
* Anchor references resolved from CSS variable names passed via
* inline style (--_pa = parent anchor, --_next, --_container).
* Same pattern as NodeGapMarkers — keeps JS minimal and anchor() in CSS.
*
* --_pa references the anchor-name of the node this gap belongs to,
* allowing us to position relative to that node's edges.
*
* Edge gaps extend outward and clamp to the containing block edge
* (0px floor). This can overlap neighboring elements when the node
* array isn't alone in its parent. anchor() only sees the border
* box, so it can't detect margin, gap, or parent padding around
* the container. --node-caret-boundary lets consumers set an
* explicit clamp target (a parent element's anchor-name) so edge
* gaps stop at that boundary instead. --node-caret-boundary-x and
* --node-caret-boundary-y override per-axis, falling back to
* --node-caret-boundary when unset.
*/
:global(.node-gap.positioned) {
/* Fallback 9999999px: when --_pa is orphan (node briefly missing during
edits), anchor() without a fallback invalidates the custom property
and `top` falls back to 0, placing the gap at viewport top as a
giant overlay. 9999999px ensures orphan anchors produce huge values
that min() excludes instead.
LIMIT: documents taller or wider than 9999999px will re-surface
the reported giant-overlay bug — the fallback must exceed the
containing block's dimensions to land off-screen. If you need to
support larger documents, bump this value here and in the matching
`* 9999999px` branch-disable trick throughout this file (and in
NodeGapMarkers.svelte). Stay below ~33M px to avoid Blink's
LayoutUnit ceiling. */
--_s-t: anchor(var(--_pa) top, 9999999px);
--_s-b: anchor(var(--_pa) bottom, 9999999px);
--_s-l: anchor(var(--_pa) left, 9999999px);
--_s-r: anchor(var(--_pa) right, 9999999px);
}
:global(.node-gap.positioned.gap-before:not(.empty)) {
--_b-t: anchor(
var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) top,
0px
);
--_b-l: anchor(
var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) left,
0px
);
}
:global(.node-gap.positioned.gap-after:not(.last)) {
--_n-t: anchor(var(--_next) top);
--_n-l: anchor(var(--_next) left);
--_c-r: anchor(var(--_container) right);
}
:global(.node-gap.positioned.gap-after.last),
:global(.node-gap.positioned.gap-before.empty) {
--_c-t: anchor(var(--_container) top);
--_c-b: anchor(var(--_container) bottom);
--_c-l: anchor(var(--_container) left);
--_c-r: anchor(var(--_container) right);
--_b-b: anchor(
var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) bottom,
0px
);
--_b-r: anchor(
var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) right,
0px
);
--_b-bt: anchor(
var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) bottom,
9999999px
);
--_b-rl: anchor(
var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) right,
9999999px
);
}
:global(.node-gap.positioned .svedit-selectable) {
--_eg: var(--node-caret-edge-gap, 24px);
--_gm: var(--node-caret-gap-min-size, 16px);
--_R: var(--row, 1);
--_C: calc(1 - var(--row, 1));
user-select: none;
pointer-events: auto;
position: absolute;
position-anchor: var(--_pa);
position-visibility: anchors-visible;
z-index: var(--node-caret-gap-z-index, 1);
cursor: pointer;
}
:global(.node-gap.positioned .svedit-selectable[data-sent-to-back]) {
z-index: 0;
}
/* ------------------------------------------------------------------ */
/* Merged column / row layout positioning */
/* */
/* Uses var(--row, 1) with the * 99999 multiplier trick: */
/* --_R = var(--row, 1) → 1 in row, 0 in column */
/* --_C = calc(1 - var(--row, 1))→ 1 in column, 0 in row */
/* Inside min(), + var(--_R) * 9999999px disables a col branch in row, */
/* + var(--_C) * 9999999px disables a row branch in column. */
/* ------------------------------------------------------------------ */
/* Between two siblings: col centers vertically, row centers horizontally */
:global(.node-gap.positioned.gap-after:not(.last) .svedit-selectable) {
--_mid: calc((var(--_s-b) + var(--_n-t)) / 2 - var(--_gm) / 2);
top: min(
calc(var(--_s-b) + var(--_R) * 9999999px),
calc(var(--_mid) + var(--_R) * 9999999px),
calc(var(--_s-t) + var(--_C) * 9999999px)
);
bottom: min(
calc(var(--_n-t) + var(--_R) * 9999999px),
calc(var(--_mid) + var(--_R) * 9999999px),
calc(var(--_s-b) + var(--_C) * 9999999px)
);
left: min(
calc(var(--_s-l) + var(--_R) * 9999999px),
calc(var(--_s-r) + var(--_C) * 9999999px),
calc(
(var(--_s-r) + var(--_n-l)) / 2 - var(--_gm) / 2 + max(0px, var(--_s-r) - var(--_n-l)) *
999 + var(--_C) * 9999999px
),
/* Safety clamp for wrap: pins gap inside CB when current/next
wrap across rows. Disabled in nowrap/horizontal-scroll where
next is side-by-side on the same row (n-l > s-r) — there
the other branches position correctly and this clamp would
wrongly force the gap to CB right minus eg. */
calc(
100% - var(--_eg) + max(0px, var(--_n-l) - var(--_s-r) + 0.5px) * 9999 + var(--_C) *
9999999px
)
);
right: min(
calc(var(--_s-r) + var(--_R) * 9999999px),
calc(
max(
0px,
min(
var(--_n-l),
calc((var(--_s-r) + var(--_n-l)) / 2 - var(--_gm) / 2),
max(
min(calc(var(--_c-r) - var(--_eg)), calc(var(--_s-r) - var(--_eg))),
calc(var(--_s-r) - (var(--_n-l) - var(--_s-r)) * 999)
)
)
) +
var(--_C) * 9999999px
)
);
min-height: calc(var(--_gm) * var(--_C));
min-width: calc(var(--_gm) * var(--_R));
/* min-height: max(calc(var(--_gm) * var(--_C)), calc(anchor-size(var(--_pa) height, 100%) * var(--_R)));
min-width: max(calc(var(--_gm) * var(--_R)), calc(anchor-size(var(--_pa) width, 100%) * var(--_C))); */
}
/* After last node: col extends down, row extends right.
top also clamps to boundary_bottom - eg so that min-height
(which wins over bottom in overconstrained abs-pos) cannot
push the element past the boundary.
left's third branch (100% - --_eg + max(0, --_s-r - 100% + 0.5px) * 9999)
is the wrap-layout safety clamp: in column or row+wrap layouts
where the last node sits WITHIN the CB (--_s-r < 100%), it pins
the gap inside the CB so it doesn't extend past the right edge.
The * 9999 multiplier disables this branch in nowrap horizontal-
scroll (where --_s-r > 100% — the trailing node has overflowed
the CB and the gap is expected to follow it).
right's `min(--_c-r - --_eg, --_s-r - --_eg)` is what fills the
gap into the whitespace between the last node and the container
in non-overflow layouts. In non-overflow, --_c-r < --_s-r in the
`right` axis (container right is further left than node right
from the CB right edge), so min picks --_c-r - --_eg and the gap
ends at container.right + --_eg (the MUST-RULE overshoot). In
overflow, --_s-r < --_c-r and min picks --_s-r - --_eg — the gap
follows the node out past the container. */
:global(.node-gap.positioned.gap-after.last .svedit-selectable) {
top: min(
calc(min(var(--_s-b), calc(var(--_b-bt) - var(--_eg))) + var(--_R) * 9999999px),
calc(var(--_s-t) + var(--_C) * 9999999px)
);
bottom: min(
calc(max(var(--_b-b), var(--_s-b) - var(--_eg)) + var(--_R) * 9999999px),
calc(var(--_s-b) + var(--_C) * 9999999px)
);
left: min(
calc(var(--_s-l) + var(--_R) * 9999999px),
calc(min(var(--_s-r), calc(var(--_b-rl) - var(--_eg))) + var(--_C) * 9999999px),
calc(100% - var(--_eg) + max(0px, var(--_s-r) - 100% + 0.5px) * 9999 + var(--_C) * 9999999px)
);
right: min(
calc(var(--_s-r) + var(--_R) * 9999999px),
calc(
max(var(--_b-r), min(calc(var(--_c-r) - var(--_eg)), calc(var(--_s-r) - var(--_eg)))) +
var(--_C) * 9999999px
)
);
min-height: calc(var(--_eg) * var(--_C));
min-width: calc(var(--_eg) * var(--_R));
}
/* Before first node: col extends up, row extends left */
:global(.node-gap.positioned.gap-before:not(.empty) .svedit-selectable) {
top: min(
calc(max(var(--_b-t), var(--_s-t) - var(--_eg)) + var(--_R) * 9999999px),
calc(var(--_s-t) + var(--_C) * 9999999px)
);
bottom: min(
calc(var(--_s-t) + var(--_R) * 9999999px),
calc(var(--_s-b) + var(--_C) * 9999999px)
);
left: min(
calc(var(--_s-l) + var(--_R) * 9999999px),
calc(max(var(--_b-l), var(--_s-l) - var(--_eg)) + var(--_C) * 9999999px)
);
right: min(
calc(var(--_s-r) + var(--_R) * 9999999px),
calc(var(--_s-l) + var(--_C) * 9999999px)
);
min-height: calc(var(--_eg) * var(--_C));
min-width: calc(var(--_eg) * var(--_R));
}
/* Empty array: the gap fills its placeholder, which is this
selectable's containing block (NodeArrayProperty sets it
position: relative). inset:0 fills it with no anchor() — so no
.positioned gating and no anchor cost. The width:auto, height:auto
and pointer-events:auto override the :not(.positioned) 0×0
collapse; position-visibility:always overrides the .positioned
rule's anchors-visible, so a transient positioned toggle can't
hide the gap. */
:global(.node-gap.gap-before.empty .svedit-selectable) {
position: absolute;
inset: 0;
width: auto;
height: auto;
pointer-events: auto;
position-visibility: always;
}
/* Debugging styles - DO NOT CHANGE OR REMOVE */
/* :global(.node-gap.positioned .svedit-selectable) {
outline: 2px solid rgba(238, 0, 255, 0.5);
background-color: rgba(238, 0, 255, 0.5);
outline-offset: -0.5px;
} */
</style>