@jinntec/fore 3.3.2 → 4.0.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 +98 -70
- package/dist/fore-dev.js +5593 -6832
- package/dist/fore.js +5602 -4887
- package/index.js +5 -10
- package/package.json +7 -7
- package/resources/fore.css +33 -0
- package/src/DependencyNotifyingDomFacade.js +90 -21
- package/src/DependentXPathQueries.js +15 -2
- package/src/ForeElementMixin.js +110 -16
- package/src/UndoManager.js +267 -0
- package/src/actions/abstract-action.js +71 -30
- package/src/actions/fx-action.js +5 -0
- package/src/actions/fx-append.js +3 -3
- package/src/actions/fx-commit-history.js +26 -0
- package/src/actions/fx-hide.js +1 -1
- package/src/actions/fx-insert.js +25 -22
- package/src/actions/fx-load.js +5 -5
- package/src/actions/fx-redo.js +58 -0
- package/src/actions/fx-refresh.js +2 -2
- package/src/actions/fx-reload.js +1 -1
- package/src/actions/fx-replace.js +1 -1
- package/src/actions/fx-send.js +27 -5
- package/src/actions/fx-setattribute.js +11 -7
- package/src/actions/fx-undo.js +58 -0
- package/src/createNodes.js +314 -0
- package/src/fore.js +53 -18
- package/src/functions/fx-functionlib.js +10 -10
- package/src/fx-bind.js +30 -18
- package/src/fx-fore.js +222 -200
- package/src/fx-instance.js +18 -1
- package/src/fx-model.js +236 -69
- package/src/fx-submission.js +37 -29
- package/src/fx-var.js +49 -13
- package/src/getInScopeContext.js +1 -1
- package/src/json/JSONDomFacade.js +1 -1
- package/src/json/JSONLens.js +2 -2
- package/src/ui/UIElement.js +18 -8
- package/src/ui/abstract-control.js +45 -3
- package/src/ui/fx-alert.js +4 -0
- package/src/ui/fx-case.js +1 -1
- package/src/ui/fx-container.js +3 -0
- package/src/ui/fx-control-menu.js +79 -11
- package/src/ui/fx-control.js +130 -41
- package/src/ui/fx-dialog.js +5 -0
- package/src/ui/fx-items.js +6 -6
- package/src/ui/fx-output.js +37 -1
- package/src/ui/fx-repeat.js +1065 -103
- package/src/ui/fx-repeatitem.js +4 -1
- package/src/ui/fx-switch.js +116 -3
- package/src/ui/fx-trigger.js +9 -4
- package/src/ui/fx-upload.js +10 -4
- package/src/ui/repeat-base.js +20 -12
- package/src/withDraggability.js +10 -1
- package/src/xpath-evaluation.js +30 -18
- package/src/xpath-path.js +122 -0
- package/src/xpath-util.js +11 -126
- package/src/actions/StringTpl.js +0 -17
- package/src/extract-predicate-deps.js +0 -57
- package/src/extractPredicateDependencies.js +0 -36
- package/src/json/lensFromNode.js +0 -5
- package/src/json-util.js +0 -27
- package/src/tools/adi.js +0 -1111
- package/src/tools/deprecation.md +0 -1
- package/src/tools/fx-action-log.js +0 -745
- package/src/tools/fx-devtools.js +0 -444
- package/src/tools/fx-dom-inspector.js +0 -610
- package/src/tools/fx-json-instance.js +0 -444
- package/src/tools/fx-log-item.js +0 -128
- package/src/tools/fx-log-settings.js +0 -533
- package/src/tools/fx-minimap.js +0 -203
- package/src/tools/helpers.js +0 -132
- package/src/ui/TemplateExpression.js +0 -12
- package/src/ui/fx-dom-inspector.js +0 -1255
package/src/ui/fx-repeat.js
CHANGED
|
@@ -10,6 +10,7 @@ import { UIElement } from './UIElement.js';
|
|
|
10
10
|
import { getPath } from '../xpath-path.js';
|
|
11
11
|
import { FxModel } from '../fx-model.js';
|
|
12
12
|
import { FxBind } from '../fx-bind.js';
|
|
13
|
+
import { DependencyNotifyingDomFacade } from '../DependencyNotifyingDomFacade.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* `fx-repeat`
|
|
@@ -59,10 +60,38 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
59
60
|
this.nodeset = [];
|
|
60
61
|
this.inited = false;
|
|
61
62
|
this.index = 1;
|
|
62
|
-
this.repeatSize = 0;
|
|
63
63
|
this.attachShadow({ mode: 'open', delegatesFocus: true });
|
|
64
64
|
this.opNum = 0; // global number of operations
|
|
65
65
|
|
|
66
|
+
// Progressive rendering (size cap + IntersectionObserver sentinel).
|
|
67
|
+
// `_sentinel !== null` is the single source of truth for "a cap is active and there
|
|
68
|
+
// is more to reveal" - see _syncSentinels().
|
|
69
|
+
this._sizeLimit = Infinity;
|
|
70
|
+
this._renderTarget = 0;
|
|
71
|
+
this._sentinel = null;
|
|
72
|
+
this._sentinelObserver = null;
|
|
73
|
+
|
|
74
|
+
// True windowed virtualization (opt-in via `virtual` attribute + `size`).
|
|
75
|
+
// `_windowStart` is the 0-based logical index of the first RENDERED row; it is always 0
|
|
76
|
+
// when `_virtual` is false, which is what makes every `+ this._windowStart` offset below
|
|
77
|
+
// degenerate to exactly today's prefix-window math for non-virtual repeats.
|
|
78
|
+
this._virtual = false;
|
|
79
|
+
this._windowStart = 0;
|
|
80
|
+
this._topSentinel = null;
|
|
81
|
+
this._topSentinelObserver = null;
|
|
82
|
+
|
|
83
|
+
// Continuous scroll-driven trim (virtual mode only). The sentinels only fire once the
|
|
84
|
+
// window's leading/trailing edge is reached, which (given the sentinel sits at the very
|
|
85
|
+
// end of whatever was just rendered) forces scrolling through almost a full window's
|
|
86
|
+
// worth of content before a slide+evict happens - see _trimWindow() for why that alone
|
|
87
|
+
// lets the rendered count settle well above `size`. This listener evicts confirmed
|
|
88
|
+
// off-screen rows continuously as the user scrolls, so the window keeps converging on
|
|
89
|
+
// `size` instead of only trimming in bursts tied to sentinel crossings.
|
|
90
|
+
this._scrollTrimRoot = null;
|
|
91
|
+
this._scrollTrimHandler = null;
|
|
92
|
+
this._scrollTrimLastTop = 0;
|
|
93
|
+
this._trimScheduled = false;
|
|
94
|
+
|
|
66
95
|
this.handleInsertHandler = null;
|
|
67
96
|
this.handleDeleteHandler = null;
|
|
68
97
|
|
|
@@ -139,6 +168,37 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
139
168
|
return true;
|
|
140
169
|
}
|
|
141
170
|
|
|
171
|
+
// ------------------------------------------------------------
|
|
172
|
+
// Progressive rendering (size cap)
|
|
173
|
+
// ------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
_getSizeLimit() {
|
|
176
|
+
const attr = this.getAttribute('size');
|
|
177
|
+
if (!attr) return Infinity;
|
|
178
|
+
const n = parseInt(attr, 10);
|
|
179
|
+
return Number.isFinite(n) && n > 0 ? n : Infinity;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* True windowed virtualization is opt-in via `virtual`, and only meaningful together with
|
|
184
|
+
* a finite `size` (which becomes the window size rather than a prefix cap). `virtual`
|
|
185
|
+
* without `size` degenerates to fully uncapped, exactly like `size` being absent.
|
|
186
|
+
*/
|
|
187
|
+
_isVirtual() {
|
|
188
|
+
return this.hasAttribute('virtual') && this._getSizeLimit() !== Infinity;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Upper bound on how many rows _evictFromTop()/_evictFromBottom() remove in a single call.
|
|
193
|
+
* A quarter of `size` (floor 4) keeps a single correction small no matter how large a
|
|
194
|
+
* backlog a caller asks to clear - see the comment on _evictFromTop() for why an uncapped
|
|
195
|
+
* batch turns into a scroll-speed-proportional scrollTop jump. Any excess beyond this cap
|
|
196
|
+
* simply drains on the next call (there always is one while scrolling continues).
|
|
197
|
+
*/
|
|
198
|
+
_maxEvictBatch() {
|
|
199
|
+
return Math.max(4, Math.ceil(this._sizeLimit / 4));
|
|
200
|
+
}
|
|
201
|
+
|
|
142
202
|
connectedCallback() {
|
|
143
203
|
super.connectedCallback();
|
|
144
204
|
this.template = this.querySelector('template');
|
|
@@ -209,6 +269,7 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
209
269
|
|
|
210
270
|
const inserted = detail.insertedNodes;
|
|
211
271
|
const insertionIndex = this.nodeset.indexOf(inserted) + 1; // 1-based
|
|
272
|
+
const relIdx = insertionIndex - 1; // 0-based logical index of the inserted row
|
|
212
273
|
|
|
213
274
|
const repeatItems = Array.from(
|
|
214
275
|
this.querySelectorAll(
|
|
@@ -216,32 +277,86 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
216
277
|
),
|
|
217
278
|
);
|
|
218
279
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
280
|
+
// Uncapped repeats always materialize immediately: `_renderTarget` is only resynced to
|
|
281
|
+
// the full nodeset length inside refresh(), which may not have run yet between two
|
|
282
|
+
// synchronous inserts, so it can't be trusted as a window boundary when uncapped.
|
|
283
|
+
// Zone B (within window): today's "within window" case, offset by `_windowStart`.
|
|
284
|
+
const withinWindow =
|
|
285
|
+
this._sizeLimit === Infinity ||
|
|
286
|
+
(relIdx >= this._windowStart && relIdx < this._renderTarget);
|
|
287
|
+
// Zone A (before window): only reachable in virtual mode - `_windowStart` is always 0
|
|
288
|
+
// off virtual mode, so `relIdx < 0` never holds.
|
|
289
|
+
const beforeWindow = this._virtual && relIdx < this._windowStart;
|
|
290
|
+
|
|
291
|
+
if (withinWindow) {
|
|
292
|
+
const relativePos = relIdx - this._windowStart;
|
|
293
|
+
const newRepeatItem = this._createNewRepeatItem();
|
|
294
|
+
|
|
295
|
+
const beforeNode = repeatItems[relativePos] ?? this._sentinel ?? null;
|
|
296
|
+
this.insertBefore(newRepeatItem, beforeNode);
|
|
297
|
+
newRepeatItem.index = insertionIndex;
|
|
298
|
+
this._initVariables(newRepeatItem);
|
|
299
|
+
|
|
300
|
+
newRepeatItem.nodeset = inserted;
|
|
301
|
+
|
|
302
|
+
for (let i = relativePos; i < repeatItems.length; ++i) {
|
|
303
|
+
repeatItems[i].index += 1;
|
|
304
|
+
}
|
|
227
305
|
|
|
228
|
-
|
|
229
|
-
|
|
306
|
+
this._renderTarget += 1;
|
|
307
|
+
// In virtual mode, an in-window insert momentarily grows the window past `size` -
|
|
308
|
+
// evict one row from the opposite (bottom) end to restore window size. This is a
|
|
309
|
+
// local, in-place insert, not a scroll-driven slide, so no scrollTop correction is
|
|
310
|
+
// needed either way (known limitation: this always evicts from the bottom regardless
|
|
311
|
+
// of where in the window the insert landed - see plan's "known limitation" note).
|
|
312
|
+
if (this._virtual && this._renderTarget - this._windowStart > this._sizeLimit) {
|
|
313
|
+
this._evictFromBottom(1);
|
|
314
|
+
}
|
|
315
|
+
this._syncSentinels();
|
|
316
|
+
|
|
317
|
+
this.opNum++;
|
|
318
|
+
let parentModelItem = FxBind.createModelItem(this.ref, inserted, newRepeatItem, this.opNum);
|
|
319
|
+
// IMPORTANT: registerModelItem may return an existing canonical ModelItem for the same path.
|
|
320
|
+
// Always keep using the returned instance to avoid "ghost" ModelItems that still notify.
|
|
321
|
+
parentModelItem = this.getModel().registerModelItem(parentModelItem);
|
|
322
|
+
newRepeatItem.modelItem = parentModelItem;
|
|
323
|
+
|
|
324
|
+
this._createModelItemsRecursively(newRepeatItem, parentModelItem);
|
|
325
|
+
|
|
326
|
+
fore.scanForNewTemplateExpressionsNextRefresh();
|
|
327
|
+
fore.addToBatchedNotifications(newRepeatItem);
|
|
328
|
+
} else if (beforeWindow) {
|
|
329
|
+
// Zone A: inserted above the rendered window. The rendered rows are still correct
|
|
330
|
+
// (same node identities) - only their logical position shifted by one. No DOM
|
|
331
|
+
// change; just shift the window's bookkeeping to match.
|
|
332
|
+
this._windowStart += 1;
|
|
333
|
+
this._renderTarget += 1;
|
|
334
|
+
this._reindexRenderedRows();
|
|
335
|
+
this._syncSentinels();
|
|
336
|
+
|
|
337
|
+
// IMPORTANT: do NOT fall through to the shared setIndex(insertionIndex) below, and do
|
|
338
|
+
// NOT route the index bump through setIndex() either - both would run the
|
|
339
|
+
// window-membership check and, since `this.index` (still pointing wherever it did
|
|
340
|
+
// before this insert - raw scrolling never updates it, only setIndex() does) may now
|
|
341
|
+
// resolve to a position outside the window we just deliberately left untouched,
|
|
342
|
+
// trigger an unwanted seek-jump that destroys/rebuilds this exact window. Nothing
|
|
343
|
+
// rendered actually moved (Zone A never touches DOM), so only the numeric index
|
|
344
|
+
// needs to shift to keep pointing at the same logical row - update the attribute
|
|
345
|
+
// directly. Known limitation: index()-dependents elsewhere in the form aren't
|
|
346
|
+
// notified of this silent renumbering (no 'item-changed' dispatch) - acceptable since
|
|
347
|
+
// Zone A only fires for inserts above an actively-scrolled-away window, an already
|
|
348
|
+
// rare interaction.
|
|
349
|
+
if (this.index >= insertionIndex) {
|
|
350
|
+
this.index += 1;
|
|
351
|
+
}
|
|
352
|
+
return;
|
|
230
353
|
}
|
|
354
|
+
// else: Zone C - inserted beyond the rendered window. this.nodeset already reflects
|
|
355
|
+
// the insert (re-evaluated above) - there's no DOM row to create yet. setIndex() below
|
|
356
|
+
// triggers growth-on-demand (non-virtual) or a seek jump (virtual) if navigation
|
|
357
|
+
// reaches that far.
|
|
231
358
|
|
|
232
359
|
this.setIndex(insertionIndex);
|
|
233
|
-
|
|
234
|
-
this.opNum++;
|
|
235
|
-
let parentModelItem = FxBind.createModelItem(this.ref, inserted, newRepeatItem, this.opNum);
|
|
236
|
-
// IMPORTANT: registerModelItem may return an existing canonical ModelItem for the same path.
|
|
237
|
-
// Always keep using the returned instance to avoid "ghost" ModelItems that still notify.
|
|
238
|
-
parentModelItem = this.getModel().registerModelItem(parentModelItem);
|
|
239
|
-
newRepeatItem.modelItem = parentModelItem;
|
|
240
|
-
|
|
241
|
-
this._createModelItemsRecursively(newRepeatItem, parentModelItem);
|
|
242
|
-
|
|
243
|
-
fore.scanForNewTemplateExpressionsNextRefresh();
|
|
244
|
-
fore.addToBatchedNotifications(newRepeatItem);
|
|
245
360
|
};
|
|
246
361
|
|
|
247
362
|
// ----------------
|
|
@@ -332,16 +447,36 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
332
447
|
-webkit-animation: fade-out-bottom 0.7s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
|
|
333
448
|
animation: fade-out-bottom 0.7s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
|
|
334
449
|
}
|
|
450
|
+
/* A bare <slot> renders as display:contents (layout-transparent), so demos that put
|
|
451
|
+
display:flex/grid on the fx-repeat host (e.g. demo/kanban.html's #column) get their
|
|
452
|
+
fx-repeatitems as direct flex/grid items. The [part=list] wrapper below must stay
|
|
453
|
+
equally transparent or it becomes the sole flex/grid item and items collapse into a
|
|
454
|
+
block stack inside it. role="list" survives display:contents in all supported browsers. */
|
|
455
|
+
[part="list"]{
|
|
456
|
+
display: contents;
|
|
457
|
+
}
|
|
335
458
|
`;
|
|
459
|
+
// `role="list"` sits on a wrapper around the default slot only, not on the host itself:
|
|
460
|
+
// a named `slot="header"` (e.g. a `<table>` used as a column header row, see demo/api.html)
|
|
461
|
+
// is real, non-listitem content that would otherwise become an accessibility-tree child of
|
|
462
|
+
// the list and fail axe's aria-required-children check. Keeping it outside the wrapper avoids
|
|
463
|
+
// that while still giving the repeated `fx-repeatitem`s (each `role="listitem"`) a `list`
|
|
464
|
+
// parent.
|
|
336
465
|
const html = `
|
|
337
466
|
<slot name="header"></slot>
|
|
338
|
-
<slot></slot>
|
|
467
|
+
<div part="list" role="list"><slot></slot></div>
|
|
339
468
|
`;
|
|
340
469
|
|
|
341
|
-
|
|
470
|
+
const sheet = Fore.getSharedStyleSheet(style);
|
|
471
|
+
if (sheet) {
|
|
472
|
+
this.shadowRoot.innerHTML = html;
|
|
473
|
+
this.shadowRoot.adoptedStyleSheets = [sheet];
|
|
474
|
+
} else {
|
|
475
|
+
this.shadowRoot.innerHTML = `
|
|
342
476
|
<style>${style}</style>
|
|
343
477
|
${html}
|
|
344
478
|
`;
|
|
479
|
+
}
|
|
345
480
|
}
|
|
346
481
|
|
|
347
482
|
/**
|
|
@@ -379,40 +514,76 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
379
514
|
insertionIndex1 = Math.max(1, Math.min(insertionIndex1, newLen));
|
|
380
515
|
const pos0 = insertionIndex1 - 1;
|
|
381
516
|
|
|
382
|
-
//
|
|
383
|
-
const
|
|
384
|
-
|
|
517
|
+
// See the equivalent comment in the XML insert handler above.
|
|
518
|
+
const withinWindow =
|
|
519
|
+
this._sizeLimit === Infinity || (pos0 >= this._windowStart && pos0 < this._renderTarget);
|
|
520
|
+
const beforeWindow = this._virtual && pos0 < this._windowStart;
|
|
385
521
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
this._initVariables(newRepeatItem);
|
|
389
|
-
newRepeatItem.nodeset = this.nodeset[pos0];
|
|
522
|
+
if (withinWindow) {
|
|
523
|
+
const relativePos = pos0 - this._windowStart;
|
|
390
524
|
|
|
391
|
-
|
|
525
|
+
// 3) Insert DOM row: set nodeset/index BEFORE inserting into DOM.
|
|
526
|
+
const before = repeatItems();
|
|
527
|
+
const beforeNode = before[relativePos] ?? this._sentinel ?? null;
|
|
392
528
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
fore.scanForNewTemplateExpressionsNextRefresh();
|
|
399
|
-
fore.addToBatchedNotifications(newRepeatItem);
|
|
529
|
+
const newRepeatItem = this._createNewRepeatItem();
|
|
530
|
+
newRepeatItem.index = pos0 + 1;
|
|
531
|
+
this._initVariables(newRepeatItem);
|
|
532
|
+
newRepeatItem.nodeset = this.nodeset[pos0];
|
|
400
533
|
|
|
401
|
-
|
|
402
|
-
const after = repeatItems();
|
|
534
|
+
this.insertBefore(newRepeatItem, beforeNode);
|
|
403
535
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
536
|
+
// Ensure it gets initialized/rendered
|
|
537
|
+
fore.registerLazyElement(newRepeatItem);
|
|
538
|
+
if (fore.createNodes) {
|
|
539
|
+
fore.initData(newRepeatItem);
|
|
540
|
+
}
|
|
541
|
+
fore.scanForNewTemplateExpressionsNextRefresh();
|
|
542
|
+
fore.addToBatchedNotifications(newRepeatItem);
|
|
407
543
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
544
|
+
this._renderTarget += 1;
|
|
545
|
+
// See the equivalent comment in the XML insert handler above.
|
|
546
|
+
if (this._virtual && this._renderTarget - this._windowStart > this._sizeLimit) {
|
|
547
|
+
this._evictFromBottom(1);
|
|
412
548
|
}
|
|
549
|
+
this._syncSentinels();
|
|
413
550
|
|
|
414
|
-
|
|
551
|
+
// 4) Rebind shifted rows (relativePos+1..end) and refresh only those.
|
|
552
|
+
const after = repeatItems();
|
|
553
|
+
|
|
554
|
+
for (let i = relativePos + 1; i < after.length; i++) {
|
|
555
|
+
const ri = after[i];
|
|
556
|
+
const logicalIdx = this._windowStart + i;
|
|
557
|
+
ri.index = logicalIdx + 1;
|
|
558
|
+
|
|
559
|
+
const newNode = this.nodeset[logicalIdx];
|
|
560
|
+
if (ri.nodeset !== newNode) {
|
|
561
|
+
ri.nodeset = newNode;
|
|
562
|
+
if (fore.createNodes) fore.initData(ri);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
fore.addToBatchedNotifications(ri);
|
|
566
|
+
}
|
|
567
|
+
} else if (beforeWindow) {
|
|
568
|
+
// Zone A: see the equivalent comment in the XML insert handler above - must NOT go
|
|
569
|
+
// through setIndex() at all (neither for pos0+1 nor for an adjusted value), since
|
|
570
|
+
// `this.index` may already point outside this deliberately-untouched window (raw
|
|
571
|
+
// scrolling never updates it) and setIndex()'s window-membership check would treat
|
|
572
|
+
// that as an out-of-window navigation, triggering an unwanted seek-jump.
|
|
573
|
+
this._windowStart += 1;
|
|
574
|
+
this._renderTarget += 1;
|
|
575
|
+
this._reindexRenderedRows();
|
|
576
|
+
this._syncSentinels();
|
|
577
|
+
|
|
578
|
+
// insertionIndex1 (outer scope) already equals pos0 + 1.
|
|
579
|
+
if (this.index >= insertionIndex1) {
|
|
580
|
+
this.index += 1;
|
|
581
|
+
}
|
|
582
|
+
return;
|
|
415
583
|
}
|
|
584
|
+
// else: Zone C - inserted beyond the rendered window - no DOM row to create, nothing to
|
|
585
|
+
// rebind. setIndex() below triggers growth-on-demand (non-virtual) or a seek jump
|
|
586
|
+
// (virtual) if navigation reaches that far.
|
|
416
587
|
|
|
417
588
|
// Select inserted row
|
|
418
589
|
this.setIndex(pos0 + 1);
|
|
@@ -435,6 +606,24 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
435
606
|
|
|
436
607
|
document.removeEventListener('deleted', this.handleDeleteHandler, true);
|
|
437
608
|
document.removeEventListener('insert', this.handleInsertHandler, true);
|
|
609
|
+
|
|
610
|
+
if (this._sentinelObserver) {
|
|
611
|
+
this._sentinelObserver.disconnect();
|
|
612
|
+
this._sentinelObserver = null;
|
|
613
|
+
}
|
|
614
|
+
this._sentinel = null;
|
|
615
|
+
|
|
616
|
+
if (this._topSentinelObserver) {
|
|
617
|
+
this._topSentinelObserver.disconnect();
|
|
618
|
+
this._topSentinelObserver = null;
|
|
619
|
+
}
|
|
620
|
+
this._topSentinel = null;
|
|
621
|
+
|
|
622
|
+
if (this._scrollTrimHandler && this._scrollTrimRoot) {
|
|
623
|
+
this._scrollTrimRoot.removeEventListener('scroll', this._scrollTrimHandler);
|
|
624
|
+
}
|
|
625
|
+
this._scrollTrimRoot = null;
|
|
626
|
+
this._scrollTrimHandler = null;
|
|
438
627
|
}
|
|
439
628
|
|
|
440
629
|
get repeatSize() {
|
|
@@ -442,14 +631,33 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
442
631
|
}
|
|
443
632
|
|
|
444
633
|
set repeatSize(size) {
|
|
445
|
-
|
|
634
|
+
if (!size) {
|
|
635
|
+
this.removeAttribute('size');
|
|
636
|
+
} else {
|
|
637
|
+
this.setAttribute('size', size);
|
|
638
|
+
}
|
|
446
639
|
}
|
|
447
640
|
|
|
448
|
-
setIndex(index) {
|
|
641
|
+
setIndex(index, notifyDependents = true) {
|
|
449
642
|
this.index = index;
|
|
450
643
|
|
|
644
|
+
if (this._sizeLimit !== Infinity && Number.isFinite(index) && index >= 1) {
|
|
645
|
+
if (this._virtual) {
|
|
646
|
+
// Sliding window: navigation to an index outside the window can't be front-filled
|
|
647
|
+
// (there's no stable prefix to grow from) - it's a hard jump instead. See
|
|
648
|
+
// _seekWindowTo(). Navigation to an index already inside the window is a no-op here.
|
|
649
|
+
const logicalIdx = index - 1;
|
|
650
|
+
const inWindow = logicalIdx >= this._windowStart && logicalIdx < this._renderTarget;
|
|
651
|
+
if (!inWindow) this._seekWindowTo(logicalIdx);
|
|
652
|
+
} else {
|
|
653
|
+
// Growth-on-demand: navigation (keyboard, index('id') dependents, programmatic) to
|
|
654
|
+
// an index beyond the currently rendered window front-fills up through it.
|
|
655
|
+
this._growRenderTarget(index);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
451
659
|
const rItems = this.querySelectorAll(':scope > fx-repeatitem');
|
|
452
|
-
const selected = rItems[this.index - 1];
|
|
660
|
+
const selected = rItems[this.index - 1 - this._windowStart];
|
|
453
661
|
|
|
454
662
|
this.applyIndex(selected);
|
|
455
663
|
|
|
@@ -458,7 +666,9 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
458
666
|
//
|
|
459
667
|
// When setIndex is invoked as a reaction to a repeatitem click/focus,
|
|
460
668
|
// the repeatitem already dispatched item-changed and dependents already react.
|
|
461
|
-
|
|
669
|
+
// refresh() passes notifyDependents=false when neither the index nor the node at
|
|
670
|
+
// the index changed — a repeat refresh alone is not an index change.
|
|
671
|
+
if (!this._settingIndexFromItemChanged && notifyDependents) {
|
|
462
672
|
this.dispatchEvent(
|
|
463
673
|
new CustomEvent('item-changed', {
|
|
464
674
|
composed: false,
|
|
@@ -512,7 +722,10 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
512
722
|
|
|
513
723
|
if (mi.path.startsWith(`${parentBaseInChildStyle}_`)) return;
|
|
514
724
|
|
|
515
|
-
|
|
725
|
+
this.getModel()._setModelItemPath(
|
|
726
|
+
mi,
|
|
727
|
+
`${parentBaseInChildStyle}_${op}${mi.path.slice(parentBaseInChildStyle.length)}`,
|
|
728
|
+
);
|
|
516
729
|
};
|
|
517
730
|
|
|
518
731
|
Array.from(parentNode.children).forEach(child => {
|
|
@@ -581,28 +794,59 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
581
794
|
if (nextIndex1 > d1) nextIndex1 -= 1;
|
|
582
795
|
}
|
|
583
796
|
|
|
797
|
+
// Three-zone split against [_windowStart, _renderTarget), using PRE-delete indices
|
|
798
|
+
// (indices0 already are pre-delete, via detail.deletedIndexes0 - no ordering hazard here,
|
|
799
|
+
// unlike the XML path's handleDelete()).
|
|
584
800
|
const before = repeatItems();
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
if (!itemEl) continue;
|
|
588
|
-
|
|
589
|
-
try {
|
|
590
|
-
fore?.unRegisterLazyElement?.(itemEl);
|
|
591
|
-
} catch (_e) {}
|
|
801
|
+
let windowShiftFromAbove = 0; // count of Zone-A deletions (above the window)
|
|
802
|
+
let renderedRemovedCount = 0; // count of Zone-B deletions (actually rendered)
|
|
592
803
|
|
|
593
|
-
|
|
804
|
+
for (const idx0 of indices0) {
|
|
805
|
+
if (idx0 < this._windowStart) {
|
|
806
|
+
// Zone A: above window - rendered rows unaffected, window floor shifts down by one.
|
|
807
|
+
windowShiftFromAbove += 1;
|
|
808
|
+
} else if (idx0 < this._renderTarget) {
|
|
809
|
+
// Zone B: rendered.
|
|
810
|
+
const domPos = idx0 - this._windowStart;
|
|
811
|
+
const itemEl = before[domPos];
|
|
812
|
+
if (itemEl) {
|
|
813
|
+
try {
|
|
814
|
+
fore?.unRegisterLazyElement?.(itemEl);
|
|
815
|
+
} catch (_e) {}
|
|
816
|
+
|
|
817
|
+
itemEl.remove();
|
|
818
|
+
renderedRemovedCount += 1;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
// else: Zone C - below window, no DOM row.
|
|
594
822
|
}
|
|
595
823
|
|
|
596
824
|
this._evalNodeset();
|
|
597
825
|
|
|
598
|
-
|
|
599
|
-
|
|
826
|
+
this._windowStart = Math.max(0, this._windowStart - windowShiftFromAbove);
|
|
827
|
+
this._renderTarget = this._windowStart + (before.length - renderedRemovedCount);
|
|
828
|
+
const totalAfterDelete = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
|
|
829
|
+
this._renderTarget = Math.min(this._renderTarget, totalAfterDelete);
|
|
830
|
+
|
|
831
|
+
// Virtual mode: a mid-window delete is not a scroll event - the user still expects a
|
|
832
|
+
// full window, so backfill from the tail if more nodeset remains below.
|
|
833
|
+
if (this._virtual) {
|
|
834
|
+
const deficit = this._sizeLimit - (this._renderTarget - this._windowStart);
|
|
835
|
+
if (deficit > 0 && this._renderTarget < totalAfterDelete) {
|
|
836
|
+
this._slideWindowDown(deficit);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
this._syncSentinels();
|
|
600
840
|
|
|
601
|
-
|
|
841
|
+
// Rebind every currently rendered row to its (possibly shifted) logical node. The
|
|
842
|
+
// `ri.nodeset !== newNode` guard below makes this cheap for rows that didn't move.
|
|
843
|
+
const after = repeatItems();
|
|
844
|
+
for (let i = 0; i < after.length; i++) {
|
|
602
845
|
const ri = after[i];
|
|
603
|
-
|
|
846
|
+
const logicalIdx = this._windowStart + i;
|
|
847
|
+
ri.index = logicalIdx + 1;
|
|
604
848
|
|
|
605
|
-
const newNode = Array.isArray(this.nodeset) ? this.nodeset[
|
|
849
|
+
const newNode = Array.isArray(this.nodeset) ? this.nodeset[logicalIdx] : null;
|
|
606
850
|
if (ri.nodeset !== newNode) {
|
|
607
851
|
ri.nodeset = newNode;
|
|
608
852
|
if (fore?.createNodes) fore.initData(ri);
|
|
@@ -611,19 +855,29 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
611
855
|
fore?.addToBatchedNotifications?.(ri);
|
|
612
856
|
}
|
|
613
857
|
|
|
614
|
-
|
|
615
|
-
|
|
858
|
+
// Clamp against the LOGICAL total, not the rendered DOM count - under a size cap the two
|
|
859
|
+
// differ, and clamping to the rendered count would make it impossible to navigate to a
|
|
860
|
+
// not-yet-rendered logical index (setIndex()'s own growth-on-demand/seek-jump handles
|
|
861
|
+
// materializing it).
|
|
862
|
+
if (totalAfterDelete === 0) {
|
|
616
863
|
this.setAttribute('index', '0');
|
|
617
864
|
this.index = 0;
|
|
618
865
|
this._removeIndexMarker?.();
|
|
619
866
|
return;
|
|
620
867
|
}
|
|
621
868
|
|
|
622
|
-
nextIndex1 = Math.max(1, Math.min(nextIndex1,
|
|
869
|
+
nextIndex1 = Math.max(1, Math.min(nextIndex1, totalAfterDelete));
|
|
623
870
|
this.setIndex(nextIndex1);
|
|
624
871
|
}
|
|
625
872
|
|
|
626
873
|
handleDelete(deleted) {
|
|
874
|
+
// Captured BEFORE _evalNodeset() runs below, so it reflects the PRE-delete logical
|
|
875
|
+
// position - needed to distinguish "deleted row was above the window" (Zone A) from
|
|
876
|
+
// "deleted row was below the window" (Zone C) when the row isn't found among rendered
|
|
877
|
+
// items. (_evalNodeset() already reflects the post-delete instance state by the time
|
|
878
|
+
// this handler runs, since the model mutation happens before the 'deleted' event fires.)
|
|
879
|
+
const priorIdx = Array.isArray(this.nodeset) ? this.nodeset.indexOf(deleted) : -1;
|
|
880
|
+
|
|
627
881
|
const items = Array.from(
|
|
628
882
|
this.querySelectorAll(
|
|
629
883
|
':scope > fx-repeat-item, :scope > fx-repeatitem, :scope > .repeat-item',
|
|
@@ -632,22 +886,54 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
632
886
|
|
|
633
887
|
this._evalNodeset();
|
|
634
888
|
|
|
889
|
+
const total = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
|
|
890
|
+
|
|
635
891
|
const indexToRemove = items.findIndex(item => item.nodeset === deleted);
|
|
636
892
|
if (indexToRemove === -1) {
|
|
893
|
+
if (this._virtual && priorIdx >= 0 && priorIdx < this._windowStart) {
|
|
894
|
+
// Zone A: deleted row was above the window - rendered rows unaffected (same node
|
|
895
|
+
// identities), only their logical position shifted down by one. No DOM change;
|
|
896
|
+
// shift the window's bookkeeping and current index to match (mirrors the XML
|
|
897
|
+
// insert handler's Zone-A shift, in reverse). Deliberately does NOT call setIndex()
|
|
898
|
+
// - see the equivalent comment on the insert side re: `this.index` possibly already
|
|
899
|
+
// being outside this deliberately-untouched window (raw scrolling never updates it),
|
|
900
|
+
// which would make setIndex()'s window-membership check trigger an unwanted seek.
|
|
901
|
+
this._windowStart = Math.max(0, this._windowStart - 1);
|
|
902
|
+
this._renderTarget = Math.max(this._windowStart, this._renderTarget - 1);
|
|
903
|
+
this._reindexRenderedRows();
|
|
904
|
+
this._syncSentinels();
|
|
905
|
+
this.index = Math.max(1, this.index - 1);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
// Zone C (or non-virtual beyond-window): deleted node was never rendered - no DOM row
|
|
909
|
+
// to remove, but the window ceiling may now exceed the shrunk nodeset.
|
|
910
|
+
this._renderTarget = Math.min(this._renderTarget, total);
|
|
911
|
+
this._syncSentinels();
|
|
637
912
|
return;
|
|
638
913
|
}
|
|
914
|
+
|
|
915
|
+
// Zone B: rendered.
|
|
639
916
|
const itemToRemove = items[indexToRemove];
|
|
640
917
|
itemToRemove.remove();
|
|
641
918
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
919
|
+
this._renderTarget = Math.max(this._windowStart, this._renderTarget - 1);
|
|
920
|
+
// Virtual mode: a mid-window delete is not a scroll event - the user still expects a
|
|
921
|
+
// full window, so backfill from the tail if more nodeset remains below.
|
|
922
|
+
if (this._virtual) {
|
|
923
|
+
const deficit = this._sizeLimit - (this._renderTarget - this._windowStart);
|
|
924
|
+
if (deficit > 0 && this._renderTarget < total) {
|
|
925
|
+
this._slideWindowDown(deficit);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
this._syncSentinels();
|
|
645
929
|
|
|
646
|
-
|
|
647
|
-
|
|
930
|
+
// Clamp against the LOGICAL total, not the rendered DOM count - see the equivalent
|
|
931
|
+
// comment in _handleJsonDeleted above.
|
|
932
|
+
let nextIndex = this._windowStart + indexToRemove + 1;
|
|
933
|
+
if (total === 0) {
|
|
648
934
|
nextIndex = 0;
|
|
649
|
-
} else if (nextIndex >
|
|
650
|
-
nextIndex =
|
|
935
|
+
} else if (nextIndex > total) {
|
|
936
|
+
nextIndex = total;
|
|
651
937
|
}
|
|
652
938
|
|
|
653
939
|
this.setIndex(nextIndex);
|
|
@@ -666,6 +952,592 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
666
952
|
return newItem;
|
|
667
953
|
}
|
|
668
954
|
|
|
955
|
+
_materializeRepeatItem(position) {
|
|
956
|
+
const newItem = this._createNewRepeatItem();
|
|
957
|
+
this.insertBefore(newItem, this._sentinel || null);
|
|
958
|
+
this._initVariables(newItem);
|
|
959
|
+
newItem.nodeset = this.nodeset[position - 1];
|
|
960
|
+
newItem.index = position;
|
|
961
|
+
return newItem;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Grows the rendered window up to (at most) `desired` logical positions, materializing
|
|
966
|
+
* any not-yet-rendered rows in between. Used by the sentinel intersection callback and
|
|
967
|
+
* by setIndex() to front-fill on-demand navigation beyond the current window.
|
|
968
|
+
*/
|
|
969
|
+
_growRenderTarget(desired) {
|
|
970
|
+
const total = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
|
|
971
|
+
const newTarget = Math.max(this._renderTarget, Math.min(desired, total));
|
|
972
|
+
if (newTarget === this._renderTarget) return;
|
|
973
|
+
|
|
974
|
+
const fore = this.getOwnerForm();
|
|
975
|
+
for (let position = this._renderTarget + 1; position <= newTarget; position += 1) {
|
|
976
|
+
const newItem = this._materializeRepeatItem(position);
|
|
977
|
+
if (fore.createNodes) {
|
|
978
|
+
fore.initData(newItem);
|
|
979
|
+
}
|
|
980
|
+
fore.scanForNewTemplateExpressionsNextRefresh();
|
|
981
|
+
fore.registerLazyElement(newItem);
|
|
982
|
+
newItem.refresh(true);
|
|
983
|
+
Fore.dispatch(this, 'item-created', { nodeset: newItem.nodeset, pos: position });
|
|
984
|
+
}
|
|
985
|
+
this._renderTarget = newTarget;
|
|
986
|
+
this._syncSentinels();
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Renumbers .index on every currently rendered row to match its logical position
|
|
991
|
+
* (_windowStart + DOM offset). Needed after any operation that shifts _windowStart
|
|
992
|
+
* without individually touching every row's index (prepend, eviction, Zone-A shifts).
|
|
993
|
+
*/
|
|
994
|
+
_reindexRenderedRows() {
|
|
995
|
+
const rows = this.querySelectorAll(':scope > fx-repeatitem');
|
|
996
|
+
rows.forEach((row, i) => {
|
|
997
|
+
row.index = this._windowStart + i + 1;
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Evicts up to `count` rows from the top of the rendered window, correcting scrollTop by
|
|
1003
|
+
* the EXACT measured height of the removed rows (not an estimate) so the still-visible
|
|
1004
|
+
* content doesn't visually jump. Only meaningful in virtual mode.
|
|
1005
|
+
*
|
|
1006
|
+
* Only rows CONFIRMED already scrolled a safe margin above the visible viewport are
|
|
1007
|
+
* evicted, even if that's fewer than `count` - a bottom-sentinel intersection only proves
|
|
1008
|
+
* the user has scrolled near the bottom of the currently rendered content, not that
|
|
1009
|
+
* they've scrolled past every row in the leading edge. Evicting unconditionally would
|
|
1010
|
+
* remove rows the user is still looking at, forcing a scrollTop correction bigger than the
|
|
1011
|
+
* current scroll position (clamped to 0) and visibly yanking the viewport.
|
|
1012
|
+
*
|
|
1013
|
+
* The `_EVICT_MARGIN_PX` buffer is what keeps the freshly-created top sentinel (inserted
|
|
1014
|
+
* right after eviction, at the new window's start) genuinely off-screen rather than
|
|
1015
|
+
* exactly at the viewport boundary: without it, the scrollTop correction above would land
|
|
1016
|
+
* precisely where content ends and the new sentinel begins, and the sentinel's very first
|
|
1017
|
+
* IntersectionObserver reading (which reports the CURRENT state, not just future changes)
|
|
1018
|
+
* would immediately report it as visible - triggering an instant slide back up and
|
|
1019
|
+
* oscillating forever. Leaving this margin of already-scrolled-past content unevicted
|
|
1020
|
+
* means the sentinel ends up genuinely above the fold, only intersecting once the user
|
|
1021
|
+
* scrolls up for real.
|
|
1022
|
+
*/
|
|
1023
|
+
_evictFromTop(count) {
|
|
1024
|
+
const EVICT_MARGIN_PX = 100;
|
|
1025
|
+
// Bounded regardless of how large a deficit the caller asks to clear in one go: a fast
|
|
1026
|
+
// scroll (or a long stretch since the last trim) can hand this a large `count`, and since
|
|
1027
|
+
// eviction here is corrected by an immediate scrollTop jump of the exact removed height,
|
|
1028
|
+
// an uncapped batch means an uncapped, scroll-speed-proportional jump - the faster you
|
|
1029
|
+
// scroll, the heftier the correction. Capping the batch keeps each single correction small
|
|
1030
|
+
// and roughly constant; any remainder simply drains on the next trim/slide call (there's
|
|
1031
|
+
// always a next one while scrolling continues, via _trimWindow's own scroll listener).
|
|
1032
|
+
const boundedCount = Math.min(count, this._maxEvictBatch());
|
|
1033
|
+
const scrollRoot =
|
|
1034
|
+
this._findSentinelRoot() || document.scrollingElement || document.documentElement;
|
|
1035
|
+
const containerTop =
|
|
1036
|
+
scrollRoot && scrollRoot.getBoundingClientRect ? scrollRoot.getBoundingClientRect().top : 0;
|
|
1037
|
+
|
|
1038
|
+
const candidates = Array.from(this.querySelectorAll(':scope > fx-repeatitem')).slice(
|
|
1039
|
+
0,
|
|
1040
|
+
boundedCount,
|
|
1041
|
+
);
|
|
1042
|
+
const rows = [];
|
|
1043
|
+
for (const row of candidates) {
|
|
1044
|
+
if (row.getBoundingClientRect().bottom <= containerTop - EVICT_MARGIN_PX) rows.push(row);
|
|
1045
|
+
else break;
|
|
1046
|
+
}
|
|
1047
|
+
if (rows.length === 0) return;
|
|
1048
|
+
|
|
1049
|
+
const removedHeight =
|
|
1050
|
+
rows[rows.length - 1].getBoundingClientRect().bottom - rows[0].getBoundingClientRect().top;
|
|
1051
|
+
// Captured BEFORE removal: removing the rows shrinks scrollHeight immediately, and the
|
|
1052
|
+
// browser auto-clamps scrollTop to the new (smaller) max as a side effect of that DOM
|
|
1053
|
+
// mutation - reading scrollTop again afterward (e.g. via `-=`) would read the
|
|
1054
|
+
// ALREADY-CLAMPED value and double-subtract from it, overshooting past 0.
|
|
1055
|
+
const scrollTopBeforeRemoval = scrollRoot ? scrollRoot.scrollTop : 0;
|
|
1056
|
+
|
|
1057
|
+
const fore = this.getOwnerForm();
|
|
1058
|
+
rows.forEach(row => {
|
|
1059
|
+
fore.unRegisterLazyElement(row);
|
|
1060
|
+
row.remove();
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
this._windowStart += rows.length;
|
|
1064
|
+
if (scrollRoot) scrollRoot.scrollTop = scrollTopBeforeRemoval - removedHeight;
|
|
1065
|
+
this._reindexRenderedRows();
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Evicts up to `count` rows from the bottom of the rendered window. Symmetric safety check
|
|
1070
|
+
* to _evictFromTop: only rows CONFIRMED already below the visible viewport are evicted, so
|
|
1071
|
+
* a row the user is currently looking at never disappears out from under them. No scrollTop
|
|
1072
|
+
* correction is needed here - removing confirmed-below-the-fold content doesn't shift what's
|
|
1073
|
+
* currently shown.
|
|
1074
|
+
*/
|
|
1075
|
+
_evictFromBottom(count) {
|
|
1076
|
+
// See the equivalent comment in _evictFromTop - same batch cap, kept symmetric even
|
|
1077
|
+
// though this particular eviction needs no scrollTop correction, so a single very large
|
|
1078
|
+
// batch here is cheap on its own; the cap mainly keeps top/bottom behavior consistent.
|
|
1079
|
+
const boundedCount = Math.min(count, this._maxEvictBatch());
|
|
1080
|
+
const scrollRoot =
|
|
1081
|
+
this._findSentinelRoot() || document.scrollingElement || document.documentElement;
|
|
1082
|
+
// scrollRoot always resolves to a real element (document.documentElement is the final
|
|
1083
|
+
// fallback), so this always has a getBoundingClientRect - no further fallback needed.
|
|
1084
|
+
const containerBottom = scrollRoot.getBoundingClientRect().bottom;
|
|
1085
|
+
|
|
1086
|
+
const allRows = Array.from(this.querySelectorAll(':scope > fx-repeatitem'));
|
|
1087
|
+
const candidates = allRows.slice(-boundedCount);
|
|
1088
|
+
const rows = [];
|
|
1089
|
+
for (let i = candidates.length - 1; i >= 0; i -= 1) {
|
|
1090
|
+
const row = candidates[i];
|
|
1091
|
+
if (row.getBoundingClientRect().top >= containerBottom) rows.unshift(row);
|
|
1092
|
+
else break;
|
|
1093
|
+
}
|
|
1094
|
+
if (rows.length === 0) return;
|
|
1095
|
+
|
|
1096
|
+
const fore = this.getOwnerForm();
|
|
1097
|
+
rows.forEach(row => {
|
|
1098
|
+
fore.unRegisterLazyElement(row);
|
|
1099
|
+
row.remove();
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
this._renderTarget -= rows.length;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Virtual-mode bottom-sentinel response: appends the next `count` logical rows below the
|
|
1107
|
+
* current window, then evicts from the top to bring the rendered count back down to
|
|
1108
|
+
* `_sizeLimit`. The evict count is derived (not assumed equal to `count`) because the
|
|
1109
|
+
* window may not have reached full size yet (e.g. very first slide after initial load).
|
|
1110
|
+
*/
|
|
1111
|
+
_slideWindowDown(count) {
|
|
1112
|
+
const total = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
|
|
1113
|
+
const oldEnd = this._renderTarget;
|
|
1114
|
+
const newEnd = Math.min(oldEnd + count, total);
|
|
1115
|
+
if (newEnd === oldEnd) return;
|
|
1116
|
+
|
|
1117
|
+
const fore = this.getOwnerForm();
|
|
1118
|
+
for (let logicalIdx = oldEnd; logicalIdx < newEnd; logicalIdx += 1) {
|
|
1119
|
+
const newItem = this._materializeRepeatItem(logicalIdx + 1);
|
|
1120
|
+
if (fore.createNodes) fore.initData(newItem);
|
|
1121
|
+
fore.scanForNewTemplateExpressionsNextRefresh();
|
|
1122
|
+
fore.registerLazyElement(newItem);
|
|
1123
|
+
newItem.refresh(true);
|
|
1124
|
+
Fore.dispatch(this, 'item-created', { nodeset: newItem.nodeset, pos: logicalIdx + 1 });
|
|
1125
|
+
}
|
|
1126
|
+
this._renderTarget = newEnd;
|
|
1127
|
+
|
|
1128
|
+
// Deliberately NOT capped at `newEnd - oldEnd` (the count just added): a previous slide
|
|
1129
|
+
// may have under-evicted (its rows weren't yet confirmed off-screen per _evictFromTop's
|
|
1130
|
+
// safety margin), leaving a backlog above `_sizeLimit`. Requesting the full deficit here
|
|
1131
|
+
// lets that backlog catch up once those rows really are off-screen, instead of the excess
|
|
1132
|
+
// being locked in forever at whatever a single partial eviction left behind.
|
|
1133
|
+
const currentRendered = this._renderTarget - this._windowStart;
|
|
1134
|
+
const evictCount = Math.max(0, currentRendered - this._sizeLimit);
|
|
1135
|
+
if (evictCount > 0) this._evictFromTop(evictCount);
|
|
1136
|
+
|
|
1137
|
+
this._syncSentinels();
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Virtual-mode top-sentinel response: prepends the previous `count` logical rows above the
|
|
1142
|
+
* current window, then evicts from the bottom to bring the rendered count back down to
|
|
1143
|
+
* `_sizeLimit`. Rows are created in ASCENDING logical order and inserted before a single
|
|
1144
|
+
* anchor node captured once before the loop - this builds correct DOM order in one pass
|
|
1145
|
+
* without recomputing the anchor (e.g. via repeated querySelector) on every iteration.
|
|
1146
|
+
*
|
|
1147
|
+
* Prepending content ABOVE the current scroll position pushes the previously-visible
|
|
1148
|
+
* content further down the document without changing scrollTop, which would otherwise
|
|
1149
|
+
* make the viewport appear to jump to different (earlier) content. Corrected the same way
|
|
1150
|
+
* _evictFromTop() corrects for removal: capture scrollTop BEFORE the mutation, measure the
|
|
1151
|
+
* EXACT height of what was just inserted, and set the new absolute scrollTop afterward
|
|
1152
|
+
* (not `+=`, which - mirroring the eviction fix - could read a value already perturbed by
|
|
1153
|
+
* the DOM mutation itself).
|
|
1154
|
+
*/
|
|
1155
|
+
_slideWindowUp(count) {
|
|
1156
|
+
const oldStart = this._windowStart;
|
|
1157
|
+
const target = Math.max(0, oldStart - count);
|
|
1158
|
+
if (target === oldStart) return;
|
|
1159
|
+
|
|
1160
|
+
// Bounded the same way _evictFromTop()/_evictFromBottom() are (see _maxEvictBatch()): a
|
|
1161
|
+
// single sentinel trigger requests sliding back a whole `size` worth at once, which would
|
|
1162
|
+
// otherwise prepend that many rows and correct scrollTop forward by their full combined
|
|
1163
|
+
// height in one jump. Prepending (and correcting for) only a small batch per call keeps
|
|
1164
|
+
// any single jump small; the remainder is picked up by the requestAnimationFrame
|
|
1165
|
+
// continuation below, converging to the same end state over a few frames instead of one.
|
|
1166
|
+
const batch = Math.min(oldStart - target, this._maxEvictBatch());
|
|
1167
|
+
const newStart = oldStart - batch;
|
|
1168
|
+
|
|
1169
|
+
const scrollRoot =
|
|
1170
|
+
this._findSentinelRoot() || document.scrollingElement || document.documentElement;
|
|
1171
|
+
const scrollTopBeforeInsert = scrollRoot ? scrollRoot.scrollTop : 0;
|
|
1172
|
+
|
|
1173
|
+
const fore = this.getOwnerForm();
|
|
1174
|
+
const anchor =
|
|
1175
|
+
(this._topSentinel && this._topSentinel.nextSibling) ||
|
|
1176
|
+
this.firstElementChild ||
|
|
1177
|
+
this._sentinel ||
|
|
1178
|
+
null;
|
|
1179
|
+
|
|
1180
|
+
const insertedItems = [];
|
|
1181
|
+
for (let logicalIdx = newStart; logicalIdx < oldStart; logicalIdx += 1) {
|
|
1182
|
+
const newItem = this._createNewRepeatItem();
|
|
1183
|
+
this.insertBefore(newItem, anchor);
|
|
1184
|
+
this._initVariables(newItem);
|
|
1185
|
+
newItem.nodeset = this.nodeset[logicalIdx];
|
|
1186
|
+
newItem.index = logicalIdx + 1;
|
|
1187
|
+
if (fore.createNodes) fore.initData(newItem);
|
|
1188
|
+
fore.scanForNewTemplateExpressionsNextRefresh();
|
|
1189
|
+
fore.registerLazyElement(newItem);
|
|
1190
|
+
newItem.refresh(true);
|
|
1191
|
+
Fore.dispatch(this, 'item-created', { nodeset: newItem.nodeset, pos: logicalIdx + 1 });
|
|
1192
|
+
insertedItems.push(newItem);
|
|
1193
|
+
}
|
|
1194
|
+
this._windowStart = newStart;
|
|
1195
|
+
this._reindexRenderedRows();
|
|
1196
|
+
|
|
1197
|
+
if (insertedItems.length > 0 && scrollRoot) {
|
|
1198
|
+
const insertedHeight =
|
|
1199
|
+
insertedItems[insertedItems.length - 1].getBoundingClientRect().bottom -
|
|
1200
|
+
insertedItems[0].getBoundingClientRect().top;
|
|
1201
|
+
scrollRoot.scrollTop = scrollTopBeforeInsert + insertedHeight;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// See the equivalent comment in _slideWindowDown - deliberately not capped at
|
|
1205
|
+
// `oldStart - newStart` so a prior partial eviction's backlog can catch up here too.
|
|
1206
|
+
const currentRendered = this._renderTarget - this._windowStart;
|
|
1207
|
+
const evictCount = Math.max(0, currentRendered - this._sizeLimit);
|
|
1208
|
+
if (evictCount > 0) this._evictFromBottom(evictCount);
|
|
1209
|
+
|
|
1210
|
+
this._syncSentinels();
|
|
1211
|
+
|
|
1212
|
+
// Batch was capped above (`batch < oldStart - target`): more prepending was requested
|
|
1213
|
+
// than this call actually performed, so continue on the next frame instead of waiting for
|
|
1214
|
+
// the top sentinel to re-cross (it may not - it's likely still off-screen after only a
|
|
1215
|
+
// partial slide, so no new IntersectionObserver trigger would ever arrive on its own).
|
|
1216
|
+
const remaining = newStart - target;
|
|
1217
|
+
if (remaining > 0) {
|
|
1218
|
+
requestAnimationFrame(() => this._slideWindowUp(remaining));
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Hard jump: destroys the entire current window and renders a fresh one starting at
|
|
1224
|
+
* `logicalIdx` (start-at-target, not center-on-target - simpler, matches the roadmap's
|
|
1225
|
+
* wording, and avoids asymmetric clamping near either end of the nodeset that centering
|
|
1226
|
+
* would require anyway), then resets scroll position to the top of the fresh window. Used
|
|
1227
|
+
* when navigation (setIndex, an index('id') dependent, an insert/append past the window)
|
|
1228
|
+
* targets a logical index outside the current window - no smooth-scroll illusion, no
|
|
1229
|
+
* incremental front-fill (that's Phase-1's answer and doesn't fit a sliding window).
|
|
1230
|
+
*/
|
|
1231
|
+
_seekWindowTo(logicalIdx) {
|
|
1232
|
+
const total = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
|
|
1233
|
+
const size = this._sizeLimit;
|
|
1234
|
+
|
|
1235
|
+
const newStart = Math.max(0, Math.min(logicalIdx, Math.max(0, total - size)));
|
|
1236
|
+
const newEnd = Math.min(total, newStart + size);
|
|
1237
|
+
|
|
1238
|
+
const fore = this.getOwnerForm();
|
|
1239
|
+
this.querySelectorAll(':scope > fx-repeatitem').forEach(item => {
|
|
1240
|
+
fore.unRegisterLazyElement(item);
|
|
1241
|
+
item.remove();
|
|
1242
|
+
});
|
|
1243
|
+
if (this._topSentinel) this._destroyTopSentinel();
|
|
1244
|
+
if (this._sentinel) this._destroySentinel();
|
|
1245
|
+
|
|
1246
|
+
this._windowStart = newStart;
|
|
1247
|
+
this._renderTarget = newEnd;
|
|
1248
|
+
for (let li = newStart; li < newEnd; li += 1) {
|
|
1249
|
+
const newItem = this._materializeRepeatItem(li + 1);
|
|
1250
|
+
if (fore.createNodes) fore.initData(newItem);
|
|
1251
|
+
fore.scanForNewTemplateExpressionsNextRefresh();
|
|
1252
|
+
fore.registerLazyElement(newItem);
|
|
1253
|
+
newItem.refresh(true);
|
|
1254
|
+
Fore.dispatch(this, 'item-created', { nodeset: newItem.nodeset, pos: li + 1 });
|
|
1255
|
+
}
|
|
1256
|
+
this._syncSentinels();
|
|
1257
|
+
|
|
1258
|
+
const scrollRoot =
|
|
1259
|
+
this._findSentinelRoot() || document.scrollingElement || document.documentElement;
|
|
1260
|
+
if (scrollRoot) scrollRoot.scrollTop = 0;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
_createSentinel() {
|
|
1264
|
+
const sentinel = document.createElement('div');
|
|
1265
|
+
sentinel.className = 'fx-repeat-sentinel';
|
|
1266
|
+
sentinel.setAttribute('aria-hidden', 'true');
|
|
1267
|
+
// Needs a real box for IntersectionObserver geometry, so it can't be display:contents
|
|
1268
|
+
// like fx-repeat/fx-repeatitem typically are in grid/flex host layouts. Kept visually
|
|
1269
|
+
// negligible; host pages using CSS grid directly on fx-repeat's light DOM may need to
|
|
1270
|
+
// give `.fx-repeat-sentinel` a `grid-column` override.
|
|
1271
|
+
sentinel.style.cssText = 'height:1px;pointer-events:none;';
|
|
1272
|
+
this.appendChild(sentinel);
|
|
1273
|
+
this._sentinel = sentinel;
|
|
1274
|
+
|
|
1275
|
+
if (!this._sentinelObserver) {
|
|
1276
|
+
// An explicit root pointing at the nearest scrollable ancestor, not root:null (the
|
|
1277
|
+
// implicit viewport), is required: root:null does not reliably re-fire when only a
|
|
1278
|
+
// *nested* scrollable container scrolls (verified reproducible in headless Chrome and
|
|
1279
|
+
// Electron - the top-level viewport itself never changes, so the browser doesn't
|
|
1280
|
+
// always re-evaluate intersection for a target clipped by an inner scroll container).
|
|
1281
|
+
// Falls back to null when no scrollable ancestor exists, i.e. the whole page scrolls.
|
|
1282
|
+
this._sentinelObserver = new IntersectionObserver(
|
|
1283
|
+
entries => this._onSentinelIntersect(entries),
|
|
1284
|
+
{
|
|
1285
|
+
root: this._findSentinelRoot(),
|
|
1286
|
+
rootMargin: '0px',
|
|
1287
|
+
threshold: 0,
|
|
1288
|
+
},
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
this._sentinelObserver.observe(sentinel);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
_findSentinelRoot() {
|
|
1295
|
+
let node = this.parentNode;
|
|
1296
|
+
while (node) {
|
|
1297
|
+
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE && node.host) {
|
|
1298
|
+
node = node.host;
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
if (node.nodeType !== Node.ELEMENT_NODE || node === document.documentElement) {
|
|
1302
|
+
node = node.parentNode;
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
const style = getComputedStyle(node);
|
|
1306
|
+
if (/(auto|scroll)/.test(style.overflowY) || /(auto|scroll)/.test(style.overflow)) {
|
|
1307
|
+
return node;
|
|
1308
|
+
}
|
|
1309
|
+
node = node.parentNode;
|
|
1310
|
+
}
|
|
1311
|
+
return null;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
_destroySentinel() {
|
|
1315
|
+
if (this._sentinelObserver && this._sentinel) {
|
|
1316
|
+
this._sentinelObserver.unobserve(this._sentinel);
|
|
1317
|
+
}
|
|
1318
|
+
if (this._sentinel) {
|
|
1319
|
+
this._sentinel.remove();
|
|
1320
|
+
this._sentinel = null;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Leading sentinel, symmetric to _createSentinel()/_destroySentinel() but placed as the
|
|
1326
|
+
* first child instead of appended, and only ever active in virtual (windowed) mode - it
|
|
1327
|
+
* signals "there is more nodeset above the rendered window", triggering a prepend+evict-
|
|
1328
|
+
* from-bottom slide instead of the trailing sentinel's append+evict-from-top slide.
|
|
1329
|
+
*/
|
|
1330
|
+
_createTopSentinel() {
|
|
1331
|
+
const sentinel = document.createElement('div');
|
|
1332
|
+
sentinel.className = 'fx-repeat-sentinel fx-repeat-sentinel-top';
|
|
1333
|
+
sentinel.setAttribute('aria-hidden', 'true');
|
|
1334
|
+
sentinel.style.cssText = 'height:1px;pointer-events:none;';
|
|
1335
|
+
this.insertBefore(sentinel, this.firstElementChild || null);
|
|
1336
|
+
this._topSentinel = sentinel;
|
|
1337
|
+
|
|
1338
|
+
if (!this._topSentinelObserver) {
|
|
1339
|
+
this._topSentinelObserver = new IntersectionObserver(
|
|
1340
|
+
entries => this._onTopSentinelIntersect(entries),
|
|
1341
|
+
{
|
|
1342
|
+
root: this._findSentinelRoot(),
|
|
1343
|
+
rootMargin: '0px',
|
|
1344
|
+
threshold: 0,
|
|
1345
|
+
},
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
this._topSentinelObserver.observe(sentinel);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
_destroyTopSentinel() {
|
|
1352
|
+
if (this._topSentinelObserver && this._topSentinel) {
|
|
1353
|
+
this._topSentinelObserver.unobserve(this._topSentinel);
|
|
1354
|
+
}
|
|
1355
|
+
if (this._topSentinel) {
|
|
1356
|
+
this._topSentinel.remove();
|
|
1357
|
+
this._topSentinel = null;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Ensures the trailing sentinel exists iff a size cap is active and there are still
|
|
1363
|
+
* unrendered rows left, and (virtual mode only) the leading sentinel exists iff the window
|
|
1364
|
+
* has scrolled past the start of the nodeset. Called after every operation that can change
|
|
1365
|
+
* _renderTarget, _windowStart, or the nodeset length.
|
|
1366
|
+
*/
|
|
1367
|
+
_syncSentinels() {
|
|
1368
|
+
this._syncBottomSentinel();
|
|
1369
|
+
this._syncTopSentinel();
|
|
1370
|
+
this._ensureScrollTrimListener();
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Suppresses the browser's native overscroll/rubber-band bounce on the actual scrolling
|
|
1375
|
+
* ancestor, for ANY size-capped repeat - progressive rendering included, not just `virtual`.
|
|
1376
|
+
* Both flavors can find themselves genuinely at the end of currently-rendered content while
|
|
1377
|
+
* the user is still actively (momentum-)scrolling toward it - virtual mode via eviction
|
|
1378
|
+
* shrinking the scrollable height out from under the scroll, progressive rendering simply
|
|
1379
|
+
* because the next chunk hasn't been appended yet when the sentinel fires - and either one
|
|
1380
|
+
* can trigger a visible bounce (briefly showing whatever is behind the container) right at
|
|
1381
|
+
* that edge. This can't live in a shared stylesheet - the scrolling ancestor is whatever
|
|
1382
|
+
* element the HOST PAGE gave `overflow: auto/scroll`, under any class name, so there's no
|
|
1383
|
+
* fixed selector to hang it on. Setting it here, on the same element _findSentinelRoot()
|
|
1384
|
+
* already resolves for virtual mode's scrollTop corrections, makes it automatic for any
|
|
1385
|
+
* capped repeat regardless of the page's own markup - no CSS to remember. Deliberately
|
|
1386
|
+
* excludes the whole-page fallback (no nested scrollable ancestor found): suppressing
|
|
1387
|
+
* overscroll for the entire document would be a surprising side effect on a page that also
|
|
1388
|
+
* scrolls for reasons unrelated to this repeat. Also skipped if the page already set this
|
|
1389
|
+
* explicitly - never override an intentional choice.
|
|
1390
|
+
*/
|
|
1391
|
+
_ensureOverscrollFix() {
|
|
1392
|
+
if (this._sizeLimit === Infinity) return;
|
|
1393
|
+
|
|
1394
|
+
const root = this._findSentinelRoot();
|
|
1395
|
+
if (root && getComputedStyle(root).overscrollBehaviorY === 'auto') {
|
|
1396
|
+
root.style.overscrollBehavior = 'none';
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Lazily attaches a 'scroll' listener on the nearest scrollable ancestor (virtual mode
|
|
1402
|
+
* only) that keeps trimming the rendered window toward `size` as the user scrolls - see
|
|
1403
|
+
* _trimWindow(). Re-attaches if the scrollable ancestor changes (e.g. repeat reparented)
|
|
1404
|
+
* and detaches entirely once virtual mode is off.
|
|
1405
|
+
*/
|
|
1406
|
+
_ensureScrollTrimListener() {
|
|
1407
|
+
this._ensureOverscrollFix();
|
|
1408
|
+
|
|
1409
|
+
if (!this._virtual) {
|
|
1410
|
+
if (this._scrollTrimHandler && this._scrollTrimRoot) {
|
|
1411
|
+
this._scrollTrimRoot.removeEventListener('scroll', this._scrollTrimHandler);
|
|
1412
|
+
}
|
|
1413
|
+
this._scrollTrimRoot = null;
|
|
1414
|
+
this._scrollTrimHandler = null;
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
const root = this._findSentinelRoot() || document.scrollingElement || document.documentElement;
|
|
1419
|
+
if (root === this._scrollTrimRoot && this._scrollTrimHandler) return;
|
|
1420
|
+
|
|
1421
|
+
if (this._scrollTrimHandler && this._scrollTrimRoot) {
|
|
1422
|
+
this._scrollTrimRoot.removeEventListener('scroll', this._scrollTrimHandler);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
this._scrollTrimRoot = root;
|
|
1426
|
+
this._scrollTrimLastTop = root ? root.scrollTop : 0;
|
|
1427
|
+
this._scrollTrimHandler = () => {
|
|
1428
|
+
if (this._trimScheduled) return;
|
|
1429
|
+
this._trimScheduled = true;
|
|
1430
|
+
requestAnimationFrame(() => {
|
|
1431
|
+
this._trimScheduled = false;
|
|
1432
|
+
const cur = this._scrollTrimRoot ? this._scrollTrimRoot.scrollTop : 0;
|
|
1433
|
+
const direction = cur - this._scrollTrimLastTop;
|
|
1434
|
+
this._scrollTrimLastTop = cur;
|
|
1435
|
+
this._trimWindow(direction);
|
|
1436
|
+
});
|
|
1437
|
+
};
|
|
1438
|
+
root.addEventListener('scroll', this._scrollTrimHandler, { passive: true });
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Evicts whatever is currently safely off-screen (top or bottom, via the same
|
|
1443
|
+
* margin-guarded checks _slideWindowDown()/_slideWindowUp() already use) whenever the
|
|
1444
|
+
* rendered count exceeds `size`. Sentinels alone only trigger a slide once the window's
|
|
1445
|
+
* edge is reached - by which point almost a full window of content has already scrolled
|
|
1446
|
+
* by - so the rendered count settles at roughly `size + one viewport's worth` instead of
|
|
1447
|
+
* `size`. Running this continuously on scroll lets the excess drain away in small
|
|
1448
|
+
* increments as it becomes confirmed off-screen, well before the next sentinel crossing.
|
|
1449
|
+
*
|
|
1450
|
+
* `direction` (signed scrollTop delta since the last call, 0 if unknown) picks a SINGLE
|
|
1451
|
+
* side to evict from, matching which side is actually accumulating stale rows: scrolling
|
|
1452
|
+
* down (positive) only makes rows above stale, scrolling up (negative) only makes rows
|
|
1453
|
+
* below stale. Trying both sides unconditionally is wrong, not just redundant - while
|
|
1454
|
+
* scrolling up specifically to shrink `_windowStart` back toward 0 (e.g. _slideWindowUp
|
|
1455
|
+
* prepending rows), any leftover excess (from _evictFromBottom's own per-call batch cap
|
|
1456
|
+
* not yet having caught up) would otherwise get partially evicted from the top here,
|
|
1457
|
+
* incrementing `_windowStart` right back up and undoing part of the very prepend that
|
|
1458
|
+
* just ran - directly fighting the scroll-up convergence instead of assisting it.
|
|
1459
|
+
*/
|
|
1460
|
+
_trimWindow(direction = 0) {
|
|
1461
|
+
if (!this._virtual) return;
|
|
1462
|
+
|
|
1463
|
+
let excess = this._renderTarget - this._windowStart - this._sizeLimit;
|
|
1464
|
+
if (excess <= 0) return;
|
|
1465
|
+
|
|
1466
|
+
let evictedFromTop = 0;
|
|
1467
|
+
let evictedFromBottom = 0;
|
|
1468
|
+
|
|
1469
|
+
if (direction < 0) {
|
|
1470
|
+
const endBefore = this._renderTarget;
|
|
1471
|
+
this._evictFromBottom(excess);
|
|
1472
|
+
evictedFromBottom = endBefore - this._renderTarget;
|
|
1473
|
+
excess -= evictedFromBottom;
|
|
1474
|
+
} else {
|
|
1475
|
+
const startBefore = this._windowStart;
|
|
1476
|
+
this._evictFromTop(excess);
|
|
1477
|
+
evictedFromTop = this._windowStart - startBefore;
|
|
1478
|
+
excess -= evictedFromTop;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
this._syncSentinels();
|
|
1482
|
+
|
|
1483
|
+
// `_evictFromTop`/`_evictFromBottom` cap themselves to a small batch per call (see
|
|
1484
|
+
// _maxEvictBatch()), so a large backlog (e.g. after a very fast scroll) needs several
|
|
1485
|
+
// passes to fully drain. While there's more to evict AND this pass actually made
|
|
1486
|
+
// progress, keep draining on the next animation frame rather than waiting for another
|
|
1487
|
+
// 'scroll' event that may never come if the user has already stopped scrolling. Stop
|
|
1488
|
+
// once a pass evicts nothing - the remainder isn't confirmed off-screen yet, and will
|
|
1489
|
+
// be picked up by the scroll listener once it is.
|
|
1490
|
+
if (excess > 0 && (evictedFromTop > 0 || evictedFromBottom > 0)) {
|
|
1491
|
+
requestAnimationFrame(() => this._trimWindow(direction));
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
_syncBottomSentinel() {
|
|
1496
|
+
const total = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
|
|
1497
|
+
const needsSentinel = this._sizeLimit !== Infinity && this._renderTarget < total;
|
|
1498
|
+
|
|
1499
|
+
if (needsSentinel && !this._sentinel) {
|
|
1500
|
+
this._createSentinel();
|
|
1501
|
+
} else if (!needsSentinel && this._sentinel) {
|
|
1502
|
+
this._destroySentinel();
|
|
1503
|
+
} else if (needsSentinel && this._sentinel && this.lastElementChild !== this._sentinel) {
|
|
1504
|
+
this.appendChild(this._sentinel);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* Only ever active in virtual mode (`_windowStart` is always 0 off virtual mode, so
|
|
1510
|
+
* `needsTop` is always false there) - signals there is unrendered nodeset above the window.
|
|
1511
|
+
*/
|
|
1512
|
+
_syncTopSentinel() {
|
|
1513
|
+
const needsTop = this._virtual && this._windowStart > 0;
|
|
1514
|
+
|
|
1515
|
+
if (needsTop && !this._topSentinel) {
|
|
1516
|
+
this._createTopSentinel();
|
|
1517
|
+
} else if (!needsTop && this._topSentinel) {
|
|
1518
|
+
this._destroyTopSentinel();
|
|
1519
|
+
} else if (needsTop && this._topSentinel && this.firstElementChild !== this._topSentinel) {
|
|
1520
|
+
this.insertBefore(this._topSentinel, this.firstElementChild);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
_onSentinelIntersect(entries) {
|
|
1525
|
+
const last = entries[entries.length - 1];
|
|
1526
|
+
if (!last || !last.isIntersecting) return;
|
|
1527
|
+
if (this._virtual) {
|
|
1528
|
+
this._slideWindowDown(this._sizeLimit);
|
|
1529
|
+
} else {
|
|
1530
|
+
this._growRenderTarget(this._renderTarget + this._sizeLimit);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
_onTopSentinelIntersect(entries) {
|
|
1535
|
+
const last = entries[entries.length - 1];
|
|
1536
|
+
if (!last || !last.isIntersecting) return;
|
|
1537
|
+
if (this._windowStart <= 0) return;
|
|
1538
|
+
this._slideWindowUp(this._sizeLimit);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
669
1541
|
init() {
|
|
670
1542
|
this._evalNodeset();
|
|
671
1543
|
this._initTemplate();
|
|
@@ -944,7 +1816,18 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
944
1816
|
});
|
|
945
1817
|
}
|
|
946
1818
|
|
|
947
|
-
|
|
1819
|
+
let touchedNodes = null;
|
|
1820
|
+
let domFacade = null;
|
|
1821
|
+
if (this._refNeedsDependencyTracking()) {
|
|
1822
|
+
touchedNodes = new Set();
|
|
1823
|
+
domFacade = new DependencyNotifyingDomFacade(node => touchedNodes.add(node));
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
const rawNodeset = evaluateXPath(this.ref, inscope, this, {}, {}, domFacade);
|
|
1827
|
+
|
|
1828
|
+
if (touchedNodes) {
|
|
1829
|
+
this._trackRefDependencies(touchedNodes);
|
|
1830
|
+
}
|
|
948
1831
|
|
|
949
1832
|
this._observeJsonPredicateDependencies(inscope);
|
|
950
1833
|
|
|
@@ -973,7 +1856,17 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
973
1856
|
async refresh(force) {
|
|
974
1857
|
if (!this.inited) this.init();
|
|
975
1858
|
|
|
1859
|
+
const prevNodeset = this.nodeset;
|
|
1860
|
+
|
|
976
1861
|
this._evalNodeset();
|
|
1862
|
+
this._sizeLimit = this._getSizeLimit();
|
|
1863
|
+
|
|
1864
|
+
// Captured BEFORE any forcing/reclamping below, so it reflects what the DOM is
|
|
1865
|
+
// currently windowed to - used afterward to detect the rare case where the floor
|
|
1866
|
+
// itself needs to move (virtual toggled off, or the tail shrank out from under it).
|
|
1867
|
+
const oldWindowStart = this._windowStart;
|
|
1868
|
+
this._virtual = this._isVirtual();
|
|
1869
|
+
if (!this._virtual) this._windowStart = 0;
|
|
977
1870
|
|
|
978
1871
|
let repeatItems = this.querySelectorAll(':scope > fx-repeatitem');
|
|
979
1872
|
let repeatItemCount = repeatItems.length;
|
|
@@ -983,10 +1876,59 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
983
1876
|
nodeCount = this.nodeset.length;
|
|
984
1877
|
}
|
|
985
1878
|
|
|
986
|
-
const
|
|
1879
|
+
const total = nodeCount;
|
|
1880
|
+
const prevTotal = Array.isArray(prevNodeset) ? prevNodeset.length : 1;
|
|
1881
|
+
|
|
1882
|
+
// Recompute the render target:
|
|
1883
|
+
// - uncapped: always track the full nodeset (identical to the pre-cap `contextSize`)
|
|
1884
|
+
// - capped, non-virtual, nodeset grew since the last refresh cycle: this only happens
|
|
1885
|
+
// through a path that didn't already update _renderTarget itself (e.g. bulk/direct
|
|
1886
|
+
// instance mutation rather than an insert/append action) - re-arm the cap by raising
|
|
1887
|
+
// the floor. Growth already tracked by insert handlers or setIndex growth-on-demand
|
|
1888
|
+
// does NOT hit this branch: by the time refresh() runs again after those, this.nodeset
|
|
1889
|
+
// was already updated by them (they call _evalNodeset() themselves), so it hasn't
|
|
1890
|
+
// grown further within *this* cycle, and _renderTarget is left exactly as they
|
|
1891
|
+
// already set it.
|
|
1892
|
+
// - capped, non-virtual, nodeset did not grow (shrank or same size): never grow the
|
|
1893
|
+
// window here, only clamp it down if it now exceeds a (possibly smaller) total.
|
|
1894
|
+
// - capped, virtual: reclamp BOTH edges to the current total, preserving window size
|
|
1895
|
+
// where possible and sliding the floor back only if the tail shrank out from under
|
|
1896
|
+
// it. Does not auto-follow nodeset growth mid-window - matches the non-virtual
|
|
1897
|
+
// branch's "don't jump the user around" philosophy.
|
|
1898
|
+
if (this._sizeLimit === Infinity) {
|
|
1899
|
+
this._renderTarget = total;
|
|
1900
|
+
this._windowStart = 0;
|
|
1901
|
+
} else if (!this._virtual) {
|
|
1902
|
+
if (total > prevTotal) {
|
|
1903
|
+
this._renderTarget = Math.min(Math.max(this._renderTarget, this._sizeLimit), total);
|
|
1904
|
+
} else {
|
|
1905
|
+
this._renderTarget = Math.min(this._renderTarget, total);
|
|
1906
|
+
}
|
|
1907
|
+
} else {
|
|
1908
|
+
const newStart = Math.max(
|
|
1909
|
+
0,
|
|
1910
|
+
Math.min(this._windowStart, Math.max(0, total - this._sizeLimit)),
|
|
1911
|
+
);
|
|
1912
|
+
this._windowStart = newStart;
|
|
1913
|
+
this._renderTarget = Math.min(total, newStart + this._sizeLimit);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
if (this._windowStart !== oldWindowStart) {
|
|
1917
|
+
// Rare: the window floor itself moved (virtual just turned off, or the reclamp above
|
|
1918
|
+
// slid it back because the nodeset's tail shrank out from under the previous window).
|
|
1919
|
+
// The tail-only shrink/grow loops below assume a stable floor and can't bridge a
|
|
1920
|
+
// floor change, so rebuild the window fresh here instead of patching a partial diff.
|
|
1921
|
+
repeatItems.forEach(item => {
|
|
1922
|
+
item.parentNode.removeChild(item);
|
|
1923
|
+
this.getOwnerForm().unRegisterLazyElement(item);
|
|
1924
|
+
});
|
|
1925
|
+
repeatItemCount = 0;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
const goal = this._renderTarget - this._windowStart;
|
|
987
1929
|
|
|
988
|
-
if (
|
|
989
|
-
for (let position = repeatItemCount; position >
|
|
1930
|
+
if (goal < repeatItemCount) {
|
|
1931
|
+
for (let position = repeatItemCount; position > goal; position -= 1) {
|
|
990
1932
|
const itemToRemove = repeatItems[position - 1];
|
|
991
1933
|
itemToRemove.parentNode.removeChild(itemToRemove);
|
|
992
1934
|
this.getOwnerForm().unRegisterLazyElement(itemToRemove);
|
|
@@ -997,15 +1939,11 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
997
1939
|
repeatItems = this.querySelectorAll(':scope > fx-repeatitem');
|
|
998
1940
|
repeatItemCount = repeatItems.length;
|
|
999
1941
|
|
|
1000
|
-
if (
|
|
1001
|
-
for (let position = repeatItemCount + 1; position <=
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
this._initVariables(newItem);
|
|
1006
|
-
|
|
1007
|
-
newItem.nodeset = this.nodeset[position - 1];
|
|
1008
|
-
newItem.index = position;
|
|
1942
|
+
if (goal > repeatItemCount) {
|
|
1943
|
+
for (let position = repeatItemCount + 1; position <= goal; position += 1) {
|
|
1944
|
+
// `position` is 1-based WITHIN the window; _materializeRepeatItem takes a 1-based
|
|
1945
|
+
// LOGICAL position, so offset by _windowStart (always 0 off virtual mode).
|
|
1946
|
+
const newItem = this._materializeRepeatItem(this._windowStart + position);
|
|
1009
1947
|
|
|
1010
1948
|
if (this.getOwnerForm().createNodes) {
|
|
1011
1949
|
this.getOwnerForm().initData(newItem);
|
|
@@ -1023,22 +1961,33 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
1023
1961
|
|
|
1024
1962
|
for (let position = 0; position < repeatItemCount; position += 1) {
|
|
1025
1963
|
const item = repeatItems[position];
|
|
1964
|
+
const logicalIdx = this._windowStart + position;
|
|
1026
1965
|
this.getOwnerForm().registerLazyElement(item);
|
|
1027
1966
|
|
|
1028
|
-
if (item.nodeset !== this.nodeset[
|
|
1029
|
-
item.nodeset = this.nodeset[
|
|
1967
|
+
if (item.nodeset !== this.nodeset[logicalIdx]) {
|
|
1968
|
+
item.nodeset = this.nodeset[logicalIdx];
|
|
1030
1969
|
if (this.getOwnerForm().createNodes) {
|
|
1031
1970
|
this.getOwnerForm().initData(item);
|
|
1032
1971
|
}
|
|
1033
1972
|
}
|
|
1973
|
+
item.index = logicalIdx + 1;
|
|
1034
1974
|
}
|
|
1035
1975
|
|
|
1976
|
+
this._syncSentinels();
|
|
1977
|
+
|
|
1036
1978
|
const fore = this.getOwnerForm();
|
|
1037
1979
|
if (!fore.lazyRefresh || force) {
|
|
1038
1980
|
await Fore.refreshChildren(this, force);
|
|
1039
1981
|
}
|
|
1040
1982
|
|
|
1041
|
-
|
|
1983
|
+
// Notify index() dependents only when THIS refresh changed the current item,
|
|
1984
|
+
// i.e. re-evaluating the nodeset put a different node at the current index.
|
|
1985
|
+
// Index changes made elsewhere (repeatitem click/focus, programmatic setIndex)
|
|
1986
|
+
// dispatch their own 'item-changed' — comparing against them here would
|
|
1987
|
+
// re-announce them, firing a spurious duplicate on every repeat refresh that
|
|
1988
|
+
// overlaps a click (e.g. one triggered by the structural-change consumer).
|
|
1989
|
+
const nodeAt = ns => (Array.isArray(ns) ? ns[this.index - 1] : ns);
|
|
1990
|
+
this.setIndex(this.index, nodeAt(prevNodeset) !== nodeAt(this.nodeset));
|
|
1042
1991
|
}
|
|
1043
1992
|
|
|
1044
1993
|
_initTemplate() {
|
|
@@ -1077,16 +2026,28 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
1077
2026
|
}
|
|
1078
2027
|
|
|
1079
2028
|
_initRepeatItems() {
|
|
1080
|
-
this.
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
2029
|
+
this._sizeLimit = this._getSizeLimit();
|
|
2030
|
+
this._virtual = this._isVirtual();
|
|
2031
|
+
this._windowStart = 0; // initial render always starts the window at the beginning
|
|
2032
|
+
const total = this.nodeset.length;
|
|
2033
|
+
this._renderTarget = Math.min(total, this._sizeLimit);
|
|
1084
2034
|
|
|
1085
|
-
|
|
2035
|
+
for (let position = 1; position <= this._renderTarget; position += 1) {
|
|
2036
|
+
const repeatItem = this._materializeRepeatItem(position);
|
|
1086
2037
|
|
|
1087
2038
|
if (this.getOwnerForm().createNodes) {
|
|
1088
2039
|
this.getOwnerForm().initData(repeatItem);
|
|
1089
|
-
|
|
2040
|
+
|
|
2041
|
+
// `createdNodeset` is only ever read once, as an insert template for a *new* row
|
|
2042
|
+
// (see fx-insert.js), so it's taken from the LAST MATERIALIZED row - cloning and
|
|
2043
|
+
// clearing every earlier row's nodeset was pure waste (O(n) discarded work, e.g.
|
|
2044
|
+
// 799 of 800 clones for the UNTDID 1001 codelist). It must be snapshotted AFTER
|
|
2045
|
+
// initData() above, which is what actually populates create-nodes-synthesized
|
|
2046
|
+
// descendants (e.g. TaxCategory/ID, TaxCategory/Percent) onto the node - cloning
|
|
2047
|
+
// before that yields an empty shell. Under a size cap this may not be the true
|
|
2048
|
+
// last logical nodeset entry, but it's the best representative shape available
|
|
2049
|
+
// (the true last entry may never be materialized).
|
|
2050
|
+
if (position === this._renderTarget && repeatItem.nodeset.nodeType) {
|
|
1090
2051
|
const repeatItemClone = repeatItem.nodeset.cloneNode(true);
|
|
1091
2052
|
this.clearTextValues(repeatItemClone);
|
|
1092
2053
|
this.createdNodeset = repeatItemClone;
|
|
@@ -1097,9 +2058,10 @@ export class FxRepeat extends withDraggability(UIElement, false) {
|
|
|
1097
2058
|
this.applyIndex(repeatItem);
|
|
1098
2059
|
}
|
|
1099
2060
|
|
|
1100
|
-
Fore.dispatch(this, 'item-created', { nodeset: repeatItem.nodeset, pos:
|
|
1101
|
-
|
|
1102
|
-
|
|
2061
|
+
Fore.dispatch(this, 'item-created', { nodeset: repeatItem.nodeset, pos: position });
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
this._syncSentinels();
|
|
1103
2065
|
}
|
|
1104
2066
|
|
|
1105
2067
|
clearTextValues(node) {
|