ngx-virtual-dnd 3.2.1 → 3.3.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/fesm2022/ngx-virtual-dnd.mjs +1839 -656
- package/fesm2022/ngx-virtual-dnd.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-virtual-dnd.d.ts +62 -26
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { signal, InjectionToken,
|
|
2
|
+
import { signal, InjectionToken, Injectable, inject, computed, effect, NgZone, DestroyRef, input, ChangeDetectionStrategy, Component, afterNextRender, ElementRef, Injector, EnvironmentInjector, PLATFORM_ID, viewChild, ViewContainerRef, untracked, isDevMode, createComponent, afterRenderEffect, Directive, output, contentChild, ErrorHandler, TemplateRef } from '@angular/core';
|
|
3
3
|
import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -75,9 +75,16 @@ class FixedHeightStrategy {
|
|
|
75
75
|
// No-op for fixed height strategy
|
|
76
76
|
}
|
|
77
77
|
setItemKeys(keys) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
this.setItemCount(keys.length);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Set the item count: all this strategy needs of the keys, so the library's containers skip
|
|
82
|
+
* computing them.
|
|
83
|
+
* @internal
|
|
84
|
+
*/
|
|
85
|
+
setItemCount(count) {
|
|
86
|
+
if (this.#itemCount !== count) {
|
|
87
|
+
this.#itemCount = count;
|
|
81
88
|
this.#version.update((v) => v + 1);
|
|
82
89
|
}
|
|
83
90
|
}
|
|
@@ -108,8 +115,15 @@ class FixedHeightStrategy {
|
|
|
108
115
|
* Cache for measured item heights in dynamic virtual scrolling.
|
|
109
116
|
*
|
|
110
117
|
* Tracks heights by trackBy key (survives reordering). Maintains a cumulative
|
|
111
|
-
* offset prefix-sum array
|
|
112
|
-
*
|
|
118
|
+
* offset prefix-sum array and binary searches it for scroll-to-index lookups.
|
|
119
|
+
*
|
|
120
|
+
* The prefix sums are brought up to date lazily and only as far as a lookup needs:
|
|
121
|
+
* a height change invalidates the offsets from that item on, and the next lookup
|
|
122
|
+
* recomputes them up to the item it asks about. Measurements land on the rendered
|
|
123
|
+
* rows and lookups ask about the rows in view, so while scrolling a long list each
|
|
124
|
+
* frame recomputes a few rows' offsets instead of the whole list. The total height
|
|
125
|
+
* is kept as a running sum, so the spacer height needs no prefix sums between key
|
|
126
|
+
* changes.
|
|
113
127
|
*
|
|
114
128
|
* This is a plain class (not a service) — one instance per virtual scroll.
|
|
115
129
|
*/
|
|
@@ -118,12 +132,21 @@ class HeightCache {
|
|
|
118
132
|
#estimatedHeight;
|
|
119
133
|
/** Map from trackBy key to measured height */
|
|
120
134
|
#heightsByKey = new Map();
|
|
121
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* Current ordered list of trackBy keys (index-aligned with item array). A copy of the array
|
|
137
|
+
* given to `setKeys`, so a caller that changes its array in place and passes it again is seen.
|
|
138
|
+
*/
|
|
122
139
|
#keys = [];
|
|
140
|
+
/** Index of each key's first occurrence in `#keys` */
|
|
141
|
+
#firstIndexByKey = new Map();
|
|
142
|
+
/** Whether a key occurs more than once in `#keys` (a trackBy misconfiguration) */
|
|
143
|
+
#hasDuplicateKeys = false;
|
|
123
144
|
/** Prefix sums: offsets[i] = cumulative height of items 0..i-1 */
|
|
124
145
|
#offsets = [];
|
|
125
|
-
/**
|
|
126
|
-
#
|
|
146
|
+
/** Number of leading `#offsets` entries that are up to date */
|
|
147
|
+
#validCount = 0;
|
|
148
|
+
/** Sum of the heights of all items in `#keys`, or null until it is next needed */
|
|
149
|
+
#totalHeight = 0;
|
|
127
150
|
/** Index excluded during same-list drag (-1 = none) */
|
|
128
151
|
#excludedIndex = -1;
|
|
129
152
|
constructor(estimatedHeight) {
|
|
@@ -142,21 +165,31 @@ class HeightCache {
|
|
|
142
165
|
* This must be called whenever items change order or the array changes.
|
|
143
166
|
*/
|
|
144
167
|
setKeys(keys) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
168
|
+
// The same keys in the same order (an items change that moved no item) leave the offsets and
|
|
169
|
+
// the key index valid: only heights recorded for keys not in the list are forgotten
|
|
170
|
+
if (keys.length === this.#keys.length &&
|
|
171
|
+
keys.every((key, index) => Object.is(key, this.#keys[index]))) {
|
|
172
|
+
return this.#forgetHeightsNotIn(this.#firstIndexByKey);
|
|
173
|
+
}
|
|
174
|
+
// Filled from the end, so each key keeps its first index. It also serves as the set of keys
|
|
175
|
+
// for pruning, so building it costs no extra pass.
|
|
176
|
+
const firstIndexByKey = new Map();
|
|
177
|
+
for (let i = keys.length - 1; i >= 0; i--) {
|
|
178
|
+
firstIndexByKey.set(keys[i], i);
|
|
179
|
+
}
|
|
180
|
+
this.#forgetHeightsNotIn(firstIndexByKey);
|
|
181
|
+
// Offsets before the first key that changed position are still valid
|
|
182
|
+
let firstChanged = 0;
|
|
183
|
+
const commonLength = Math.min(keys.length, this.#keys.length);
|
|
184
|
+
while (firstChanged < commonLength && Object.is(keys[firstChanged], this.#keys[firstChanged])) {
|
|
185
|
+
firstChanged++;
|
|
186
|
+
}
|
|
187
|
+
this.#keys = keys.slice();
|
|
188
|
+
this.#firstIndexByKey = firstIndexByKey;
|
|
189
|
+
this.#hasDuplicateKeys = firstIndexByKey.size < keys.length;
|
|
190
|
+
this.#offsets.length = keys.length;
|
|
191
|
+
this.#validCount = Math.min(this.#validCount, firstChanged);
|
|
192
|
+
this.#totalHeight = null;
|
|
160
193
|
return true;
|
|
161
194
|
}
|
|
162
195
|
/**
|
|
@@ -168,7 +201,21 @@ class HeightCache {
|
|
|
168
201
|
if (existing === height)
|
|
169
202
|
return false;
|
|
170
203
|
this.#heightsByKey.set(key, height);
|
|
171
|
-
|
|
204
|
+
// Only items at or after the key's first position move
|
|
205
|
+
const firstIndex = this.#firstIndexByKey.get(key);
|
|
206
|
+
if (firstIndex !== undefined) {
|
|
207
|
+
this.#validCount = Math.min(this.#validCount, firstIndex);
|
|
208
|
+
const previous = existing ?? this.#estimatedHeight;
|
|
209
|
+
// A key listed twice, or a non-finite height (which a running sum can't take back out),
|
|
210
|
+
// leaves the total to be recomputed from the offsets
|
|
211
|
+
this.#totalHeight =
|
|
212
|
+
this.#totalHeight === null ||
|
|
213
|
+
this.#hasDuplicateKeys ||
|
|
214
|
+
!Number.isFinite(height) ||
|
|
215
|
+
!Number.isFinite(previous)
|
|
216
|
+
? null
|
|
217
|
+
: this.#totalHeight + height - previous;
|
|
218
|
+
}
|
|
172
219
|
return true;
|
|
173
220
|
}
|
|
174
221
|
/**
|
|
@@ -186,18 +233,18 @@ class HeightCache {
|
|
|
186
233
|
* Accounts for the excluded index during same-list drag.
|
|
187
234
|
*/
|
|
188
235
|
getOffset(index) {
|
|
189
|
-
this.#
|
|
236
|
+
const count = this.#keys.length;
|
|
190
237
|
if (this.#excludedIndex < 0) {
|
|
191
238
|
// No exclusion — direct lookup
|
|
192
|
-
return index <
|
|
239
|
+
return index < count ? this.#offsetAt(index) : this.#getExactTotalHeight();
|
|
193
240
|
}
|
|
194
241
|
// With exclusion: subtract the excluded item's height from offsets
|
|
195
242
|
// at or after the excluded index
|
|
196
243
|
if (index <= this.#excludedIndex) {
|
|
197
|
-
return this.#
|
|
244
|
+
return this.#offsetAt(index) ?? 0;
|
|
198
245
|
}
|
|
199
246
|
// index > excludedIndex: offset = raw offset for (index) minus excluded item's height
|
|
200
|
-
const rawOffset = index <
|
|
247
|
+
const rawOffset = index < count ? this.#offsetAt(index) : this.#getExactTotalHeight();
|
|
201
248
|
const excludedHeight = this.getHeight(this.#excludedIndex);
|
|
202
249
|
return rawOffset - excludedHeight;
|
|
203
250
|
}
|
|
@@ -208,7 +255,6 @@ class HeightCache {
|
|
|
208
255
|
* Uses O(1) prefix-sum lookup when possible.
|
|
209
256
|
*/
|
|
210
257
|
getTotalHeight(itemCount) {
|
|
211
|
-
this.#rebuildIfDirty();
|
|
212
258
|
if (itemCount <= 0)
|
|
213
259
|
return 0;
|
|
214
260
|
const keyCount = this.#keys.length;
|
|
@@ -219,20 +265,24 @@ class HeightCache {
|
|
|
219
265
|
return this.#getTotalHeightRaw() + (itemCount - keyCount) * this.#estimatedHeight;
|
|
220
266
|
}
|
|
221
267
|
// Partial: prefix sum up to itemCount
|
|
222
|
-
return this.#
|
|
268
|
+
return this.#offsetAt(itemCount - 1) + this.getHeight(itemCount - 1);
|
|
223
269
|
}
|
|
224
270
|
/**
|
|
225
271
|
* Find the first visible index for a given scrollTop.
|
|
226
272
|
* Uses binary search on the prefix-sum offsets (excluding the excluded item).
|
|
227
273
|
*/
|
|
228
274
|
findFirstVisibleIndex(scrollTop) {
|
|
229
|
-
this.#rebuildIfDirty();
|
|
230
275
|
const count = this.#keys.length;
|
|
231
276
|
if (count === 0)
|
|
232
277
|
return 0;
|
|
233
|
-
//
|
|
278
|
+
// Offsets after the excluded item sit its height higher than their raw values, so
|
|
279
|
+
// bring the raw offsets up to date just past scrollTop plus that height
|
|
280
|
+
const excludedHeight = this.#excludedIndex >= 0 ? this.getHeight(this.#excludedIndex) : 0;
|
|
281
|
+
this.#extendPastOffset(scrollTop + excludedHeight);
|
|
282
|
+
// Binary search for the last item whose top is at or above scrollTop. Items past the
|
|
283
|
+
// up-to-date offsets start below scrollTop, so the search can stop there.
|
|
234
284
|
let lo = 0;
|
|
235
|
-
let hi = count - 1;
|
|
285
|
+
let hi = Math.min(count, this.#validCount) - 1;
|
|
236
286
|
let result = 0;
|
|
237
287
|
while (lo <= hi) {
|
|
238
288
|
const mid = (lo + hi) >>> 1;
|
|
@@ -278,7 +328,6 @@ class HeightCache {
|
|
|
278
328
|
* Used for drag index calculations with variable heights.
|
|
279
329
|
*/
|
|
280
330
|
findIndexAtOffset(offset) {
|
|
281
|
-
this.#rebuildIfDirty();
|
|
282
331
|
const count = this.#keys.length;
|
|
283
332
|
if (count === 0)
|
|
284
333
|
return 0;
|
|
@@ -295,7 +344,7 @@ class HeightCache {
|
|
|
295
344
|
if (excludedIndex < 0 || excludedIndex >= count) {
|
|
296
345
|
return this.#findFirstIndexWithBottomPastOffset(offset, 0, count - 1);
|
|
297
346
|
}
|
|
298
|
-
const excludedOffset = this.#
|
|
347
|
+
const excludedOffset = this.#offsetAt(excludedIndex);
|
|
299
348
|
if (offset < excludedOffset && excludedIndex > 0) {
|
|
300
349
|
return this.#findFirstIndexWithBottomPastOffset(offset, 0, excludedIndex - 1);
|
|
301
350
|
}
|
|
@@ -316,6 +365,17 @@ class HeightCache {
|
|
|
316
365
|
this.#excludedIndex = nextIndex;
|
|
317
366
|
return true;
|
|
318
367
|
}
|
|
368
|
+
/** Forget the heights of keys not in `keys`. Returns whether it forgot any. */
|
|
369
|
+
#forgetHeightsNotIn(keys) {
|
|
370
|
+
let forgot = false;
|
|
371
|
+
for (const key of this.#heightsByKey.keys()) {
|
|
372
|
+
if (!keys.has(key)) {
|
|
373
|
+
this.#heightsByKey.delete(key);
|
|
374
|
+
forgot = true;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return forgot;
|
|
378
|
+
}
|
|
319
379
|
/**
|
|
320
380
|
* Find first logical index in [start, end] whose bottom edge is past offset.
|
|
321
381
|
* Returns one-past-end when no match is found.
|
|
@@ -324,8 +384,15 @@ class HeightCache {
|
|
|
324
384
|
if (start > end) {
|
|
325
385
|
return start;
|
|
326
386
|
}
|
|
387
|
+
// Items past the up-to-date offsets start below `offset`, so their bottoms are past it
|
|
388
|
+
// too: the answer is at or before the first of them
|
|
389
|
+
this.#extendPastOffset(offset);
|
|
390
|
+
const lastValid = this.#validCount - 1;
|
|
391
|
+
if (start > lastValid) {
|
|
392
|
+
return start;
|
|
393
|
+
}
|
|
327
394
|
let lo = start;
|
|
328
|
-
let hi = end;
|
|
395
|
+
let hi = Math.min(end, lastValid);
|
|
329
396
|
let result = end + 1;
|
|
330
397
|
while (lo <= hi) {
|
|
331
398
|
const mid = (lo + hi) >>> 1;
|
|
@@ -340,27 +407,64 @@ class HeightCache {
|
|
|
340
407
|
}
|
|
341
408
|
return result;
|
|
342
409
|
}
|
|
343
|
-
/**
|
|
410
|
+
/**
|
|
411
|
+
* Raw total height without exclusion, from the running sum. With fractional heights it can
|
|
412
|
+
* differ from the prefix sums by rounding, so it is only for the spacer height.
|
|
413
|
+
*/
|
|
344
414
|
#getTotalHeightRaw() {
|
|
345
|
-
this.#
|
|
346
|
-
const len = this.#offsets.length;
|
|
347
|
-
if (len === 0)
|
|
415
|
+
if (this.#keys.length === 0)
|
|
348
416
|
return 0;
|
|
349
|
-
return this.#
|
|
417
|
+
return this.#totalHeight ?? this.#getExactTotalHeight();
|
|
350
418
|
}
|
|
351
|
-
/**
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
419
|
+
/**
|
|
420
|
+
* Raw total height computed like the offsets (the offset one past the last item), so it
|
|
421
|
+
* compares exactly with them. Re-syncs the running sum.
|
|
422
|
+
*/
|
|
423
|
+
#getExactTotalHeight() {
|
|
356
424
|
const count = this.#keys.length;
|
|
357
|
-
if (
|
|
358
|
-
|
|
425
|
+
if (count === 0)
|
|
426
|
+
return 0;
|
|
427
|
+
this.#extendTo(count, Infinity);
|
|
428
|
+
this.#totalHeight = this.#offsets[count - 1] + this.getHeight(count - 1);
|
|
429
|
+
return this.#totalHeight;
|
|
430
|
+
}
|
|
431
|
+
/** The raw offset of an item, bringing the prefix sums up to date as far as it */
|
|
432
|
+
#offsetAt(index) {
|
|
433
|
+
if (index >= this.#validCount) {
|
|
434
|
+
this.#extendTo(index + 1, Infinity);
|
|
435
|
+
}
|
|
436
|
+
return this.#offsets[index];
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Bring the prefix sums up to date until the last up-to-date item starts past `offset`, or
|
|
440
|
+
* to the end of the list.
|
|
441
|
+
*/
|
|
442
|
+
#extendPastOffset(offset) {
|
|
443
|
+
this.#extendTo(this.#keys.length, offset);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Bring the first `count` prefix sums up to date, stopping early after the first item that
|
|
447
|
+
* starts past `stopPastOffset`.
|
|
448
|
+
*/
|
|
449
|
+
#extendTo(count, stopPastOffset) {
|
|
450
|
+
const end = Math.min(count, this.#keys.length);
|
|
451
|
+
let i = this.#validCount;
|
|
452
|
+
if (i >= end || (i > 0 && this.#offsets[i - 1] > stopPastOffset)) {
|
|
453
|
+
return;
|
|
359
454
|
}
|
|
360
|
-
let cumulative = 0;
|
|
361
|
-
|
|
455
|
+
let cumulative = i === 0 ? 0 : this.#offsets[i - 1] + this.getHeight(i - 1);
|
|
456
|
+
while (i < end) {
|
|
362
457
|
this.#offsets[i] = cumulative;
|
|
363
|
-
|
|
458
|
+
i++;
|
|
459
|
+
if (cumulative > stopPastOffset) {
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
cumulative += this.getHeight(i - 1);
|
|
463
|
+
}
|
|
464
|
+
this.#validCount = i;
|
|
465
|
+
if (i === this.#keys.length) {
|
|
466
|
+
// Re-sync the running total with the prefix sums, dropping floating-point drift
|
|
467
|
+
this.#totalHeight = this.#offsets[i - 1] + this.getHeight(i - 1);
|
|
364
468
|
}
|
|
365
469
|
}
|
|
366
470
|
}
|
|
@@ -487,6 +591,168 @@ const DEFAULT_DROP_EASING = 'cubic-bezier(0.2, 0, 0, 1)';
|
|
|
487
591
|
*/
|
|
488
592
|
const VDND_ANIMATION_CONFIG = new InjectionToken('VDND_ANIMATION_CONFIG');
|
|
489
593
|
|
|
594
|
+
/**
|
|
595
|
+
* Registry of the rendered droppables, keyed by group and by ID.
|
|
596
|
+
*
|
|
597
|
+
* `DroppableDirective` registers its element once rendered and unregisters on destroy, so drag
|
|
598
|
+
* code reads the droppables of one group instead of querying the whole document for
|
|
599
|
+
* `data-droppable-*` attributes. That keeps lookups proportional to the group, not the page,
|
|
600
|
+
* and finds droppables a document query cannot reach (inside shadow roots). It also delivers
|
|
601
|
+
* a drop to its target the moment the drag ends (`DragStateService.endDrag`).
|
|
602
|
+
* @internal
|
|
603
|
+
*/
|
|
604
|
+
class DroppableRegistryService {
|
|
605
|
+
#byGroup = new Map();
|
|
606
|
+
#byId = new Map();
|
|
607
|
+
#listeners = new Set();
|
|
608
|
+
/**
|
|
609
|
+
* Register a droppable, with the handler that receives drops on it. Returns the function
|
|
610
|
+
* that unregisters it; calling that more than once is a no-op.
|
|
611
|
+
*/
|
|
612
|
+
register(element, id, group, onDrop = null) {
|
|
613
|
+
const registration = { element, id, group, onDrop };
|
|
614
|
+
this.#add(this.#byGroup, group, registration);
|
|
615
|
+
if (id) {
|
|
616
|
+
this.#add(this.#byId, id, registration);
|
|
617
|
+
}
|
|
618
|
+
this.#notify(group);
|
|
619
|
+
let registered = true;
|
|
620
|
+
return () => {
|
|
621
|
+
if (!registered) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
registered = false;
|
|
625
|
+
this.#remove(this.#byGroup, group, registration);
|
|
626
|
+
if (id) {
|
|
627
|
+
this.#remove(this.#byId, id, registration);
|
|
628
|
+
}
|
|
629
|
+
this.#notify(group);
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* The group's droppables that are in the document, in document order (the order they are
|
|
634
|
+
* painted in, which hit-testing relies on). Returns a new array.
|
|
635
|
+
*
|
|
636
|
+
* Sorted on every call rather than cached: a list moved in the DOM keeps its registration.
|
|
637
|
+
* Callers read it at drag start and on candidate changes, not per frame.
|
|
638
|
+
*/
|
|
639
|
+
getGroup(group) {
|
|
640
|
+
const registrations = this.#byGroup.get(group);
|
|
641
|
+
if (!registrations) {
|
|
642
|
+
return [];
|
|
643
|
+
}
|
|
644
|
+
const elements = [];
|
|
645
|
+
for (const { element } of registrations) {
|
|
646
|
+
if (element.isConnected) {
|
|
647
|
+
elements.push(element);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return elements.sort(compareElements);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* The droppable with this ID that is in the document, or null. If a re-render briefly
|
|
654
|
+
* leaves two with the same ID, returns the first in document order.
|
|
655
|
+
*/
|
|
656
|
+
getById(id) {
|
|
657
|
+
return this.#firstById(id)?.element ?? null;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Hand an ended drag to the drop handler of its target (`activeDroppableId`), so a drop
|
|
661
|
+
* reaches exactly one droppable. IDs should be unique; if several connected droppables share
|
|
662
|
+
* it (briefly, while re-rendering), those in the source list's group come first, then
|
|
663
|
+
* document order, and the first that takes the drop gets it. Does nothing when the drag had
|
|
664
|
+
* no target or item, or the target is no longer registered.
|
|
665
|
+
*/
|
|
666
|
+
deliverDrop(endedState) {
|
|
667
|
+
const targetId = endedState.activeDroppableId;
|
|
668
|
+
const registrations = targetId ? this.#byId.get(targetId) : undefined;
|
|
669
|
+
if (!registrations || !endedState.draggedItem) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const sourceId = endedState.sourceDroppableId;
|
|
673
|
+
const dragGroup = sourceId ? this.#firstById(sourceId)?.group : undefined;
|
|
674
|
+
const candidates = registrations
|
|
675
|
+
.filter((registration) => registration.onDrop && registration.element.isConnected)
|
|
676
|
+
.sort((a, b) => Number(b.group === dragGroup) - Number(a.group === dragGroup) ||
|
|
677
|
+
compareElements(a.element, b.element));
|
|
678
|
+
for (const { onDrop } of candidates) {
|
|
679
|
+
if (onDrop?.(endedState)) {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Call `listener` with the group whenever a droppable of that group registers or
|
|
686
|
+
* unregisters. Returns the function that removes the listener.
|
|
687
|
+
*/
|
|
688
|
+
onChange(listener) {
|
|
689
|
+
this.#listeners.add(listener);
|
|
690
|
+
return () => this.#listeners.delete(listener);
|
|
691
|
+
}
|
|
692
|
+
/** The registration with this ID that is in the document and first in document order. */
|
|
693
|
+
#firstById(id) {
|
|
694
|
+
const registrations = this.#byId.get(id);
|
|
695
|
+
if (!registrations) {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
let match = null;
|
|
699
|
+
for (const registration of registrations) {
|
|
700
|
+
if (registration.element.isConnected &&
|
|
701
|
+
(!match || compareElements(registration.element, match.element) < 0)) {
|
|
702
|
+
match = registration;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return match;
|
|
706
|
+
}
|
|
707
|
+
#add(map, key, registration) {
|
|
708
|
+
const list = map.get(key);
|
|
709
|
+
if (list) {
|
|
710
|
+
list.push(registration);
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
map.set(key, [registration]);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
#remove(map, key, registration) {
|
|
717
|
+
const list = map.get(key);
|
|
718
|
+
const index = list?.indexOf(registration) ?? -1;
|
|
719
|
+
if (!list || index === -1) {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
list.splice(index, 1);
|
|
723
|
+
if (list.length === 0) {
|
|
724
|
+
map.delete(key);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
#notify(group) {
|
|
728
|
+
for (const listener of this.#listeners) {
|
|
729
|
+
listener(group);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
733
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableRegistryService, providedIn: 'root' });
|
|
734
|
+
}
|
|
735
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableRegistryService, decorators: [{
|
|
736
|
+
type: Injectable,
|
|
737
|
+
args: [{
|
|
738
|
+
providedIn: 'root',
|
|
739
|
+
}]
|
|
740
|
+
}] });
|
|
741
|
+
/**
|
|
742
|
+
* Negative when `a` comes before `b` in the document (an ancestor comes before its
|
|
743
|
+
* descendants). Across shadow roots the order is stable but implementation-defined.
|
|
744
|
+
*/
|
|
745
|
+
function compareElements(a, b) {
|
|
746
|
+
if (a === b) {
|
|
747
|
+
return 0;
|
|
748
|
+
}
|
|
749
|
+
const position = a.compareDocumentPosition(b);
|
|
750
|
+
return position & Node.DOCUMENT_POSITION_FOLLOWING ||
|
|
751
|
+
position & Node.DOCUMENT_POSITION_CONTAINED_BY
|
|
752
|
+
? -1
|
|
753
|
+
: 1;
|
|
754
|
+
}
|
|
755
|
+
|
|
490
756
|
const INITIAL_CORE_STATE = {
|
|
491
757
|
isDragging: false,
|
|
492
758
|
draggedItem: null,
|
|
@@ -509,6 +775,7 @@ const INITIAL_CORE_STATE = {
|
|
|
509
775
|
* draggedItem, etc.).
|
|
510
776
|
*/
|
|
511
777
|
class DragStateService {
|
|
778
|
+
#droppableRegistry = inject(DroppableRegistryService);
|
|
512
779
|
/** Core state signal — contains all rarely-changing properties */
|
|
513
780
|
#state = signal(INITIAL_CORE_STATE, ...(ngDevMode ? [{ debugName: "#state" }] : []));
|
|
514
781
|
/** High-frequency cursor position — updated at 60fps during pointer drag */
|
|
@@ -525,7 +792,7 @@ class DragStateService {
|
|
|
525
792
|
#endedDragState = signal(null, ...(ngDevMode ? [{ debugName: "#endedDragState" }] : []));
|
|
526
793
|
/** Flag indicating if the last drag was cancelled (not dropped) */
|
|
527
794
|
#wasCancelled = signal(false, ...(ngDevMode ? [{ debugName: "#wasCancelled" }] : []));
|
|
528
|
-
/** Whether the last drag was cancelled (
|
|
795
|
+
/** Whether the last drag was cancelled (a cancelled drag delivers no drop) */
|
|
529
796
|
wasCancelled = this.#wasCancelled.asReadonly();
|
|
530
797
|
/** Snapshot captured synchronously before the last drag reset. */
|
|
531
798
|
endedDragState = this.#endedDragState.asReadonly();
|
|
@@ -662,18 +929,34 @@ class DragStateService {
|
|
|
662
929
|
this.#placeholderIndex.set(placeholderIndex);
|
|
663
930
|
}
|
|
664
931
|
/**
|
|
665
|
-
* End the drag operation and reset state (normal drop)
|
|
932
|
+
* End the drag operation and reset state (normal drop), then deliver the drop: the target
|
|
933
|
+
* droppable (`activeDroppableId`) emits `drop` before this returns.
|
|
666
934
|
*/
|
|
667
935
|
endDrag() {
|
|
668
|
-
|
|
936
|
+
// Only a drag in progress ends. Again (e.g. from a drop handler) it would overwrite the
|
|
937
|
+
// ended drag, which the drop animation reads, with an empty one.
|
|
938
|
+
if (!this.#state().isDragging) {
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const endedState = this.getStateSnapshot();
|
|
942
|
+
this.#endedDragState.set(endedState);
|
|
669
943
|
this.#wasCancelled.set(false);
|
|
670
944
|
this.#resetHighFrequencySignals();
|
|
671
945
|
this.#state.set(INITIAL_CORE_STATE);
|
|
946
|
+
// After the reset, so the drop handler sees the drag as over. Delivered here rather than
|
|
947
|
+
// from a droppable effect on the next change detection: by then the target may already
|
|
948
|
+
// be destroyed or re-created (a drop zone rendered only while dragging, a list replaced
|
|
949
|
+
// by a (dragEnd) handler), and every other droppable would have to tell a new drop from
|
|
950
|
+
// an old one.
|
|
951
|
+
this.#droppableRegistry.deliverDrop(endedState);
|
|
672
952
|
}
|
|
673
953
|
/**
|
|
674
954
|
* Cancel the drag operation (escape key, disabled, etc.).
|
|
675
955
|
*/
|
|
676
956
|
cancelDrag() {
|
|
957
|
+
if (!this.#state().isDragging) {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
677
960
|
this.#endedDragState.set(this.getStateSnapshot());
|
|
678
961
|
this.#wasCancelled.set(true);
|
|
679
962
|
this.#resetHighFrequencySignals();
|
|
@@ -760,26 +1043,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
760
1043
|
}]
|
|
761
1044
|
}], ctorParameters: () => [] });
|
|
762
1045
|
|
|
763
|
-
/**
|
|
764
|
-
* Query by an exact attribute value without interpolating consumer data into a CSS selector.
|
|
765
|
-
*
|
|
766
|
-
* CSS attribute selectors need string-specific escaping that differs from `CSS.escape()`.
|
|
767
|
-
* Filtering candidates by attribute presence avoids selector syntax errors for IDs containing
|
|
768
|
-
* quotes, brackets, backslashes, or other selector-sensitive characters.
|
|
769
|
-
*/
|
|
770
|
-
function queryByAttribute(root, attributeName, attributeValue) {
|
|
771
|
-
const candidates = root.querySelectorAll(`[${attributeName}]`);
|
|
772
|
-
for (const candidate of candidates) {
|
|
773
|
-
if (candidate.getAttribute(attributeName) === attributeValue) {
|
|
774
|
-
return candidate;
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
return null;
|
|
778
|
-
}
|
|
779
|
-
function queryAllByAttribute(root, attributeName, attributeValue) {
|
|
780
|
-
return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
|
|
781
|
-
}
|
|
782
|
-
|
|
783
1046
|
/** The parent lookups stop below `<body>`: it and `<html>` never count as list or item. */
|
|
784
1047
|
function isPageRoot(element) {
|
|
785
1048
|
return element.tagName === 'BODY' || element.tagName === 'HTML';
|
|
@@ -790,6 +1053,7 @@ function isPageRoot(element) {
|
|
|
790
1053
|
*/
|
|
791
1054
|
class PositionCalculatorService {
|
|
792
1055
|
#ngZone = inject(NgZone);
|
|
1056
|
+
#registry = inject(DroppableRegistryService);
|
|
793
1057
|
/** Data attribute used to identify droppable elements */
|
|
794
1058
|
#DROPPABLE_ID_ATTR = 'data-droppable-id';
|
|
795
1059
|
/** Data attribute used to identify droppable groups */
|
|
@@ -802,6 +1066,15 @@ class PositionCalculatorService {
|
|
|
802
1066
|
#nearEdgeResult = { top: false, bottom: false, left: false, right: false };
|
|
803
1067
|
/** Active drag session rect snapshot, or null when no drag is in progress. */
|
|
804
1068
|
#session = null;
|
|
1069
|
+
constructor() {
|
|
1070
|
+
// A droppable (un)registering mid-drag makes the next hit-test re-read the candidates.
|
|
1071
|
+
// Removed on destroy in case the service is provided below root.
|
|
1072
|
+
const removeListener = this.#registry.onChange((group) => this.notifyCandidatesChanged(group));
|
|
1073
|
+
inject(DestroyRef).onDestroy(() => {
|
|
1074
|
+
removeListener();
|
|
1075
|
+
this.endDragSession();
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
805
1078
|
/**
|
|
806
1079
|
* Begin a drag session: snapshot the candidate droppables for `groupName` and
|
|
807
1080
|
* their rects once, then watch for viewport changes (scroll/resize) that would
|
|
@@ -821,7 +1094,7 @@ class PositionCalculatorService {
|
|
|
821
1094
|
this.#session = {
|
|
822
1095
|
groupName,
|
|
823
1096
|
candidates,
|
|
824
|
-
rects:
|
|
1097
|
+
rects: this.#measureRects(candidates, []),
|
|
825
1098
|
dirty: false,
|
|
826
1099
|
candidatesStale: false,
|
|
827
1100
|
onViewportChange,
|
|
@@ -867,10 +1140,11 @@ class PositionCalculatorService {
|
|
|
867
1140
|
}
|
|
868
1141
|
}
|
|
869
1142
|
/**
|
|
870
|
-
* Re-
|
|
871
|
-
* rects. Unlike {@link invalidateDroppableRects} (which only refreshes
|
|
872
|
-
* picks up droppables added or removed since the snapshot was captured at
|
|
873
|
-
*
|
|
1143
|
+
* Re-read the candidate droppable *list* for the active session from the registry and
|
|
1144
|
+
* re-read their rects. Unlike {@link invalidateDroppableRects} (which only refreshes
|
|
1145
|
+
* rects), this picks up droppables added or removed since the snapshot was captured at
|
|
1146
|
+
* drag start. Registration changes already trigger it on the next hit-test; this is the
|
|
1147
|
+
* manual escape hatch.
|
|
874
1148
|
*
|
|
875
1149
|
* Safe to call when no session is active (no-op).
|
|
876
1150
|
*/
|
|
@@ -880,19 +1154,18 @@ class PositionCalculatorService {
|
|
|
880
1154
|
return;
|
|
881
1155
|
}
|
|
882
1156
|
session.candidates = this.#queryDroppables(session.groupName);
|
|
883
|
-
session.rects = session.candidates
|
|
1157
|
+
session.rects = this.#measureRects(session.candidates, []);
|
|
884
1158
|
session.dirty = false;
|
|
885
1159
|
session.candidatesStale = false;
|
|
886
1160
|
this.#observeCandidates(session);
|
|
887
1161
|
}
|
|
888
1162
|
/**
|
|
889
1163
|
* Signal that the set of droppables for `groupName` may have changed (a droppable
|
|
890
|
-
* registered or unregistered mid-drag). Defers the
|
|
891
|
-
*
|
|
892
|
-
* one has its data attributes applied — keeping this notification path cheap.
|
|
1164
|
+
* registered or unregistered mid-drag). Defers the re-read to the next hit-test, keeping
|
|
1165
|
+
* this notification path cheap.
|
|
893
1166
|
*
|
|
894
|
-
* Called
|
|
895
|
-
*
|
|
1167
|
+
* Called for every droppable registry change. No-op when the active session belongs to
|
|
1168
|
+
* a different group or no session is active.
|
|
896
1169
|
*/
|
|
897
1170
|
notifyCandidatesChanged(groupName) {
|
|
898
1171
|
if (this.#session && this.#session.groupName === groupName) {
|
|
@@ -922,23 +1195,20 @@ class PositionCalculatorService {
|
|
|
922
1195
|
this.refreshCandidates();
|
|
923
1196
|
}
|
|
924
1197
|
else if (session.dirty) {
|
|
925
|
-
|
|
926
|
-
session.rects[i] = this.#measureRect(session.candidates[i]);
|
|
927
|
-
}
|
|
1198
|
+
this.#measureRects(session.candidates, session.rects);
|
|
928
1199
|
session.dirty = false;
|
|
929
1200
|
}
|
|
930
1201
|
return this.#hitTest(x, y, session.candidates, session.rects);
|
|
931
1202
|
}
|
|
932
1203
|
// No active session: one-shot geometric query (still avoids elementFromPoint).
|
|
933
1204
|
const candidates = this.#queryDroppables(groupName);
|
|
934
|
-
|
|
935
|
-
return this.#hitTest(x, y, candidates, rects);
|
|
1205
|
+
return this.#hitTest(x, y, candidates, this.#measureRects(candidates, []));
|
|
936
1206
|
}
|
|
937
1207
|
/**
|
|
938
1208
|
* Look up a droppable element by its ID.
|
|
939
1209
|
*
|
|
940
|
-
* When a drag session is active, searches the cached candidate list
|
|
941
|
-
*
|
|
1210
|
+
* When a drag session is active, searches the cached candidate list. Otherwise looks the
|
|
1211
|
+
* ID up in the droppable registry. Neither queries the document.
|
|
942
1212
|
*
|
|
943
1213
|
* Intended for the autoscroll scroll-only fast path, where the active droppable is already
|
|
944
1214
|
* known and only the placeholder index needs recalculation.
|
|
@@ -952,13 +1222,10 @@ class PositionCalculatorService {
|
|
|
952
1222
|
}
|
|
953
1223
|
return null;
|
|
954
1224
|
}
|
|
955
|
-
|
|
956
|
-
return null;
|
|
957
|
-
}
|
|
958
|
-
return queryByAttribute(document, this.#DROPPABLE_ID_ATTR, id);
|
|
1225
|
+
return this.#registry.getById(id);
|
|
959
1226
|
}
|
|
960
1227
|
/**
|
|
961
|
-
*
|
|
1228
|
+
* All rendered droppables belonging to a group, in document order, from the registry.
|
|
962
1229
|
*
|
|
963
1230
|
* All group members are included — the disabled check happens per-frame in
|
|
964
1231
|
* {@link #hitTest} rather than here, because a droppable can be disabled or
|
|
@@ -966,30 +1233,46 @@ class PositionCalculatorService {
|
|
|
966
1233
|
* `(dragStart)` handler, which fires AFTER the candidate snapshot is captured).
|
|
967
1234
|
*/
|
|
968
1235
|
#queryDroppables(groupName) {
|
|
969
|
-
|
|
970
|
-
return [];
|
|
971
|
-
}
|
|
972
|
-
return queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
|
|
1236
|
+
return this.#registry.getGroup(groupName);
|
|
973
1237
|
}
|
|
974
1238
|
/**
|
|
975
|
-
*
|
|
976
|
-
* ancestor
|
|
977
|
-
* still hit-tests over its full unclipped rect (issue #23
|
|
978
|
-
*
|
|
979
|
-
* `#hitTest` bounds check can never match it.
|
|
1239
|
+
* The ancestor that clips a candidate's hit-test rect: its nearest `.vdnd-scrollable`
|
|
1240
|
+
* ancestor, or null when it has none or is one itself. Without clipping a droppable scrolled
|
|
1241
|
+
* mostly out of a clipping container still hit-tests over its full unclipped rect (issue #23
|
|
1242
|
+
* case 3).
|
|
980
1243
|
*/
|
|
981
|
-
#
|
|
982
|
-
const rect = el.getBoundingClientRect();
|
|
1244
|
+
#clipOf(el) {
|
|
983
1245
|
const scrollable = el.closest('.vdnd-scrollable');
|
|
984
|
-
|
|
985
|
-
|
|
1246
|
+
return scrollable === el ? null : scrollable;
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Measure the candidates' hit-test rects into `rects`, each clipped to its clip ancestor (see
|
|
1250
|
+
* `#clipOf`). Consecutive candidates in one scroller (lists side by side in it) read its rect
|
|
1251
|
+
* once. The ancestor is looked up on each pass: a list moved to another scroller mid-drag keeps
|
|
1252
|
+
* its registration. The intersection is built as a plain DOMRect; an empty intersection yields
|
|
1253
|
+
* a negative width/height so the `#hitTest` bounds check can never match it.
|
|
1254
|
+
*/
|
|
1255
|
+
#measureRects(candidates, rects) {
|
|
1256
|
+
let lastClip = null;
|
|
1257
|
+
let lastClipRect = null;
|
|
1258
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
1259
|
+
const rect = candidates[i].getBoundingClientRect();
|
|
1260
|
+
const clip = this.#clipOf(candidates[i]);
|
|
1261
|
+
if (!clip) {
|
|
1262
|
+
rects[i] = rect;
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
if (clip !== lastClip || !lastClipRect) {
|
|
1266
|
+
lastClip = clip;
|
|
1267
|
+
lastClipRect = clip.getBoundingClientRect();
|
|
1268
|
+
}
|
|
1269
|
+
const top = Math.max(rect.top, lastClipRect.top);
|
|
1270
|
+
const left = Math.max(rect.left, lastClipRect.left);
|
|
1271
|
+
const right = Math.min(rect.right, lastClipRect.right);
|
|
1272
|
+
const bottom = Math.min(rect.bottom, lastClipRect.bottom);
|
|
1273
|
+
rects[i] = new DOMRect(left, top, right - left, bottom - top);
|
|
986
1274
|
}
|
|
987
|
-
|
|
988
|
-
const top = Math.max(rect.top, clip.top);
|
|
989
|
-
const left = Math.max(rect.left, clip.left);
|
|
990
|
-
const right = Math.min(rect.right, clip.right);
|
|
991
|
-
const bottom = Math.min(rect.bottom, clip.bottom);
|
|
992
|
-
return new DOMRect(left, top, right - left, bottom - top);
|
|
1275
|
+
return rects;
|
|
993
1276
|
}
|
|
994
1277
|
/**
|
|
995
1278
|
* Create a ResizeObserver (run outside Angular per CLAUDE.md) that marks the session's
|
|
@@ -1191,21 +1474,20 @@ class PositionCalculatorService {
|
|
|
1191
1474
|
// current container — otherwise a container disabled mid-drag (its own index becomes
|
|
1192
1475
|
// -1) would trap the drag with no reachable neighbour. Disabled droppables are skipped
|
|
1193
1476
|
// as *targets* during the outward scan below instead.
|
|
1194
|
-
const allDroppables =
|
|
1477
|
+
const allDroppables = this.#registry.getGroup(groupName);
|
|
1195
1478
|
if (allDroppables.length <= 1) {
|
|
1196
1479
|
return null;
|
|
1197
1480
|
}
|
|
1198
1481
|
// Get bounding rects, IDs, and disabled state, sorted by X position
|
|
1199
1482
|
const droppableInfos = [];
|
|
1200
1483
|
allDroppables.forEach((el) => {
|
|
1201
|
-
const
|
|
1202
|
-
const id = htmlEl.getAttribute(this.#DROPPABLE_ID_ATTR);
|
|
1484
|
+
const id = el.getAttribute(this.#DROPPABLE_ID_ATTR);
|
|
1203
1485
|
if (id) {
|
|
1204
1486
|
droppableInfos.push({
|
|
1205
|
-
element:
|
|
1487
|
+
element: el,
|
|
1206
1488
|
id,
|
|
1207
|
-
rect:
|
|
1208
|
-
disabled: this.#isDroppableDisabled(
|
|
1489
|
+
rect: el.getBoundingClientRect(),
|
|
1490
|
+
disabled: this.#isDroppableDisabled(el),
|
|
1209
1491
|
});
|
|
1210
1492
|
}
|
|
1211
1493
|
});
|
|
@@ -1249,7 +1531,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
1249
1531
|
args: [{
|
|
1250
1532
|
providedIn: 'root',
|
|
1251
1533
|
}]
|
|
1252
|
-
}] });
|
|
1534
|
+
}], ctorParameters: () => [] });
|
|
1253
1535
|
|
|
1254
1536
|
/**
|
|
1255
1537
|
* Single RAF loop that coordinates all drag-frame work in ordered phases:
|
|
@@ -1262,6 +1544,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
1262
1544
|
* latest cursor and a `cursorDirty` flag. Callers skip their hit-test when
|
|
1263
1545
|
* the flag is false, so autoscroll-only frames (cursor stationary) incur
|
|
1264
1546
|
* zero hit-test work.
|
|
1547
|
+
* 3. Frame writer phase (WRITE): registered writers apply the state computed this
|
|
1548
|
+
* frame to the DOM. DragPreviewComponent writes the preview transform here, so
|
|
1549
|
+
* the preview moves in the frame that processed the pointer instead of waiting
|
|
1550
|
+
* for a change detection tick, which Angular schedules after this frame.
|
|
1265
1551
|
*
|
|
1266
1552
|
* PointerDragHandler calls `queueCursorUpdate()` instead of managing its own
|
|
1267
1553
|
* RAF per pointermove. This coalesces all moves within a frame into one update
|
|
@@ -1277,6 +1563,8 @@ class DragSchedulerService {
|
|
|
1277
1563
|
#cursorDirty = false;
|
|
1278
1564
|
/** Tick participants called before the main compute phase each frame. */
|
|
1279
1565
|
#participants = [];
|
|
1566
|
+
/** Frame writers called after the main compute phase each frame. */
|
|
1567
|
+
#frameWriters = [];
|
|
1280
1568
|
/**
|
|
1281
1569
|
* Start the scheduler RAF loop.
|
|
1282
1570
|
* No-op if already running.
|
|
@@ -1333,6 +1621,26 @@ class DragSchedulerService {
|
|
|
1333
1621
|
this.#participants.splice(idx, 1);
|
|
1334
1622
|
}
|
|
1335
1623
|
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Register a function to be called at the end of each frame, after the main tick.
|
|
1626
|
+
* DragPreviewComponent registers here to move the preview within the same frame.
|
|
1627
|
+
* No-op if the same function reference is already registered.
|
|
1628
|
+
*/
|
|
1629
|
+
addFrameWriter(fn) {
|
|
1630
|
+
if (!this.#frameWriters.includes(fn)) {
|
|
1631
|
+
this.#frameWriters.push(fn);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Unregister a previously added frame writer.
|
|
1636
|
+
* No-op if the function was not registered.
|
|
1637
|
+
*/
|
|
1638
|
+
removeFrameWriter(fn) {
|
|
1639
|
+
const idx = this.#frameWriters.indexOf(fn);
|
|
1640
|
+
if (idx !== -1) {
|
|
1641
|
+
this.#frameWriters.splice(idx, 1);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1336
1644
|
#scheduleNextTick() {
|
|
1337
1645
|
this.#rafId = requestAnimationFrame(() => this.#tick());
|
|
1338
1646
|
}
|
|
@@ -1360,7 +1668,15 @@ class DragSchedulerService {
|
|
|
1360
1668
|
}
|
|
1361
1669
|
// Phase 2 — main compute (cursor-based hit-test + signal write)
|
|
1362
1670
|
onTick(this.#pendingCursor, cursorDirty);
|
|
1363
|
-
//
|
|
1671
|
+
// stop() may have been called by onTick (the drag ended): nothing left to write.
|
|
1672
|
+
if (this.#onTick === null) {
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
// Phase 3 — frame writers (apply this frame's state to the DOM)
|
|
1676
|
+
for (const writer of this.#frameWriters) {
|
|
1677
|
+
writer();
|
|
1678
|
+
}
|
|
1679
|
+
// Reschedule only if still active (a writer is not expected to stop it, but may).
|
|
1364
1680
|
if (this.#onTick !== null) {
|
|
1365
1681
|
this.#scheduleNextTick();
|
|
1366
1682
|
}
|
|
@@ -1411,6 +1727,14 @@ class AutoScrollService {
|
|
|
1411
1727
|
/** Last tick cursor position for stationary detection */
|
|
1412
1728
|
#lastTickCursorX = NaN;
|
|
1413
1729
|
#lastTickCursorY = NaN;
|
|
1730
|
+
/**
|
|
1731
|
+
* The containers the last tick that ran asked to scroll but had no room left: the first
|
|
1732
|
+
* #exhaustedCount entries (the objects are reused from tick to tick). A resting cursor skips the
|
|
1733
|
+
* tick only while they stay as they were on the axes it asked for: content that grows (rows
|
|
1734
|
+
* measured taller, items added), a resize or a scroll can give one room, and the scroll resumes.
|
|
1735
|
+
*/
|
|
1736
|
+
#exhausted = [];
|
|
1737
|
+
#exhaustedCount = 0;
|
|
1414
1738
|
/**
|
|
1415
1739
|
* Last timestamp used to scale per-frame autoscroll distance by elapsed frame time.
|
|
1416
1740
|
* Scale is intentionally floored at 1 to preserve the existing feel on 60fps+ displays while
|
|
@@ -1514,6 +1838,8 @@ class AutoScrollService {
|
|
|
1514
1838
|
this.#cursorOverride = null;
|
|
1515
1839
|
this.#lastTickCursorX = NaN;
|
|
1516
1840
|
this.#lastTickCursorY = NaN;
|
|
1841
|
+
this.#exhausted.length = 0;
|
|
1842
|
+
this.#exhaustedCount = 0;
|
|
1517
1843
|
this.#lastScrollTimestamp = 0;
|
|
1518
1844
|
this.#scrollState.containerId = null;
|
|
1519
1845
|
this.#scrollState.direction.x = 0;
|
|
@@ -1542,14 +1868,17 @@ class AutoScrollService {
|
|
|
1542
1868
|
if (!cursor) {
|
|
1543
1869
|
return;
|
|
1544
1870
|
}
|
|
1545
|
-
// Skip the container iteration when cursor hasn't moved and scroll is idle
|
|
1871
|
+
// Skip the container iteration when the cursor hasn't moved and scroll is idle, unless a
|
|
1872
|
+
// container it asked to scroll without room has changed since (see #exhausted).
|
|
1546
1873
|
if (cursor.x === this.#lastTickCursorX &&
|
|
1547
1874
|
cursor.y === this.#lastTickCursorY &&
|
|
1548
|
-
!this.isScrolling()
|
|
1875
|
+
!this.isScrolling() &&
|
|
1876
|
+
!this.#exhaustedChanged()) {
|
|
1549
1877
|
return;
|
|
1550
1878
|
}
|
|
1551
1879
|
this.#lastTickCursorX = cursor.x;
|
|
1552
1880
|
this.#lastTickCursorY = cursor.y;
|
|
1881
|
+
this.#exhaustedCount = 0;
|
|
1553
1882
|
let scrollPerformed = false;
|
|
1554
1883
|
// Frame scale is shared across candidates within a tick; computed lazily on
|
|
1555
1884
|
// the first scroll attempt so stationary/idle frames don't advance the clock.
|
|
@@ -1609,8 +1938,19 @@ class AutoScrollService {
|
|
|
1609
1938
|
this.#scrollState.direction.y = direction.y;
|
|
1610
1939
|
this.#scrollState.speed = speed;
|
|
1611
1940
|
scrollPerformed = true;
|
|
1941
|
+
// Notify callback IMMEDIATELY in the same frame (no RAF delay)
|
|
1942
|
+
// Delaying via RAF causes cumulative drift during continuous autoscroll
|
|
1943
|
+
// because multiple scrolls happen before each delayed callback runs.
|
|
1944
|
+
// Note: No ngZone.run() needed here - the callback (DraggableDirective.#recalculatePlaceholder)
|
|
1945
|
+
// updates signals and plain fields, and enters the zone itself to emit dragEnd if it ends
|
|
1946
|
+
// the drag.
|
|
1947
|
+
// It runs after the scroll state is recorded: it may end the drag, and the reset of
|
|
1948
|
+
// stopMonitoring() must not be overwritten.
|
|
1949
|
+
this.#onScrollCallback?.();
|
|
1612
1950
|
break;
|
|
1613
1951
|
}
|
|
1952
|
+
// No room in any requested direction: a resting cursor retries it once it changes
|
|
1953
|
+
this.#recordExhausted(element, direction);
|
|
1614
1954
|
}
|
|
1615
1955
|
}
|
|
1616
1956
|
if (!scrollPerformed) {
|
|
@@ -1670,16 +2010,46 @@ class AutoScrollService {
|
|
|
1670
2010
|
}
|
|
1671
2011
|
}
|
|
1672
2012
|
}
|
|
1673
|
-
|
|
1674
|
-
|
|
2013
|
+
return scrolled;
|
|
2014
|
+
}
|
|
2015
|
+
/** Record a container that had no room in `direction` (see #exhausted). */
|
|
2016
|
+
#recordExhausted(element, direction) {
|
|
2017
|
+
const exhausted = (this.#exhausted[this.#exhaustedCount] ??= {
|
|
2018
|
+
element,
|
|
2019
|
+
vertical: false,
|
|
2020
|
+
horizontal: false,
|
|
2021
|
+
scrollTop: 0,
|
|
2022
|
+
scrollLeft: 0,
|
|
2023
|
+
maxScrollTop: 0,
|
|
2024
|
+
maxScrollLeft: 0,
|
|
2025
|
+
});
|
|
2026
|
+
exhausted.element = element;
|
|
2027
|
+
exhausted.vertical = direction.y !== 0;
|
|
2028
|
+
exhausted.horizontal = direction.x !== 0;
|
|
2029
|
+
exhausted.scrollTop = element.scrollTop;
|
|
2030
|
+
exhausted.scrollLeft = element.scrollLeft;
|
|
2031
|
+
exhausted.maxScrollTop = element.scrollHeight - element.clientHeight;
|
|
2032
|
+
exhausted.maxScrollLeft = element.scrollWidth - element.clientWidth;
|
|
2033
|
+
this.#exhaustedCount++;
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Whether a container in #exhausted has scrolled or changed size on an axis it was asked to
|
|
2037
|
+
* scroll, so it may have room now.
|
|
2038
|
+
*/
|
|
2039
|
+
#exhaustedChanged() {
|
|
2040
|
+
for (let i = 0; i < this.#exhaustedCount; i++) {
|
|
2041
|
+
const exhausted = this.#exhausted[i];
|
|
2042
|
+
const { element } = exhausted;
|
|
2043
|
+
if ((exhausted.vertical &&
|
|
2044
|
+
(element.scrollTop !== exhausted.scrollTop ||
|
|
2045
|
+
element.scrollHeight - element.clientHeight !== exhausted.maxScrollTop)) ||
|
|
2046
|
+
(exhausted.horizontal &&
|
|
2047
|
+
(element.scrollLeft !== exhausted.scrollLeft ||
|
|
2048
|
+
element.scrollWidth - element.clientWidth !== exhausted.maxScrollLeft))) {
|
|
2049
|
+
return true;
|
|
2050
|
+
}
|
|
1675
2051
|
}
|
|
1676
|
-
|
|
1677
|
-
// Delaying via RAF causes cumulative drift during continuous autoscroll
|
|
1678
|
-
// because multiple scrolls happen before each delayed callback runs.
|
|
1679
|
-
// Note: No ngZone.run() needed here - the callback (DraggableDirective.#recalculatePlaceholder)
|
|
1680
|
-
// already enters the zone when updating drag state.
|
|
1681
|
-
this.#onScrollCallback?.();
|
|
1682
|
-
return true;
|
|
2052
|
+
return false;
|
|
1683
2053
|
}
|
|
1684
2054
|
#getFrameScale() {
|
|
1685
2055
|
const now = performance.now();
|
|
@@ -1720,20 +2090,19 @@ class ElementCloneService {
|
|
|
1720
2090
|
* These are the visual properties that affect appearance. Keeping them
|
|
1721
2091
|
* kebab-case avoids a camelCase→kebab conversion on every property of every
|
|
1722
2092
|
* node during the recursive clone walk (a drag-start hot path).
|
|
2093
|
+
*
|
|
2094
|
+
* A shorthand carries more than the longhands listed with it (`background-size`,
|
|
2095
|
+
* `font-style`, …), so it is copied first, and the longhands only when the engine can't
|
|
2096
|
+
* serialize the shorthand (it gives '' for `font` with `font-variant-ligatures: none`, for
|
|
2097
|
+
* example). Copying both would read and write the same values twice.
|
|
1723
2098
|
*/
|
|
1724
|
-
#
|
|
1725
|
-
'background',
|
|
1726
|
-
'background-color',
|
|
1727
|
-
'background-image',
|
|
2099
|
+
#stylesToCopy = [
|
|
2100
|
+
['background', ['background-color', 'background-image']],
|
|
1728
2101
|
'border',
|
|
1729
2102
|
'border-radius',
|
|
1730
2103
|
'box-shadow',
|
|
1731
2104
|
'color',
|
|
1732
|
-
'font',
|
|
1733
|
-
'font-family',
|
|
1734
|
-
'font-size',
|
|
1735
|
-
'font-weight',
|
|
1736
|
-
'line-height',
|
|
2105
|
+
['font', ['font-family', 'font-size', 'font-weight', 'line-height']],
|
|
1737
2106
|
'padding',
|
|
1738
2107
|
'margin',
|
|
1739
2108
|
'display',
|
|
@@ -1775,10 +2144,22 @@ class ElementCloneService {
|
|
|
1775
2144
|
#applyComputedStyles(source, target) {
|
|
1776
2145
|
const computed = window.getComputedStyle(source);
|
|
1777
2146
|
// Copy essential visual properties (keys are already kebab-case)
|
|
1778
|
-
for (const
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
2147
|
+
for (const style of this.#stylesToCopy) {
|
|
2148
|
+
if (typeof style === 'string') {
|
|
2149
|
+
this.#copyStyle(computed, target, style);
|
|
2150
|
+
continue;
|
|
2151
|
+
}
|
|
2152
|
+
// The longhands only when the shorthand was not copied: the engine gave no value, or the
|
|
2153
|
+
// clone rejected it. A shorthand sets all its longhands, so reading one back tells (the
|
|
2154
|
+
// shorthand itself would be serialized again to be read). The clone may carry that
|
|
2155
|
+
// longhand inline from the source, so it goes first.
|
|
2156
|
+
const [shorthand, longhands] = style;
|
|
2157
|
+
target.style.removeProperty(longhands[0]);
|
|
2158
|
+
if (!this.#copyStyle(computed, target, shorthand) ||
|
|
2159
|
+
!target.style.getPropertyValue(longhands[0])) {
|
|
2160
|
+
for (const longhand of longhands) {
|
|
2161
|
+
this.#copyStyle(computed, target, longhand);
|
|
2162
|
+
}
|
|
1782
2163
|
}
|
|
1783
2164
|
}
|
|
1784
2165
|
// Disable animations and transitions on clone
|
|
@@ -1795,6 +2176,18 @@ class ElementCloneService {
|
|
|
1795
2176
|
}
|
|
1796
2177
|
}
|
|
1797
2178
|
}
|
|
2179
|
+
/**
|
|
2180
|
+
* Copy one computed property to the target's inline style. Returns whether the engine gave a
|
|
2181
|
+
* value for it.
|
|
2182
|
+
*/
|
|
2183
|
+
#copyStyle(computed, target, property) {
|
|
2184
|
+
const value = computed.getPropertyValue(property);
|
|
2185
|
+
if (!value) {
|
|
2186
|
+
return false;
|
|
2187
|
+
}
|
|
2188
|
+
target.style.setProperty(property, value);
|
|
2189
|
+
return true;
|
|
2190
|
+
}
|
|
1798
2191
|
/**
|
|
1799
2192
|
* Handle special elements that require extra processing.
|
|
1800
2193
|
*/
|
|
@@ -2149,9 +2542,7 @@ class DragIndexCalculatorService {
|
|
|
2149
2542
|
return cached;
|
|
2150
2543
|
const isViewport = droppableElement.hasAttribute('data-virtual-viewport');
|
|
2151
2544
|
const virtualScrollElement = droppableElement.querySelector('vdnd-virtual-scroll');
|
|
2152
|
-
const virtualContentElement = (droppableElement
|
|
2153
|
-
? droppableElement
|
|
2154
|
-
: droppableElement.closest('vdnd-virtual-content'));
|
|
2545
|
+
const virtualContentElement = this.#findVirtualContent(droppableElement);
|
|
2155
2546
|
let containerType;
|
|
2156
2547
|
let scrollContainer;
|
|
2157
2548
|
let scrollableParent = null;
|
|
@@ -2198,7 +2589,7 @@ class DragIndexCalculatorService {
|
|
|
2198
2589
|
return entry;
|
|
2199
2590
|
}
|
|
2200
2591
|
getTotalItemCount(args) {
|
|
2201
|
-
return this.#getTotalItemCount(args.droppableElement, args.
|
|
2592
|
+
return this.#getTotalItemCount(args.droppableElement, args.draggedItemHeight);
|
|
2202
2593
|
}
|
|
2203
2594
|
/**
|
|
2204
2595
|
* Live scroll geometry of a droppable's rows (see {@link DroppableScrollGeometry}). The same
|
|
@@ -2270,7 +2661,7 @@ class DragIndexCalculatorService {
|
|
|
2270
2661
|
totalItems = Math.max(0, cachedItemCount);
|
|
2271
2662
|
}
|
|
2272
2663
|
else {
|
|
2273
|
-
totalItems = this.#getTotalItemCount(droppableElement,
|
|
2664
|
+
totalItems = this.#getTotalItemCount(droppableElement, draggedItemHeight, cache);
|
|
2274
2665
|
}
|
|
2275
2666
|
// Edge detection: allow dropping at the END of the list when cursor is near bottom edge.
|
|
2276
2667
|
// Due to max scroll limits, the math alone can't reach totalItems when the list is longer
|
|
@@ -2329,7 +2720,12 @@ class DragIndexCalculatorService {
|
|
|
2329
2720
|
const offset = parseFloat(element?.getAttribute('data-content-offset') ?? '');
|
|
2330
2721
|
return Number.isFinite(offset) ? offset : 0;
|
|
2331
2722
|
}
|
|
2332
|
-
|
|
2723
|
+
/**
|
|
2724
|
+
* The number of items in a droppable. With `cache`, the drag's resolved metadata, the virtual
|
|
2725
|
+
* containers it knows are not looked up in the DOM again: this runs on every frame of a drag
|
|
2726
|
+
* over a list without a registered strategy.
|
|
2727
|
+
*/
|
|
2728
|
+
#getTotalItemCount(droppableElement, draggedItemHeight, cache) {
|
|
2333
2729
|
// Check if a registered strategy exists
|
|
2334
2730
|
const droppableId = this.#positionCalculator.getDroppableId(droppableElement);
|
|
2335
2731
|
const strategy = droppableId ? this.#strategies.get(droppableId) : null;
|
|
@@ -2338,7 +2734,9 @@ class DragIndexCalculatorService {
|
|
|
2338
2734
|
return Math.max(0, strategyItemCount);
|
|
2339
2735
|
}
|
|
2340
2736
|
// Check for embedded virtual scroll component
|
|
2341
|
-
const virtualScroll =
|
|
2737
|
+
const virtualScroll = cache
|
|
2738
|
+
? cache.virtualScrollElement
|
|
2739
|
+
: droppableElement.querySelector('vdnd-virtual-scroll');
|
|
2342
2740
|
if (virtualScroll) {
|
|
2343
2741
|
// Use data-total-items attribute if available (always the true N)
|
|
2344
2742
|
const totalItemsAttr = virtualScroll.getAttribute('data-total-items');
|
|
@@ -2375,9 +2773,9 @@ class DragIndexCalculatorService {
|
|
|
2375
2773
|
return count;
|
|
2376
2774
|
}
|
|
2377
2775
|
// Check for page-level scroll (vdnd-virtual-content)
|
|
2378
|
-
const virtualContent =
|
|
2379
|
-
?
|
|
2380
|
-
: droppableElement
|
|
2776
|
+
const virtualContent = cache
|
|
2777
|
+
? cache.virtualContentElement
|
|
2778
|
+
: this.#findVirtualContent(droppableElement);
|
|
2381
2779
|
if (virtualContent) {
|
|
2382
2780
|
// Prefer explicit total items attribute (works for both fixed and dynamic heights)
|
|
2383
2781
|
const totalItemsAttr = virtualContent.getAttribute('data-total-items');
|
|
@@ -2404,6 +2802,12 @@ class DragIndexCalculatorService {
|
|
|
2404
2802
|
const items = droppableElement.querySelectorAll('[data-draggable-id]');
|
|
2405
2803
|
return items.length;
|
|
2406
2804
|
}
|
|
2805
|
+
/** The `vdnd-virtual-content` the droppable is or is inside (page-level scroll), if any. */
|
|
2806
|
+
#findVirtualContent(droppableElement) {
|
|
2807
|
+
return droppableElement.matches('vdnd-virtual-content')
|
|
2808
|
+
? droppableElement
|
|
2809
|
+
: droppableElement.closest('vdnd-virtual-content');
|
|
2810
|
+
}
|
|
2407
2811
|
#getDraggedItemHeightFallback(height, fallback) {
|
|
2408
2812
|
return Number.isFinite(height) && height > 0 ? height : fallback;
|
|
2409
2813
|
}
|
|
@@ -2547,6 +2951,66 @@ function createAutoScrollRegistration(options) {
|
|
|
2547
2951
|
destroyRef.onDestroy(unregisterCurrent);
|
|
2548
2952
|
}
|
|
2549
2953
|
|
|
2954
|
+
/**
|
|
2955
|
+
* Give a list's strategy its items: `count` of them, with the track keys `keys` returns (the
|
|
2956
|
+
* list's own array, which it goes on reading).
|
|
2957
|
+
*
|
|
2958
|
+
* - FixedHeightStrategy's `setItemKeys` only counts the keys: it gets the count, and the keys are
|
|
2959
|
+
* never computed.
|
|
2960
|
+
* - DynamicHeightStrategy's copies the keys: it gets the list's array.
|
|
2961
|
+
* - Any other `setItemKeys` (a strategy of the consumer's own, or a subclass that overrides it)
|
|
2962
|
+
* gets a copy, which it may keep and change.
|
|
2963
|
+
* @internal
|
|
2964
|
+
*/
|
|
2965
|
+
function setStrategyItems(strategy, count, keys) {
|
|
2966
|
+
if (strategy instanceof FixedHeightStrategy &&
|
|
2967
|
+
strategy.setItemKeys === FixedHeightStrategy.prototype.setItemKeys) {
|
|
2968
|
+
strategy.setItemCount(count);
|
|
2969
|
+
}
|
|
2970
|
+
else if (strategy instanceof DynamicHeightStrategy &&
|
|
2971
|
+
strategy.setItemKeys === DynamicHeightStrategy.prototype.setItemKeys) {
|
|
2972
|
+
strategy.setItemKeys(keys());
|
|
2973
|
+
}
|
|
2974
|
+
else {
|
|
2975
|
+
strategy.setItemKeys(keys().slice());
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
/**
|
|
2980
|
+
* Query by an exact attribute value without interpolating consumer data into a CSS selector.
|
|
2981
|
+
*
|
|
2982
|
+
* CSS attribute selectors need string-specific escaping that differs from `CSS.escape()`.
|
|
2983
|
+
* Filtering candidates by attribute presence avoids selector syntax errors for IDs containing
|
|
2984
|
+
* quotes, brackets, backslashes, or other selector-sensitive characters.
|
|
2985
|
+
*/
|
|
2986
|
+
function queryByAttribute(root, attributeName, attributeValue) {
|
|
2987
|
+
const candidates = root.querySelectorAll(`[${attributeName}]`);
|
|
2988
|
+
for (const candidate of candidates) {
|
|
2989
|
+
if (candidate.getAttribute(attributeName) === attributeValue) {
|
|
2990
|
+
return candidate;
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
return null;
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* The elements carrying an attribute, by its value (the first in document order when several
|
|
2997
|
+
* share one, as `queryByAttribute` finds). One query for many lookups, where `queryByAttribute`
|
|
2998
|
+
* scans every candidate on each call.
|
|
2999
|
+
*/
|
|
3000
|
+
function mapByAttribute(root, attributeName) {
|
|
3001
|
+
const elements = new Map();
|
|
3002
|
+
for (const candidate of root.querySelectorAll(`[${attributeName}]`)) {
|
|
3003
|
+
const value = candidate.getAttribute(attributeName);
|
|
3004
|
+
if (!elements.has(value)) {
|
|
3005
|
+
elements.set(value, candidate);
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
return elements;
|
|
3009
|
+
}
|
|
3010
|
+
function queryAllByAttribute(root, attributeName, attributeValue) {
|
|
3011
|
+
return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
|
|
3012
|
+
}
|
|
3013
|
+
|
|
2550
3014
|
/** Layout changes smaller than this are ignored (sub-pixel rounding noise). */
|
|
2551
3015
|
const MIN_SHIFT_PX = 0.5;
|
|
2552
3016
|
/**
|
|
@@ -2730,33 +3194,235 @@ class ShiftAnimator {
|
|
|
2730
3194
|
}
|
|
2731
3195
|
}
|
|
2732
3196
|
|
|
3197
|
+
/** The elements drop animations hide, with the animation hiding each */
|
|
3198
|
+
const hiddenTargets = new Map();
|
|
2733
3199
|
/**
|
|
2734
|
-
*
|
|
2735
|
-
*
|
|
2736
|
-
*
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
*
|
|
2748
|
-
*
|
|
2749
|
-
* [itemHeight]="50"
|
|
2750
|
-
* [containerHeight]="400"
|
|
2751
|
-
* [trackByFn]="trackById">
|
|
2752
|
-
* <ng-template let-item let-index="index">
|
|
2753
|
-
* <div class="item">{{ item.name }}</div>
|
|
2754
|
-
* </ng-template>
|
|
2755
|
-
* </vdnd-virtual-scroll>
|
|
3200
|
+
* Show again an element a drop animation hides, if one of these nodes holds it. A virtual list
|
|
3201
|
+
* calls it for the row it pools: that row's element renders another item next.
|
|
3202
|
+
* @internal
|
|
3203
|
+
*/
|
|
3204
|
+
function revealDropTargetIn(nodes) {
|
|
3205
|
+
for (const [element, animation] of hiddenTargets) {
|
|
3206
|
+
if (nodes.some((node) => node.contains(element))) {
|
|
3207
|
+
hiddenTargets.delete(element);
|
|
3208
|
+
animation.cancel();
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
/**
|
|
3213
|
+
* Plays the drop ("settle") animation: the drag preview glides from where it was released
|
|
3214
|
+
* into the dropped item's final position, while that item stays invisible underneath.
|
|
2756
3215
|
*
|
|
2757
|
-
*
|
|
2758
|
-
*
|
|
2759
|
-
*
|
|
3216
|
+
* Purely visual. The drag state and the drop events are already final when it starts, so it
|
|
3217
|
+
* never delays or changes a drop. Without a target (the item is not rendered, or scrolled out
|
|
3218
|
+
* of view) the ghost fades out where it was released.
|
|
3219
|
+
* @internal
|
|
3220
|
+
*/
|
|
3221
|
+
class DropAnimator {
|
|
3222
|
+
#config;
|
|
3223
|
+
#ghostAnimation = null;
|
|
3224
|
+
#hidden = null;
|
|
3225
|
+
/** The reduced-motion media query, created on first use (null without `matchMedia`) */
|
|
3226
|
+
#reducedMotionQuery;
|
|
3227
|
+
constructor(config) {
|
|
3228
|
+
this.#config = config;
|
|
3229
|
+
}
|
|
3230
|
+
/** Whether a drop animation would play right now (duration, WAAPI, reduced motion). */
|
|
3231
|
+
isEnabled() {
|
|
3232
|
+
if (this.#duration() <= 0)
|
|
3233
|
+
return false;
|
|
3234
|
+
if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
|
|
3235
|
+
return false;
|
|
3236
|
+
}
|
|
3237
|
+
return !this.#prefersReducedMotion();
|
|
3238
|
+
}
|
|
3239
|
+
/**
|
|
3240
|
+
* Animate `ghost` onto `target` (or fade it out without one), then call `onDone`.
|
|
3241
|
+
* `onDone` is not called when the animation is cancelled.
|
|
3242
|
+
*/
|
|
3243
|
+
play(ghost, target, onDone) {
|
|
3244
|
+
this.cancel();
|
|
3245
|
+
const timing = {
|
|
3246
|
+
duration: this.#duration(),
|
|
3247
|
+
easing: this.#config.dropEasing ?? DEFAULT_DROP_EASING,
|
|
3248
|
+
fill: 'forwards',
|
|
3249
|
+
};
|
|
3250
|
+
let ghostAnimation;
|
|
3251
|
+
if (target) {
|
|
3252
|
+
const from = ghost.getBoundingClientRect();
|
|
3253
|
+
const dx = target.rect.left - from.left;
|
|
3254
|
+
const dy = target.rect.top - from.top;
|
|
3255
|
+
// Travel on top of the preview's own position transform (width/height must not be
|
|
3256
|
+
// composited, so the base transform is repeated instead of using composite: 'add').
|
|
3257
|
+
const base = ghost.style.transform;
|
|
3258
|
+
ghostAnimation = ghost.animate([
|
|
3259
|
+
{
|
|
3260
|
+
transform: `${base} translate(0px, 0px)`,
|
|
3261
|
+
width: `${from.width}px`,
|
|
3262
|
+
height: `${from.height}px`,
|
|
3263
|
+
},
|
|
3264
|
+
{
|
|
3265
|
+
transform: `${base} translate(${dx}px, ${dy}px)`,
|
|
3266
|
+
width: `${target.rect.width}px`,
|
|
3267
|
+
height: `${target.rect.height}px`,
|
|
3268
|
+
},
|
|
3269
|
+
], timing);
|
|
3270
|
+
// Keep the real item invisible until the ghost lands on it, so it is never seen twice.
|
|
3271
|
+
const hideAnimation = target.element.animate([{ opacity: 0 }, { opacity: 0 }], {
|
|
3272
|
+
duration: timing.duration,
|
|
3273
|
+
});
|
|
3274
|
+
this.#hidden = { element: target.element, animation: hideAnimation };
|
|
3275
|
+
hiddenTargets.set(target.element, hideAnimation);
|
|
3276
|
+
}
|
|
3277
|
+
else {
|
|
3278
|
+
ghostAnimation = ghost.animate([{ opacity: 1 }, { opacity: 0 }], timing);
|
|
3279
|
+
}
|
|
3280
|
+
this.#ghostAnimation = ghostAnimation;
|
|
3281
|
+
ghostAnimation.onfinish = () => {
|
|
3282
|
+
if (this.#ghostAnimation !== ghostAnimation)
|
|
3283
|
+
return;
|
|
3284
|
+
this.cancel();
|
|
3285
|
+
onDone();
|
|
3286
|
+
};
|
|
3287
|
+
}
|
|
3288
|
+
/** Stop the running animation immediately and reveal the real item. */
|
|
3289
|
+
cancel() {
|
|
3290
|
+
const ghostAnimation = this.#ghostAnimation;
|
|
3291
|
+
const hidden = this.#hidden;
|
|
3292
|
+
this.#ghostAnimation = null;
|
|
3293
|
+
this.#hidden = null;
|
|
3294
|
+
ghostAnimation?.cancel();
|
|
3295
|
+
if (hidden) {
|
|
3296
|
+
if (hiddenTargets.get(hidden.element) === hidden.animation) {
|
|
3297
|
+
hiddenTargets.delete(hidden.element);
|
|
3298
|
+
}
|
|
3299
|
+
hidden.animation.cancel();
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
#duration() {
|
|
3303
|
+
return this.#config.dropDuration ?? DEFAULT_DROP_DURATION;
|
|
3304
|
+
}
|
|
3305
|
+
/** One media query for every drop: it is live, so it follows a change of the preference. */
|
|
3306
|
+
#prefersReducedMotion() {
|
|
3307
|
+
if (this.#reducedMotionQuery === undefined) {
|
|
3308
|
+
this.#reducedMotionQuery =
|
|
3309
|
+
typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
|
|
3310
|
+
}
|
|
3311
|
+
return this.#reducedMotionQuery?.matches ?? false;
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
/**
|
|
3315
|
+
* Find the rendered element of a dropped item: first in the droppable it was dropped on, then
|
|
3316
|
+
* in the one it came from (a drop the consumer rejected or has not committed yet leaves it
|
|
3317
|
+
* there). Returns null when it is not rendered, or rendered but scrolled out of its list's view.
|
|
3318
|
+
*
|
|
3319
|
+
* `getDroppable` resolves a droppable ID to its element (the droppable registry lookup).
|
|
3320
|
+
*/
|
|
3321
|
+
function findDropTarget(draggableId, droppableIds, getDroppable) {
|
|
3322
|
+
for (const droppableId of droppableIds) {
|
|
3323
|
+
if (!droppableId)
|
|
3324
|
+
continue;
|
|
3325
|
+
const droppable = getDroppable(droppableId);
|
|
3326
|
+
const element = droppable
|
|
3327
|
+
? queryByAttribute(droppable, 'data-draggable-id', draggableId)
|
|
3328
|
+
: null;
|
|
3329
|
+
if (!droppable || !element)
|
|
3330
|
+
continue;
|
|
3331
|
+
const rect = element.getBoundingClientRect();
|
|
3332
|
+
// Hidden, or virtualized/scrolled out of the list's visible box: nowhere to land.
|
|
3333
|
+
if (rect.width === 0 && rect.height === 0)
|
|
3334
|
+
return null;
|
|
3335
|
+
const bounds = droppable.getBoundingClientRect();
|
|
3336
|
+
const visible = rect.bottom > bounds.top &&
|
|
3337
|
+
rect.top < bounds.bottom &&
|
|
3338
|
+
rect.right > bounds.left &&
|
|
3339
|
+
rect.left < bounds.right;
|
|
3340
|
+
return visible ? { element, rect } : null;
|
|
3341
|
+
}
|
|
3342
|
+
return null;
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
/**
|
|
3346
|
+
* How many views of rows that left a list stay pooled for rows that come in (recycleRows). A
|
|
3347
|
+
* scroll step reuses the views it pools right away; the pool keeps the rest, for example when
|
|
3348
|
+
* fewer rows render after the list shrinks.
|
|
3349
|
+
*/
|
|
3350
|
+
const MAX_POOLED_ROWS = 10;
|
|
3351
|
+
/** `Node.DOCUMENT_POSITION_FOLLOWING`, without the `Node` global (servers have none) */
|
|
3352
|
+
const DOCUMENT_POSITION_FOLLOWING = 4;
|
|
3353
|
+
/**
|
|
3354
|
+
* The node that comes first in the DOM. A view's `rootNodes` list a control flow block's anchor
|
|
3355
|
+
* before the nodes rendered in it, which the DOM puts before the anchor.
|
|
3356
|
+
*/
|
|
3357
|
+
function firstInDocumentOrder(nodes) {
|
|
3358
|
+
let first = null;
|
|
3359
|
+
for (const node of nodes) {
|
|
3360
|
+
if (first === null || node.compareDocumentPosition(first) & DOCUMENT_POSITION_FOLLOWING) {
|
|
3361
|
+
first = node;
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
return first;
|
|
3365
|
+
}
|
|
3366
|
+
/**
|
|
3367
|
+
* Which values belong to a longest increasing subsequence of `values` (negative values never
|
|
3368
|
+
* do). For rows: the views that can stay where they are while the others move around them, as
|
|
3369
|
+
* few as possible. A moved row loses focus and restarts its CSS transitions.
|
|
3370
|
+
*/
|
|
3371
|
+
function longestIncreasingRun(values) {
|
|
3372
|
+
// tails[k]: the index of the smallest value that ends an increasing run of length k + 1
|
|
3373
|
+
const tails = [];
|
|
3374
|
+
const previous = new Array(values.length).fill(-1);
|
|
3375
|
+
for (let i = 0; i < values.length; i++) {
|
|
3376
|
+
const value = values[i];
|
|
3377
|
+
if (value < 0)
|
|
3378
|
+
continue;
|
|
3379
|
+
let low = 0;
|
|
3380
|
+
let high = tails.length;
|
|
3381
|
+
while (low < high) {
|
|
3382
|
+
const middle = (low + high) >> 1;
|
|
3383
|
+
if (values[tails[middle]] < value) {
|
|
3384
|
+
low = middle + 1;
|
|
3385
|
+
}
|
|
3386
|
+
else {
|
|
3387
|
+
high = middle;
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
previous[i] = low > 0 ? tails[low - 1] : -1;
|
|
3391
|
+
tails[low] = i;
|
|
3392
|
+
}
|
|
3393
|
+
const inRun = new Array(values.length).fill(false);
|
|
3394
|
+
for (let i = tails.length > 0 ? tails[tails.length - 1] : -1; i >= 0; i = previous[i]) {
|
|
3395
|
+
inRun[i] = true;
|
|
3396
|
+
}
|
|
3397
|
+
return inRun;
|
|
3398
|
+
}
|
|
3399
|
+
/**
|
|
3400
|
+
* A virtual scroll container that only renders visible items.
|
|
3401
|
+
*
|
|
3402
|
+
* Key features:
|
|
3403
|
+
* - Only renders items within the visible viewport plus an overscan buffer
|
|
3404
|
+
* - Supports "sticky" items that are always rendered (used for dragged items)
|
|
3405
|
+
* - Uses spacer divs to maintain correct scroll height
|
|
3406
|
+
* - Integrates with the drag-and-drop system
|
|
3407
|
+
* - Automatic height detection via ResizeObserver when containerHeight is not provided
|
|
3408
|
+
* - Supports dynamic item heights via `dynamicItemHeight` input
|
|
3409
|
+
*
|
|
3410
|
+
* @example
|
|
3411
|
+
* ```html
|
|
3412
|
+
* <!-- With fixed height items -->
|
|
3413
|
+
* <vdnd-virtual-scroll
|
|
3414
|
+
* [items]="items()"
|
|
3415
|
+
* [itemHeight]="50"
|
|
3416
|
+
* [containerHeight]="400"
|
|
3417
|
+
* [trackByFn]="trackById">
|
|
3418
|
+
* <ng-template let-item let-index="index">
|
|
3419
|
+
* <div class="item">{{ item.name }}</div>
|
|
3420
|
+
* </ng-template>
|
|
3421
|
+
* </vdnd-virtual-scroll>
|
|
3422
|
+
*
|
|
3423
|
+
* <!-- With dynamic height items -->
|
|
3424
|
+
* <vdnd-virtual-scroll
|
|
3425
|
+
* [items]="items()"
|
|
2760
3426
|
* [itemHeight]="50"
|
|
2761
3427
|
* [dynamicItemHeight]="true"
|
|
2762
3428
|
* [containerHeight]="400"
|
|
@@ -2775,6 +3441,7 @@ class VirtualScrollContainerComponent {
|
|
|
2775
3441
|
#keyboardDrag = inject(KeyboardDragService);
|
|
2776
3442
|
#ngZone = inject(NgZone);
|
|
2777
3443
|
#injector = inject(Injector);
|
|
3444
|
+
#environmentInjector = inject(EnvironmentInjector);
|
|
2778
3445
|
#isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
2779
3446
|
/** Cleanup function for scroll listener */
|
|
2780
3447
|
#scrollCleanup = null;
|
|
@@ -2789,6 +3456,38 @@ class VirtualScrollContainerComponent {
|
|
|
2789
3456
|
#measuredHeight = signal(0, ...(ngDevMode ? [{ debugName: "#measuredHeight" }] : []));
|
|
2790
3457
|
/** Slides items displaced by the placeholder (only when VDND_ANIMATION_CONFIG is provided) */
|
|
2791
3458
|
#shiftAnimator = this.#createShiftAnimator();
|
|
3459
|
+
/** The template's spacer (cannot use ES private with viewChild) */
|
|
3460
|
+
spacer = viewChild('spacer', ...(ngDevMode ? [{ debugName: "spacer" }] : []));
|
|
3461
|
+
/** The template's content wrapper (cannot use ES private with viewChild) */
|
|
3462
|
+
contentWrapper = viewChild('contentWrapper', ...(ngDevMode ? [{ debugName: "contentWrapper" }] : []));
|
|
3463
|
+
/** Where #renderRows inserts the rows (cannot use ES private with viewChild) */
|
|
3464
|
+
rowOutlet = viewChild('rows', { ...(ngDevMode ? { debugName: "rowOutlet" } : {}), read: ViewContainerRef });
|
|
3465
|
+
/** Rendered rows, in DOM order */
|
|
3466
|
+
#rows = [];
|
|
3467
|
+
/** The entries the rendered rows show */
|
|
3468
|
+
#rowEntries = null;
|
|
3469
|
+
/** The template the rendered rows were created from */
|
|
3470
|
+
#rowTemplate = null;
|
|
3471
|
+
/** The track function that gave the rendered rows their keys */
|
|
3472
|
+
#rowTrackBy = null;
|
|
3473
|
+
/** Views of rows that left, detached, to render rows that come in (recycleRows) */
|
|
3474
|
+
#rowPool = [];
|
|
3475
|
+
/**
|
|
3476
|
+
* Set while this component's view is about to be checked in the current change detection pass:
|
|
3477
|
+
* from an input change (ngOnChanges, which runs before its effects; the first render included)
|
|
3478
|
+
* until the check (ngAfterViewChecked). That check renders every row.
|
|
3479
|
+
*/
|
|
3480
|
+
#viewCheckPending = false;
|
|
3481
|
+
/** The placeholder, created on its first render and reused */
|
|
3482
|
+
#placeholder = null;
|
|
3483
|
+
/** Height last set on the placeholder */
|
|
3484
|
+
#placeholderHeightSet = null;
|
|
3485
|
+
/** Content offset last written to the content wrapper */
|
|
3486
|
+
#contentTransformSet = null;
|
|
3487
|
+
/** Whether duplicate track keys were reported (once per list) */
|
|
3488
|
+
#warnedDuplicateKeys = false;
|
|
3489
|
+
/** The row keys last checked for duplicates (checked again when the rows change) */
|
|
3490
|
+
#rowKeysChecked = null;
|
|
2792
3491
|
/** Template for rendering each item - passed as input instead of content child for reliability */
|
|
2793
3492
|
itemTemplate = input.required(...(ngDevMode ? [{ debugName: "itemTemplate" }] : []));
|
|
2794
3493
|
/** Unique ID for this scroll container (used for auto-scroll registration) */
|
|
@@ -2835,6 +3534,13 @@ class VirtualScrollContainerComponent {
|
|
|
2835
3534
|
* Required for placeholder positioning.
|
|
2836
3535
|
*/
|
|
2837
3536
|
droppableId = input(...(ngDevMode ? [undefined, { debugName: "droppableId" }] : []));
|
|
3537
|
+
/**
|
|
3538
|
+
* Reuse the views of rows that scroll out to render the rows that scroll in, instead of
|
|
3539
|
+
* destroying them and creating new ones. Scrolling creates fewer components and elements, but
|
|
3540
|
+
* a row's components, element and DOM state (focus aside) then carry over to other items.
|
|
3541
|
+
* @default false
|
|
3542
|
+
*/
|
|
3543
|
+
recycleRows = input(false, ...(ngDevMode ? [{ debugName: "recycleRows" }] : []));
|
|
2838
3544
|
/**
|
|
2839
3545
|
* Whether to automatically add the dragged item to the sticky list.
|
|
2840
3546
|
* This ensures the dragged item remains visible during virtual scrolling.
|
|
@@ -2931,22 +3637,50 @@ class VirtualScrollContainerComponent {
|
|
|
2931
3637
|
draggedItemId = computed(() => {
|
|
2932
3638
|
return this.#dragState.draggedItem()?.draggableId ?? null;
|
|
2933
3639
|
}, ...(ngDevMode ? [{ debugName: "draggedItemId" }] : []));
|
|
2934
|
-
/**
|
|
2935
|
-
|
|
2936
|
-
|
|
3640
|
+
/**
|
|
3641
|
+
* Every item's ID, in order. Computed once per items change, and only when something reads it:
|
|
3642
|
+
* a dynamic-height strategy, a drag, or a sticky item outside the rendered rows. A fixed-height
|
|
3643
|
+
* strategy needs only the item count.
|
|
3644
|
+
*/
|
|
3645
|
+
#itemIds = computed(() => {
|
|
2937
3646
|
const idFn = this.itemIdFn();
|
|
3647
|
+
return this.items().map((item) => idFn(item));
|
|
3648
|
+
}, ...(ngDevMode ? [{ debugName: "#itemIds" }] : []));
|
|
3649
|
+
/**
|
|
3650
|
+
* Index of each item ID (the last, when several share one), for the sticky items outside the
|
|
3651
|
+
* rendered rows. Built on first use, so an items change (a drop) builds none.
|
|
3652
|
+
*/
|
|
3653
|
+
#itemIndexMap = computed(() => {
|
|
3654
|
+
const ids = this.#itemIds();
|
|
2938
3655
|
const map = new Map();
|
|
2939
|
-
for (let i = 0; i <
|
|
2940
|
-
map.set(
|
|
3656
|
+
for (let i = 0; i < ids.length; i++) {
|
|
3657
|
+
map.set(ids[i], i);
|
|
2941
3658
|
}
|
|
2942
3659
|
return map;
|
|
2943
3660
|
}, ...(ngDevMode ? [{ debugName: "#itemIndexMap" }] : []));
|
|
2944
|
-
/**
|
|
3661
|
+
/**
|
|
3662
|
+
* The index of the currently dragged item in the items array (-1 if not found or not dragging).
|
|
3663
|
+
* Found without the index map, which only this lookup would need on a drag: a drag that started
|
|
3664
|
+
* in another list has no item here (IDs are unique across lists), the index where the drag found
|
|
3665
|
+
* its item is checked next, and only then every item ID is searched.
|
|
3666
|
+
*/
|
|
2945
3667
|
#draggedItemIndex = computed(() => {
|
|
2946
3668
|
const draggedId = this.draggedItemId();
|
|
2947
3669
|
if (!draggedId)
|
|
2948
3670
|
return -1;
|
|
2949
|
-
|
|
3671
|
+
const droppableId = this.droppableId();
|
|
3672
|
+
const sourceDroppableId = this.#dragState.sourceDroppableId();
|
|
3673
|
+
if (droppableId && sourceDroppableId && droppableId !== sourceDroppableId)
|
|
3674
|
+
return -1;
|
|
3675
|
+
const sourceIndex = this.#dragState.sourceIndex();
|
|
3676
|
+
const items = this.items();
|
|
3677
|
+
if (sourceIndex !== null &&
|
|
3678
|
+
sourceIndex >= 0 &&
|
|
3679
|
+
sourceIndex < items.length &&
|
|
3680
|
+
this.itemIdFn()(items[sourceIndex]) === draggedId) {
|
|
3681
|
+
return sourceIndex;
|
|
3682
|
+
}
|
|
3683
|
+
return this.#itemIds().lastIndexOf(draggedId);
|
|
2950
3684
|
}, ...(ngDevMode ? [{ debugName: "#draggedItemIndex" }] : []));
|
|
2951
3685
|
/** Memoized Set of sticky IDs - rebuilt only when effectiveStickyIds() changes */
|
|
2952
3686
|
#stickyIdsSet = computed(() => new Set(this.effectiveStickyIds()), ...(ngDevMode ? [{ debugName: "#stickyIdsSet" }] : []));
|
|
@@ -2971,31 +3705,20 @@ class VirtualScrollContainerComponent {
|
|
|
2971
3705
|
}
|
|
2972
3706
|
return this.itemHeight();
|
|
2973
3707
|
}, ...(ngDevMode ? [{ debugName: "placeholderHeight" }] : []));
|
|
2974
|
-
/**
|
|
2975
|
-
|
|
3708
|
+
/**
|
|
3709
|
+
* Rows to render: the items in range, then the sticky items outside it. Independent of the
|
|
3710
|
+
* placeholder, so a placeholder move leaves the rows as they are.
|
|
3711
|
+
*/
|
|
3712
|
+
#renderedRows = computed(() => {
|
|
2976
3713
|
const items = this.items();
|
|
2977
3714
|
const { start, end } = this.#renderRange();
|
|
2978
3715
|
const stickyIds = this.#stickyIdsSet();
|
|
2979
3716
|
const idFn = this.itemIdFn();
|
|
2980
|
-
const itemIndexMap = this.#itemIndexMap();
|
|
2981
3717
|
const draggedId = this.draggedItemId();
|
|
2982
|
-
const placeholderIdx = this.placeholderIndex();
|
|
2983
3718
|
const result = [];
|
|
2984
3719
|
const renderedIds = new Set();
|
|
2985
|
-
|
|
2986
|
-
// Add all items in the visible range, inserting placeholder at correct position
|
|
3720
|
+
// Add all items in the visible range
|
|
2987
3721
|
for (let i = start; i <= end && i < items.length; i++) {
|
|
2988
|
-
// Insert placeholder before item at placeholderIndex
|
|
2989
|
-
if (placeholderIdx === i && !hasPlaceholder) {
|
|
2990
|
-
result.push({
|
|
2991
|
-
type: 'placeholder',
|
|
2992
|
-
data: null,
|
|
2993
|
-
index: placeholderIdx,
|
|
2994
|
-
isSticky: false,
|
|
2995
|
-
isDragging: false,
|
|
2996
|
-
});
|
|
2997
|
-
hasPlaceholder = true;
|
|
2998
|
-
}
|
|
2999
3722
|
const item = items[i];
|
|
3000
3723
|
const id = idFn(item);
|
|
3001
3724
|
result.push({
|
|
@@ -3007,24 +3730,14 @@ class VirtualScrollContainerComponent {
|
|
|
3007
3730
|
});
|
|
3008
3731
|
renderedIds.add(id);
|
|
3009
3732
|
}
|
|
3010
|
-
// If placeholder is at the end (after all items), add it
|
|
3011
|
-
if (placeholderIdx >= items.length && placeholderIdx >= 0 && !hasPlaceholder) {
|
|
3012
|
-
result.push({
|
|
3013
|
-
type: 'placeholder',
|
|
3014
|
-
data: null,
|
|
3015
|
-
index: placeholderIdx,
|
|
3016
|
-
isSticky: false,
|
|
3017
|
-
isDragging: false,
|
|
3018
|
-
});
|
|
3019
|
-
hasPlaceholder = true;
|
|
3020
|
-
}
|
|
3021
3733
|
// Add any sticky items that aren't already rendered
|
|
3022
3734
|
const missingStickyIndices = [];
|
|
3023
3735
|
for (const id of stickyIds) {
|
|
3024
3736
|
if (renderedIds.has(id))
|
|
3025
3737
|
continue;
|
|
3026
|
-
|
|
3027
|
-
|
|
3738
|
+
// The drag knows its item's index; the index map serves the other sticky items
|
|
3739
|
+
const index = id === draggedId ? this.#draggedItemIndex() : (this.#itemIndexMap().get(id) ?? -1);
|
|
3740
|
+
if (index < 0)
|
|
3028
3741
|
continue;
|
|
3029
3742
|
missingStickyIndices.push({ id, index });
|
|
3030
3743
|
}
|
|
@@ -3044,6 +3757,45 @@ class VirtualScrollContainerComponent {
|
|
|
3044
3757
|
});
|
|
3045
3758
|
}
|
|
3046
3759
|
return result;
|
|
3760
|
+
}, ...(ngDevMode ? [{ debugName: "#renderedRows" }] : []));
|
|
3761
|
+
/** Track keys of #renderedRows, in order */
|
|
3762
|
+
#rowKeys = computed(() => this.#renderedRows().map((entry, i) => this.trackEntry(i, entry)), ...(ngDevMode ? [{ debugName: "#rowKeys" }] : []));
|
|
3763
|
+
/**
|
|
3764
|
+
* Where the placeholder renders among the rows: before the row at this position (after the
|
|
3765
|
+
* rows in range when it equals their count), or -1 when it is not rendered. It goes before the
|
|
3766
|
+
* item at the placeholder index when that item is in range, and after the rows in range when
|
|
3767
|
+
* the index is past the last item.
|
|
3768
|
+
*/
|
|
3769
|
+
#placeholderSlot = computed(() => {
|
|
3770
|
+
const placeholderIndex = this.placeholderIndex();
|
|
3771
|
+
if (placeholderIndex < 0)
|
|
3772
|
+
return -1;
|
|
3773
|
+
const itemCount = this.items().length;
|
|
3774
|
+
const { start, end } = this.#renderRange();
|
|
3775
|
+
const lastInRange = Math.min(end, itemCount - 1);
|
|
3776
|
+
if (placeholderIndex >= start && placeholderIndex <= lastInRange) {
|
|
3777
|
+
return placeholderIndex - start;
|
|
3778
|
+
}
|
|
3779
|
+
if (placeholderIndex >= itemCount) {
|
|
3780
|
+
return Math.max(0, lastInRange - start + 1);
|
|
3781
|
+
}
|
|
3782
|
+
return -1;
|
|
3783
|
+
}, ...(ngDevMode ? [{ debugName: "#placeholderSlot" }] : []));
|
|
3784
|
+
/** Items to render, including sticky items and placeholder */
|
|
3785
|
+
renderedItems = computed(() => {
|
|
3786
|
+
const rows = this.#renderedRows();
|
|
3787
|
+
const slot = this.#placeholderSlot();
|
|
3788
|
+
const result = [...rows];
|
|
3789
|
+
if (slot >= 0) {
|
|
3790
|
+
result.splice(slot, 0, {
|
|
3791
|
+
type: 'placeholder',
|
|
3792
|
+
data: null,
|
|
3793
|
+
index: this.placeholderIndex(),
|
|
3794
|
+
isSticky: false,
|
|
3795
|
+
isDragging: false,
|
|
3796
|
+
});
|
|
3797
|
+
}
|
|
3798
|
+
return result;
|
|
3047
3799
|
}, ...(ngDevMode ? [{ debugName: "renderedItems" }] : []));
|
|
3048
3800
|
/** Generated ID for auto-scroll registration */
|
|
3049
3801
|
#generatedScrollId = `vdnd-scroll-${Math.random().toString(36).slice(2, 9)}`;
|
|
@@ -3059,12 +3811,10 @@ class VirtualScrollContainerComponent {
|
|
|
3059
3811
|
enabled: () => this.autoScrollEnabled(),
|
|
3060
3812
|
config: () => this.autoScrollConfig(),
|
|
3061
3813
|
});
|
|
3062
|
-
// Keep strategy
|
|
3814
|
+
// Keep the strategy's items in sync. A fixed-height strategy needs only their count, which
|
|
3815
|
+
// spares computing every item's ID on each items change (each drop).
|
|
3063
3816
|
effect(() => {
|
|
3064
|
-
|
|
3065
|
-
const idFn = this.itemIdFn();
|
|
3066
|
-
const keys = items.map((item) => idFn(item));
|
|
3067
|
-
this.#strategy().setItemKeys(keys);
|
|
3817
|
+
setStrategyItems(this.#strategy(), this.items().length, () => this.#itemIds());
|
|
3068
3818
|
});
|
|
3069
3819
|
// Register strategy with drag index calculator for accurate drag calculations
|
|
3070
3820
|
effect((onCleanup) => {
|
|
@@ -3081,16 +3831,6 @@ class VirtualScrollContainerComponent {
|
|
|
3081
3831
|
const draggedIndex = this.#draggedItemIndex();
|
|
3082
3832
|
strategy.setExcludedIndex(draggedIndex >= 0 ? draggedIndex : null);
|
|
3083
3833
|
});
|
|
3084
|
-
// Snapshot item positions before the template re-renders the placeholder move.
|
|
3085
|
-
// Component effects run before the component's own template is refreshed.
|
|
3086
|
-
if (this.#shiftAnimator) {
|
|
3087
|
-
const animator = this.#shiftAnimator;
|
|
3088
|
-
effect(() => {
|
|
3089
|
-
const isDragging = this.#dragState.isDragging();
|
|
3090
|
-
const placeholderIndex = this.placeholderIndex();
|
|
3091
|
-
untracked(() => animator.beforeUpdate(isDragging, placeholderIndex));
|
|
3092
|
-
});
|
|
3093
|
-
}
|
|
3094
3834
|
// Keyboard drag autoscroll: keep the placeholder visible. Arrow keys call
|
|
3095
3835
|
// #revealPlaceholder synchronously (through KeyboardDragService) so a drop right after a
|
|
3096
3836
|
// move never lands outside the rendered range; this effect re-runs it when item
|
|
@@ -3156,6 +3896,18 @@ class VirtualScrollContainerComponent {
|
|
|
3156
3896
|
}
|
|
3157
3897
|
}
|
|
3158
3898
|
});
|
|
3899
|
+
effect(() => {
|
|
3900
|
+
const spacer = this.spacer()?.nativeElement;
|
|
3901
|
+
if (spacer) {
|
|
3902
|
+
spacer.style.height = `${this.totalHeight()}px`;
|
|
3903
|
+
}
|
|
3904
|
+
});
|
|
3905
|
+
// Last, so it renders what the effects above scrolled to. Component effects run before the
|
|
3906
|
+
// component's own view is checked, which has nothing to update: the template has no bindings.
|
|
3907
|
+
effect(() => this.#render());
|
|
3908
|
+
}
|
|
3909
|
+
ngOnChanges() {
|
|
3910
|
+
this.#viewCheckPending = true;
|
|
3159
3911
|
}
|
|
3160
3912
|
ngOnInit() {
|
|
3161
3913
|
// Measure rows in dynamic height mode. dynamicItemHeight can change at runtime, and the
|
|
@@ -3195,6 +3947,9 @@ class VirtualScrollContainerComponent {
|
|
|
3195
3947
|
// Observe rendered items for dynamic height measurement
|
|
3196
3948
|
this.#observeRenderedItems();
|
|
3197
3949
|
}
|
|
3950
|
+
ngAfterViewChecked() {
|
|
3951
|
+
this.#viewCheckPending = false;
|
|
3952
|
+
}
|
|
3198
3953
|
/**
|
|
3199
3954
|
* Scroll so the placeholder is fully visible during a keyboard drag into this list.
|
|
3200
3955
|
* Reads only signals and strategy offsets, so it works before the placeholder renders.
|
|
@@ -3239,6 +3994,279 @@ class VirtualScrollContainerComponent {
|
|
|
3239
3994
|
this.#scrollTop.set(newScrollTop);
|
|
3240
3995
|
}
|
|
3241
3996
|
}
|
|
3997
|
+
/**
|
|
3998
|
+
* Render the content offset, the rows and the placeholder. From an effect, not the template:
|
|
3999
|
+
* Angular checks every row view in this component's view each time a signal its template reads
|
|
4000
|
+
* changes, so reading the placeholder there re-rendered every row on each placeholder move.
|
|
4001
|
+
* Here a placeholder move only moves the placeholder, and a row renders when its context does.
|
|
4002
|
+
*/
|
|
4003
|
+
#render() {
|
|
4004
|
+
// Snapshot the rendered positions before anything moves, so displaced rows can slide. First,
|
|
4005
|
+
// as a subclass that renders its own template relies on it too.
|
|
4006
|
+
const isDragging = this.#dragState.isDragging();
|
|
4007
|
+
const placeholderIndex = this.placeholderIndex();
|
|
4008
|
+
untracked(() => this.#shiftAnimator?.beforeUpdate(isDragging, placeholderIndex));
|
|
4009
|
+
const outlet = this.rowOutlet();
|
|
4010
|
+
const wrapper = this.contentWrapper()?.nativeElement;
|
|
4011
|
+
// Not this component's template (a subclass's own): it renders renderedItems() itself
|
|
4012
|
+
if (!outlet || !wrapper)
|
|
4013
|
+
return;
|
|
4014
|
+
const template = this.itemTemplate() ?? null;
|
|
4015
|
+
const entries = this.#renderedRows();
|
|
4016
|
+
const keys = this.#rowKeys();
|
|
4017
|
+
const trackBy = this.effectiveTrackByFn();
|
|
4018
|
+
const recycle = this.recycleRows();
|
|
4019
|
+
const transform = this.contentTransform();
|
|
4020
|
+
const slot = this.#placeholderSlot();
|
|
4021
|
+
const placeholderHeight = slot >= 0 ? this.placeholderHeight() : null;
|
|
4022
|
+
// Untracked: the signals the rows' templates read must not become this effect's
|
|
4023
|
+
untracked(() => {
|
|
4024
|
+
if (transform !== this.#contentTransformSet) {
|
|
4025
|
+
wrapper.style.transform = transform;
|
|
4026
|
+
this.#contentTransformSet = transform;
|
|
4027
|
+
}
|
|
4028
|
+
this.#renderRows(outlet, template, entries, keys, trackBy, recycle);
|
|
4029
|
+
this.#renderPlaceholder(outlet, slot, placeholderHeight);
|
|
4030
|
+
});
|
|
4031
|
+
}
|
|
4032
|
+
/**
|
|
4033
|
+
* Give each entry a view of the item template, in order. As with `@for`, a row keeps its view
|
|
4034
|
+
* (and the components in it) while its track key stays rendered, and only the rows out of order
|
|
4035
|
+
* move. The view of a row that leaves is destroyed, or with `recycle` pooled and given to a row
|
|
4036
|
+
* that comes in. Only new, pooled and changed views are checked, with `detectChanges()`:
|
|
4037
|
+
* `markForCheck()` would mark every ancestor view up to the root too.
|
|
4038
|
+
*/
|
|
4039
|
+
#renderRows(outlet, template, entries, keys, trackBy, recycle) {
|
|
4040
|
+
if (!recycle) {
|
|
4041
|
+
this.#destroyRowPool();
|
|
4042
|
+
}
|
|
4043
|
+
// Nothing to do, as on a placeholder move
|
|
4044
|
+
if (entries === this.#rowEntries &&
|
|
4045
|
+
template === this.#rowTemplate &&
|
|
4046
|
+
trackBy === this.#rowTrackBy) {
|
|
4047
|
+
return;
|
|
4048
|
+
}
|
|
4049
|
+
// A new template renders every row anew; without one there is no row (as ngTemplateOutlet)
|
|
4050
|
+
if (template !== this.#rowTemplate) {
|
|
4051
|
+
for (const row of this.#rows) {
|
|
4052
|
+
row.view.destroy();
|
|
4053
|
+
}
|
|
4054
|
+
this.#rows = [];
|
|
4055
|
+
this.#destroyRowPool();
|
|
4056
|
+
this.#rowTemplate = template;
|
|
4057
|
+
}
|
|
4058
|
+
if (!template)
|
|
4059
|
+
return;
|
|
4060
|
+
// A new track function gives the rendered rows new keys; they keep their views (as with @for)
|
|
4061
|
+
if (trackBy !== this.#rowTrackBy) {
|
|
4062
|
+
for (const row of this.#rows) {
|
|
4063
|
+
const { $implicit, index } = row.view.context;
|
|
4064
|
+
row.key = this.trackEntry(index, { type: 'item', data: $implicit, index });
|
|
4065
|
+
}
|
|
4066
|
+
this.#rowTrackBy = trackBy;
|
|
4067
|
+
}
|
|
4068
|
+
// The last render's views by key, in order: an entry takes the first view left for its key
|
|
4069
|
+
const previous = new Map();
|
|
4070
|
+
for (const row of this.#rows) {
|
|
4071
|
+
if (row.view.destroyed)
|
|
4072
|
+
continue;
|
|
4073
|
+
const views = previous.get(row.key);
|
|
4074
|
+
if (views) {
|
|
4075
|
+
views.push(row);
|
|
4076
|
+
}
|
|
4077
|
+
else {
|
|
4078
|
+
previous.set(row.key, [row]);
|
|
4079
|
+
}
|
|
4080
|
+
}
|
|
4081
|
+
const kept = keys.map((key) => previous.get(key)?.shift());
|
|
4082
|
+
this.#warnDuplicateKeys(keys);
|
|
4083
|
+
// Remove the rows that left, then keep in place the longest run of kept views that is
|
|
4084
|
+
// already in order (their positions in the outlet, which now holds only kept views)
|
|
4085
|
+
const dragged = this.#dragState.draggedItem();
|
|
4086
|
+
for (const views of previous.values()) {
|
|
4087
|
+
for (const row of views) {
|
|
4088
|
+
// The dragged item's view is destroyed, as without recycling: that cancels the drag
|
|
4089
|
+
// instead of handing it to another item
|
|
4090
|
+
if (recycle && !this.#rendersDraggedItem(row.view, dragged)) {
|
|
4091
|
+
this.#poolRow(outlet, row.view);
|
|
4092
|
+
}
|
|
4093
|
+
else {
|
|
4094
|
+
row.view.destroy();
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
const positions = new Map();
|
|
4099
|
+
for (let i = 0; i < outlet.length; i++) {
|
|
4100
|
+
positions.set(outlet.get(i), i);
|
|
4101
|
+
}
|
|
4102
|
+
const inPlace = longestIncreasingRun(kept.map((row) => (row ? positions.get(row.view) : -1)));
|
|
4103
|
+
// From the last row up, so each row goes right before the next one, already in place
|
|
4104
|
+
const rows = new Array(entries.length);
|
|
4105
|
+
const toCheck = [];
|
|
4106
|
+
let next = null;
|
|
4107
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
4108
|
+
const entry = entries[i];
|
|
4109
|
+
let row = kept[i];
|
|
4110
|
+
if (row) {
|
|
4111
|
+
if (!inPlace[i]) {
|
|
4112
|
+
this.#moveBefore(outlet, row.view, next);
|
|
4113
|
+
}
|
|
4114
|
+
if (this.#updateContext(row.view.context, entry)) {
|
|
4115
|
+
toCheck.push(row.view);
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
else {
|
|
4119
|
+
const index = next ? outlet.indexOf(next) : outlet.length;
|
|
4120
|
+
// A pooled view is checked whatever its context: nothing checked it while detached
|
|
4121
|
+
let view = this.#rowPool.pop();
|
|
4122
|
+
if (view) {
|
|
4123
|
+
this.#updateContext(view.context, entry);
|
|
4124
|
+
outlet.insert(view, index);
|
|
4125
|
+
}
|
|
4126
|
+
else {
|
|
4127
|
+
const context = {
|
|
4128
|
+
$implicit: entry.data,
|
|
4129
|
+
index: entry.index,
|
|
4130
|
+
isSticky: entry.isSticky,
|
|
4131
|
+
};
|
|
4132
|
+
view = outlet.createEmbeddedView(template, context, index);
|
|
4133
|
+
}
|
|
4134
|
+
row = { key: keys[i], view };
|
|
4135
|
+
toCheck.push(view);
|
|
4136
|
+
}
|
|
4137
|
+
rows[i] = row;
|
|
4138
|
+
next = row.view;
|
|
4139
|
+
}
|
|
4140
|
+
this.#rows = rows;
|
|
4141
|
+
while (this.#rowPool.length > MAX_POOLED_ROWS) {
|
|
4142
|
+
this.#rowPool.pop().destroy();
|
|
4143
|
+
}
|
|
4144
|
+
// When this component's own view is checked later in this pass, that check renders every row
|
|
4145
|
+
if (!this.#viewCheckPending) {
|
|
4146
|
+
// Top to bottom (toCheck lists them bottom up)
|
|
4147
|
+
for (let i = toCheck.length - 1; i >= 0; i--) {
|
|
4148
|
+
toCheck[i].detectChanges();
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
4151
|
+
this.#rowEntries = entries;
|
|
4152
|
+
}
|
|
4153
|
+
/**
|
|
4154
|
+
* Whether a row's view renders the dragged item: its item has the dragged ID, or the view holds
|
|
4155
|
+
* the dragged element (its draggable ID need not be the item's ID).
|
|
4156
|
+
*/
|
|
4157
|
+
#rendersDraggedItem(view, dragged) {
|
|
4158
|
+
if (!dragged)
|
|
4159
|
+
return false;
|
|
4160
|
+
return (this.itemIdFn()(view.context.$implicit) === dragged.draggableId ||
|
|
4161
|
+
view.rootNodes.some((node) => node.contains(dragged.element)));
|
|
4162
|
+
}
|
|
4163
|
+
/**
|
|
4164
|
+
* Take a row's view out of the outlet and pool it. It keeps its element, so its shift
|
|
4165
|
+
* animation is cancelled (the item it renders next must not slide from this one's position),
|
|
4166
|
+
* and a drop animation that hides it shows it again.
|
|
4167
|
+
*/
|
|
4168
|
+
#poolRow(outlet, view) {
|
|
4169
|
+
outlet.detach(outlet.indexOf(view));
|
|
4170
|
+
const nodes = view.rootNodes;
|
|
4171
|
+
// A leave animation (`animate.leave`) keeps the row in the DOM, and Angular removes it when
|
|
4172
|
+
// the animation ends: in the row of another item by then, if the view were reused
|
|
4173
|
+
if (nodes.some((node) => node.parentNode !== null)) {
|
|
4174
|
+
view.destroy();
|
|
4175
|
+
return;
|
|
4176
|
+
}
|
|
4177
|
+
for (const node of nodes) {
|
|
4178
|
+
if (node instanceof HTMLElement) {
|
|
4179
|
+
this.#shiftAnimator?.cancel(node);
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
revealDropTargetIn(nodes);
|
|
4183
|
+
this.#rowPool.push(view);
|
|
4184
|
+
}
|
|
4185
|
+
#destroyRowPool() {
|
|
4186
|
+
for (const view of this.#rowPool) {
|
|
4187
|
+
view.destroy();
|
|
4188
|
+
}
|
|
4189
|
+
this.#rowPool = [];
|
|
4190
|
+
}
|
|
4191
|
+
/** Move a row's view right before `next` (last without one), unless it is there already. */
|
|
4192
|
+
#moveBefore(outlet, view, next) {
|
|
4193
|
+
const target = next ? outlet.indexOf(next) : outlet.length;
|
|
4194
|
+
const current = outlet.indexOf(view);
|
|
4195
|
+
if (current === target - 1)
|
|
4196
|
+
return;
|
|
4197
|
+
// move() takes the view out before inserting it, which shifts the views after it
|
|
4198
|
+
outlet.move(view, current < target ? target - 1 : target);
|
|
4199
|
+
}
|
|
4200
|
+
/** Copy an entry into a row's context. Returns whether the context changed. */
|
|
4201
|
+
#updateContext(context, entry) {
|
|
4202
|
+
if (Object.is(context.$implicit, entry.data) &&
|
|
4203
|
+
context.index === entry.index &&
|
|
4204
|
+
context.isSticky === entry.isSticky) {
|
|
4205
|
+
return false;
|
|
4206
|
+
}
|
|
4207
|
+
context.$implicit = entry.data;
|
|
4208
|
+
context.index = entry.index;
|
|
4209
|
+
context.isSticky = entry.isSticky;
|
|
4210
|
+
return true;
|
|
4211
|
+
}
|
|
4212
|
+
/** Items sharing a track key still render (a view each), but drag and drop needs unique IDs. */
|
|
4213
|
+
#warnDuplicateKeys(keys) {
|
|
4214
|
+
if (this.#warnedDuplicateKeys || keys === this.#rowKeysChecked || !isDevMode())
|
|
4215
|
+
return;
|
|
4216
|
+
this.#rowKeysChecked = keys;
|
|
4217
|
+
const seen = new Set();
|
|
4218
|
+
for (const key of keys) {
|
|
4219
|
+
if (seen.has(key)) {
|
|
4220
|
+
this.#warnedDuplicateKeys = true;
|
|
4221
|
+
console.warn(`[ngx-virtual-dnd] vdnd-virtual-scroll rendered two items with the track key ` +
|
|
4222
|
+
`${String(key)}. Give every item a unique ID (itemIdFn, or trackByFn).`);
|
|
4223
|
+
return;
|
|
4224
|
+
}
|
|
4225
|
+
seen.add(key);
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
/**
|
|
4229
|
+
* Put the placeholder before the row at `slot` (see #placeholderSlot), or take it out of the
|
|
4230
|
+
* DOM. It is a DragPlaceholderComponent of its own, outside this view: moving it or setting its
|
|
4231
|
+
* height checks no row.
|
|
4232
|
+
*/
|
|
4233
|
+
#renderPlaceholder(outlet, slot, height) {
|
|
4234
|
+
if (slot < 0 || height === null) {
|
|
4235
|
+
this.#placeholderElement()?.remove();
|
|
4236
|
+
return;
|
|
4237
|
+
}
|
|
4238
|
+
this.#placeholder ??= createComponent(DragPlaceholderComponent, {
|
|
4239
|
+
environmentInjector: this.#environmentInjector,
|
|
4240
|
+
});
|
|
4241
|
+
if (height !== this.#placeholderHeightSet) {
|
|
4242
|
+
this.#placeholder.setInput('itemHeight', height);
|
|
4243
|
+
this.#placeholder.changeDetectorRef.detectChanges();
|
|
4244
|
+
this.#placeholderHeightSet = height;
|
|
4245
|
+
}
|
|
4246
|
+
// Rows are inserted before the outlet's anchor, so the anchor follows the last of them
|
|
4247
|
+
const anchor = outlet.element.nativeElement;
|
|
4248
|
+
const parent = anchor.parentNode;
|
|
4249
|
+
const element = this.#placeholderElement();
|
|
4250
|
+
if (!parent || !element)
|
|
4251
|
+
return;
|
|
4252
|
+
const before = this.#firstNodeOfRows(slot, parent) ?? anchor;
|
|
4253
|
+
if (element.nextSibling !== before) {
|
|
4254
|
+
parent.insertBefore(element, before);
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4257
|
+
#placeholderElement() {
|
|
4258
|
+
return this.#placeholder?.location.nativeElement ?? null;
|
|
4259
|
+
}
|
|
4260
|
+
/** The first node in `parent` of the rows from `index` on (null when none has one). */
|
|
4261
|
+
#firstNodeOfRows(index, parent) {
|
|
4262
|
+
for (let i = index; i < this.#rows.length; i++) {
|
|
4263
|
+
const nodes = this.#rows[i].view.rootNodes.filter((node) => node.parentNode === parent);
|
|
4264
|
+
const node = firstInDocumentOrder(nodes);
|
|
4265
|
+
if (node)
|
|
4266
|
+
return node;
|
|
4267
|
+
}
|
|
4268
|
+
return null;
|
|
4269
|
+
}
|
|
3242
4270
|
ngOnDestroy() {
|
|
3243
4271
|
this.#scrollCleanup?.();
|
|
3244
4272
|
this.#resizeCleanup?.();
|
|
@@ -3249,6 +4277,12 @@ class VirtualScrollContainerComponent {
|
|
|
3249
4277
|
this.#observedElementByKey.clear();
|
|
3250
4278
|
itemResizeObserver?.disconnect();
|
|
3251
4279
|
this.#shiftAnimator?.cancelAll();
|
|
4280
|
+
// Detached, so not destroyed with this view
|
|
4281
|
+
this.#destroyRowPool();
|
|
4282
|
+
// Created outside this view, so not destroyed with it (and destroy() leaves it in the DOM)
|
|
4283
|
+
this.#placeholderElement()?.remove();
|
|
4284
|
+
this.#placeholder?.destroy();
|
|
4285
|
+
this.#placeholder = null;
|
|
3252
4286
|
}
|
|
3253
4287
|
#createShiftAnimator() {
|
|
3254
4288
|
const config = inject(VDND_ANIMATION_CONFIG, { optional: true });
|
|
@@ -3262,10 +4296,25 @@ class VirtualScrollContainerComponent {
|
|
|
3262
4296
|
});
|
|
3263
4297
|
}
|
|
3264
4298
|
/**
|
|
3265
|
-
* Rendered item roots
|
|
3266
|
-
*
|
|
4299
|
+
* Rendered item roots, keyed by the row's track key (a recycled view renders another item, so
|
|
4300
|
+
* its element is no identity), and the placeholder.
|
|
3267
4301
|
*/
|
|
3268
4302
|
*#shiftAnimationEntries() {
|
|
4303
|
+
if (this.rowOutlet()) {
|
|
4304
|
+
for (const row of this.#rows) {
|
|
4305
|
+
for (const node of row.view.rootNodes) {
|
|
4306
|
+
if (node instanceof HTMLElement) {
|
|
4307
|
+
yield [row.key, node];
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
const placeholder = this.#placeholderElement();
|
|
4312
|
+
if (placeholder?.isConnected) {
|
|
4313
|
+
yield [placeholder, placeholder];
|
|
4314
|
+
}
|
|
4315
|
+
return;
|
|
4316
|
+
}
|
|
4317
|
+
// A subclass's own template (see #render) never recycles: each element is its own identity
|
|
3269
4318
|
const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
|
|
3270
4319
|
if (!wrapper)
|
|
3271
4320
|
return;
|
|
@@ -3299,25 +4348,29 @@ class VirtualScrollContainerComponent {
|
|
|
3299
4348
|
* item when a new observer is created.
|
|
3300
4349
|
*/
|
|
3301
4350
|
#observeRenderedItems() {
|
|
3302
|
-
// Re-observe whenever
|
|
3303
|
-
//
|
|
4351
|
+
// Re-observe whenever the rows change, once #render has put them in the DOM. The rows, not
|
|
4352
|
+
// renderedItems(): a placeholder move changes no row. A new item template changes no entry
|
|
4353
|
+
// but renders every row anew, as new elements.
|
|
3304
4354
|
afterRenderEffect(() => {
|
|
3305
4355
|
const observer = this.#itemResizeObserver();
|
|
3306
4356
|
if (!observer)
|
|
3307
4357
|
return;
|
|
3308
|
-
const rendered = this
|
|
4358
|
+
const rendered = this.#renderedRows();
|
|
3309
4359
|
const idFn = this.itemIdFn();
|
|
4360
|
+
this.itemTemplate();
|
|
3310
4361
|
// Find the content wrapper and observe item elements
|
|
3311
4362
|
const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
|
|
3312
4363
|
if (!wrapper)
|
|
3313
4364
|
return;
|
|
4365
|
+
// The DOM element of each item, by its data-draggable-id: one query for all rows, where a
|
|
4366
|
+
// query per row scanned the wrapper once for each (quadratic in the rendered rows)
|
|
4367
|
+
const elementsById = mapByAttribute(wrapper, 'data-draggable-id');
|
|
3314
4368
|
const nextObservedByKey = new Map();
|
|
3315
4369
|
for (const entry of rendered) {
|
|
3316
4370
|
if (entry.type !== 'item' || !entry.data)
|
|
3317
4371
|
continue;
|
|
3318
4372
|
const key = idFn(entry.data);
|
|
3319
|
-
|
|
3320
|
-
const el = queryByAttribute(wrapper, 'data-draggable-id', key);
|
|
4373
|
+
const el = elementsById.get(key);
|
|
3321
4374
|
if (el) {
|
|
3322
4375
|
nextObservedByKey.set(key, el);
|
|
3323
4376
|
}
|
|
@@ -3353,209 +4406,59 @@ class VirtualScrollContainerComponent {
|
|
|
3353
4406
|
}
|
|
3354
4407
|
/**
|
|
3355
4408
|
* Scroll to a specific item index.
|
|
3356
|
-
*/
|
|
3357
|
-
scrollToIndex(index) {
|
|
3358
|
-
const strategy = this.#strategy();
|
|
3359
|
-
const position = strategy.getOffsetForIndex(index);
|
|
3360
|
-
this.scrollTo(position);
|
|
3361
|
-
}
|
|
3362
|
-
/**
|
|
3363
|
-
* Get the current scroll position.
|
|
3364
|
-
*/
|
|
3365
|
-
getScrollTop() {
|
|
3366
|
-
return this.#scrollTop();
|
|
3367
|
-
}
|
|
3368
|
-
/**
|
|
3369
|
-
* Get the total scrollable height.
|
|
3370
|
-
*/
|
|
3371
|
-
getScrollHeight() {
|
|
3372
|
-
return this.totalHeight();
|
|
3373
|
-
}
|
|
3374
|
-
/**
|
|
3375
|
-
* Scroll by a delta amount.
|
|
3376
|
-
*/
|
|
3377
|
-
scrollBy(delta) {
|
|
3378
|
-
const newPosition = Math.max(0, Math.min(this.getScrollTop() + delta, this.getScrollHeight() - this.effectiveHeight()));
|
|
3379
|
-
this.scrollTo(newPosition);
|
|
3380
|
-
}
|
|
3381
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3382
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.0.6", type: VirtualScrollContainerComponent, isStandalone: true, selector: "vdnd-virtual-scroll", inputs: { itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, scrollContainerId: { classPropertyName: "scrollContainerId", publicName: "scrollContainerId", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, stickyItemIds: { classPropertyName: "stickyItemIds", publicName: "stickyItemIds", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: false, transformFunction: null }, autoStickyDraggedItem: { classPropertyName: "autoStickyDraggedItem", publicName: "autoStickyDraggedItem", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style.height.px": "containerHeight() ?? null", "attr.data-item-height": "itemHeight()", "attr.data-total-items": "items().length" }, classAttribute: "vdnd-virtual-scroll" }, ngImport: i0, template: `
|
|
3383
|
-
<!-- Single spacer maintains scroll height -->
|
|
3384
|
-
<div class="vdnd-virtual-scroll-spacer" [style.height.px]="totalHeight()"></div>
|
|
3385
|
-
|
|
3386
|
-
<!-- Content wrapper positioned via GPU-accelerated transform -->
|
|
3387
|
-
<div class="vdnd-virtual-scroll-content-wrapper" [style.transform]="contentTransform()">
|
|
3388
|
-
@for (entry of renderedItems(); track trackEntry($index, entry)) {
|
|
3389
|
-
@if (entry.type === 'placeholder') {
|
|
3390
|
-
<vdnd-drag-placeholder [itemHeight]="placeholderHeight()" />
|
|
3391
|
-
} @else {
|
|
3392
|
-
<ng-container
|
|
3393
|
-
*ngTemplateOutlet="
|
|
3394
|
-
itemTemplate();
|
|
3395
|
-
context: {
|
|
3396
|
-
$implicit: entry.data,
|
|
3397
|
-
index: entry.index,
|
|
3398
|
-
isSticky: entry.isSticky,
|
|
3399
|
-
}
|
|
3400
|
-
"
|
|
3401
|
-
>
|
|
3402
|
-
</ng-container>
|
|
3403
|
-
}
|
|
3404
|
-
}
|
|
3405
|
-
</div>
|
|
3406
|
-
`, isInline: true, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-virtual-scroll-spacer{position:absolute;top:0;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-virtual-scroll-content-wrapper{position:absolute;top:0;left:0;right:0;will-change:transform}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: DragPlaceholderComponent, selector: "vdnd-drag-placeholder", inputs: ["itemHeight"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3407
|
-
}
|
|
3408
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, decorators: [{
|
|
3409
|
-
type: Component,
|
|
3410
|
-
args: [{ selector: 'vdnd-virtual-scroll', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgTemplateOutlet, DragPlaceholderComponent], host: {
|
|
3411
|
-
class: 'vdnd-virtual-scroll',
|
|
3412
|
-
'[style.height.px]': 'containerHeight() ?? null',
|
|
3413
|
-
'[attr.data-item-height]': 'itemHeight()',
|
|
3414
|
-
'[attr.data-total-items]': 'items().length',
|
|
3415
|
-
}, template: `
|
|
3416
|
-
<!-- Single spacer maintains scroll height -->
|
|
3417
|
-
<div class="vdnd-virtual-scroll-spacer" [style.height.px]="totalHeight()"></div>
|
|
3418
|
-
|
|
3419
|
-
<!-- Content wrapper positioned via GPU-accelerated transform -->
|
|
3420
|
-
<div class="vdnd-virtual-scroll-content-wrapper" [style.transform]="contentTransform()">
|
|
3421
|
-
@for (entry of renderedItems(); track trackEntry($index, entry)) {
|
|
3422
|
-
@if (entry.type === 'placeholder') {
|
|
3423
|
-
<vdnd-drag-placeholder [itemHeight]="placeholderHeight()" />
|
|
3424
|
-
} @else {
|
|
3425
|
-
<ng-container
|
|
3426
|
-
*ngTemplateOutlet="
|
|
3427
|
-
itemTemplate();
|
|
3428
|
-
context: {
|
|
3429
|
-
$implicit: entry.data,
|
|
3430
|
-
index: entry.index,
|
|
3431
|
-
isSticky: entry.isSticky,
|
|
3432
|
-
}
|
|
3433
|
-
"
|
|
3434
|
-
>
|
|
3435
|
-
</ng-container>
|
|
3436
|
-
}
|
|
3437
|
-
}
|
|
3438
|
-
</div>
|
|
3439
|
-
`, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-virtual-scroll-spacer{position:absolute;top:0;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-virtual-scroll-content-wrapper{position:absolute;top:0;left:0;right:0;will-change:transform}\n"] }]
|
|
3440
|
-
}], ctorParameters: () => [], propDecorators: { itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], scrollContainerId: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollContainerId", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], stickyItemIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "stickyItemIds", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: false }] }], autoStickyDraggedItem: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoStickyDraggedItem", required: false }] }] } });
|
|
3441
|
-
|
|
3442
|
-
/**
|
|
3443
|
-
* Plays the drop ("settle") animation: the drag preview glides from where it was released
|
|
3444
|
-
* into the dropped item's final position, while that item stays invisible underneath.
|
|
3445
|
-
*
|
|
3446
|
-
* Purely visual. The drag state and the drop events are already final when it starts, so it
|
|
3447
|
-
* never delays or changes a drop. Without a target (the item is not rendered, or scrolled out
|
|
3448
|
-
* of view) the ghost fades out where it was released.
|
|
3449
|
-
* @internal
|
|
3450
|
-
*/
|
|
3451
|
-
class DropAnimator {
|
|
3452
|
-
#config;
|
|
3453
|
-
#ghostAnimation = null;
|
|
3454
|
-
#hideAnimation = null;
|
|
3455
|
-
constructor(config) {
|
|
3456
|
-
this.#config = config;
|
|
3457
|
-
}
|
|
3458
|
-
/** Whether a drop animation would play right now (duration, WAAPI, reduced motion). */
|
|
3459
|
-
isEnabled() {
|
|
3460
|
-
if (this.#duration() <= 0)
|
|
3461
|
-
return false;
|
|
3462
|
-
if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
|
|
3463
|
-
return false;
|
|
3464
|
-
}
|
|
3465
|
-
if (typeof matchMedia !== 'function')
|
|
3466
|
-
return true;
|
|
3467
|
-
return !matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
3468
|
-
}
|
|
3469
|
-
/**
|
|
3470
|
-
* Animate `ghost` onto `target` (or fade it out without one), then call `onDone`.
|
|
3471
|
-
* `onDone` is not called when the animation is cancelled.
|
|
3472
|
-
*/
|
|
3473
|
-
play(ghost, target, onDone) {
|
|
3474
|
-
this.cancel();
|
|
3475
|
-
const timing = {
|
|
3476
|
-
duration: this.#duration(),
|
|
3477
|
-
easing: this.#config.dropEasing ?? DEFAULT_DROP_EASING,
|
|
3478
|
-
fill: 'forwards',
|
|
3479
|
-
};
|
|
3480
|
-
let ghostAnimation;
|
|
3481
|
-
if (target) {
|
|
3482
|
-
const from = ghost.getBoundingClientRect();
|
|
3483
|
-
const dx = target.rect.left - from.left;
|
|
3484
|
-
const dy = target.rect.top - from.top;
|
|
3485
|
-
// Travel on top of the preview's own position transform (width/height must not be
|
|
3486
|
-
// composited, so the base transform is repeated instead of using composite: 'add').
|
|
3487
|
-
const base = ghost.style.transform;
|
|
3488
|
-
ghostAnimation = ghost.animate([
|
|
3489
|
-
{
|
|
3490
|
-
transform: `${base} translate(0px, 0px)`,
|
|
3491
|
-
width: `${from.width}px`,
|
|
3492
|
-
height: `${from.height}px`,
|
|
3493
|
-
},
|
|
3494
|
-
{
|
|
3495
|
-
transform: `${base} translate(${dx}px, ${dy}px)`,
|
|
3496
|
-
width: `${target.rect.width}px`,
|
|
3497
|
-
height: `${target.rect.height}px`,
|
|
3498
|
-
},
|
|
3499
|
-
], timing);
|
|
3500
|
-
// Keep the real item invisible until the ghost lands on it, so it is never seen twice.
|
|
3501
|
-
this.#hideAnimation = target.element.animate([{ opacity: 0 }, { opacity: 0 }], {
|
|
3502
|
-
duration: timing.duration,
|
|
3503
|
-
});
|
|
3504
|
-
}
|
|
3505
|
-
else {
|
|
3506
|
-
ghostAnimation = ghost.animate([{ opacity: 1 }, { opacity: 0 }], timing);
|
|
3507
|
-
}
|
|
3508
|
-
this.#ghostAnimation = ghostAnimation;
|
|
3509
|
-
ghostAnimation.onfinish = () => {
|
|
3510
|
-
if (this.#ghostAnimation !== ghostAnimation)
|
|
3511
|
-
return;
|
|
3512
|
-
this.cancel();
|
|
3513
|
-
onDone();
|
|
3514
|
-
};
|
|
4409
|
+
*/
|
|
4410
|
+
scrollToIndex(index) {
|
|
4411
|
+
const strategy = this.#strategy();
|
|
4412
|
+
const position = strategy.getOffsetForIndex(index);
|
|
4413
|
+
this.scrollTo(position);
|
|
3515
4414
|
}
|
|
3516
|
-
/**
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
this.#
|
|
3521
|
-
this.#hideAnimation = null;
|
|
3522
|
-
ghostAnimation?.cancel();
|
|
3523
|
-
hideAnimation?.cancel();
|
|
4415
|
+
/**
|
|
4416
|
+
* Get the current scroll position.
|
|
4417
|
+
*/
|
|
4418
|
+
getScrollTop() {
|
|
4419
|
+
return this.#scrollTop();
|
|
3524
4420
|
}
|
|
3525
|
-
|
|
3526
|
-
|
|
4421
|
+
/**
|
|
4422
|
+
* Get the total scrollable height.
|
|
4423
|
+
*/
|
|
4424
|
+
getScrollHeight() {
|
|
4425
|
+
return this.totalHeight();
|
|
3527
4426
|
}
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
function findDropTarget(draggableId, droppableIds) {
|
|
3535
|
-
if (typeof document === 'undefined')
|
|
3536
|
-
return null;
|
|
3537
|
-
for (const droppableId of droppableIds) {
|
|
3538
|
-
if (!droppableId)
|
|
3539
|
-
continue;
|
|
3540
|
-
const droppable = queryByAttribute(document, 'data-droppable-id', droppableId);
|
|
3541
|
-
const element = droppable
|
|
3542
|
-
? queryByAttribute(droppable, 'data-draggable-id', draggableId)
|
|
3543
|
-
: null;
|
|
3544
|
-
if (!droppable || !element)
|
|
3545
|
-
continue;
|
|
3546
|
-
const rect = element.getBoundingClientRect();
|
|
3547
|
-
// Hidden, or virtualized/scrolled out of the list's visible box: nowhere to land.
|
|
3548
|
-
if (rect.width === 0 && rect.height === 0)
|
|
3549
|
-
return null;
|
|
3550
|
-
const bounds = droppable.getBoundingClientRect();
|
|
3551
|
-
const visible = rect.bottom > bounds.top &&
|
|
3552
|
-
rect.top < bounds.bottom &&
|
|
3553
|
-
rect.right > bounds.left &&
|
|
3554
|
-
rect.left < bounds.right;
|
|
3555
|
-
return visible ? { element, rect } : null;
|
|
4427
|
+
/**
|
|
4428
|
+
* Scroll by a delta amount.
|
|
4429
|
+
*/
|
|
4430
|
+
scrollBy(delta) {
|
|
4431
|
+
const newPosition = Math.max(0, Math.min(this.getScrollTop() + delta, this.getScrollHeight() - this.effectiveHeight()));
|
|
4432
|
+
this.scrollTo(newPosition);
|
|
3556
4433
|
}
|
|
3557
|
-
|
|
4434
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
4435
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.0.6", type: VirtualScrollContainerComponent, isStandalone: true, selector: "vdnd-virtual-scroll", inputs: { itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, scrollContainerId: { classPropertyName: "scrollContainerId", publicName: "scrollContainerId", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, stickyItemIds: { classPropertyName: "stickyItemIds", publicName: "stickyItemIds", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: false, transformFunction: null }, recycleRows: { classPropertyName: "recycleRows", publicName: "recycleRows", isSignal: true, isRequired: false, transformFunction: null }, autoStickyDraggedItem: { classPropertyName: "autoStickyDraggedItem", publicName: "autoStickyDraggedItem", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style.height.px": "containerHeight() ?? null", "attr.data-item-height": "itemHeight()", "attr.data-total-items": "items().length" }, classAttribute: "vdnd-virtual-scroll" }, viewQueries: [{ propertyName: "spacer", first: true, predicate: ["spacer"], descendants: true, isSignal: true }, { propertyName: "contentWrapper", first: true, predicate: ["contentWrapper"], descendants: true, isSignal: true }, { propertyName: "rowOutlet", first: true, predicate: ["rows"], descendants: true, read: ViewContainerRef, isSignal: true }], usesOnChanges: true, ngImport: i0, template: `
|
|
4436
|
+
<!-- Single spacer maintains scroll height -->
|
|
4437
|
+
<div #spacer class="vdnd-virtual-scroll-spacer"></div>
|
|
4438
|
+
|
|
4439
|
+
<!-- Content wrapper positioned via GPU-accelerated transform -->
|
|
4440
|
+
<div #contentWrapper class="vdnd-virtual-scroll-content-wrapper">
|
|
4441
|
+
<ng-container #rows />
|
|
4442
|
+
</div>
|
|
4443
|
+
`, isInline: true, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-virtual-scroll-spacer{position:absolute;top:0;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-virtual-scroll-content-wrapper{position:absolute;top:0;left:0;right:0;will-change:transform}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3558
4444
|
}
|
|
4445
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, decorators: [{
|
|
4446
|
+
type: Component,
|
|
4447
|
+
args: [{ selector: 'vdnd-virtual-scroll', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
4448
|
+
class: 'vdnd-virtual-scroll',
|
|
4449
|
+
'[style.height.px]': 'containerHeight() ?? null',
|
|
4450
|
+
'[attr.data-item-height]': 'itemHeight()',
|
|
4451
|
+
'[attr.data-total-items]': 'items().length',
|
|
4452
|
+
}, template: `
|
|
4453
|
+
<!-- Single spacer maintains scroll height -->
|
|
4454
|
+
<div #spacer class="vdnd-virtual-scroll-spacer"></div>
|
|
4455
|
+
|
|
4456
|
+
<!-- Content wrapper positioned via GPU-accelerated transform -->
|
|
4457
|
+
<div #contentWrapper class="vdnd-virtual-scroll-content-wrapper">
|
|
4458
|
+
<ng-container #rows />
|
|
4459
|
+
</div>
|
|
4460
|
+
`, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-virtual-scroll-spacer{position:absolute;top:0;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-virtual-scroll-content-wrapper{position:absolute;top:0;left:0;right:0;will-change:transform}\n"] }]
|
|
4461
|
+
}], ctorParameters: () => [], propDecorators: { spacer: [{ type: i0.ViewChild, args: ['spacer', { isSignal: true }] }], contentWrapper: [{ type: i0.ViewChild, args: ['contentWrapper', { isSignal: true }] }], rowOutlet: [{ type: i0.ViewChild, args: ['rows', { ...{ read: ViewContainerRef }, isSignal: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], scrollContainerId: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollContainerId", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], stickyItemIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "stickyItemIds", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: false }] }], recycleRows: [{ type: i0.Input, args: [{ isSignal: true, alias: "recycleRows", required: false }] }], autoStickyDraggedItem: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoStickyDraggedItem", required: false }] }] } });
|
|
3559
4462
|
|
|
3560
4463
|
/**
|
|
3561
4464
|
* Renders a preview of the dragged item that follows the cursor.
|
|
@@ -3581,6 +4484,8 @@ class DragPreviewComponent {
|
|
|
3581
4484
|
#overlayContainer = inject(OverlayContainerService);
|
|
3582
4485
|
#elementRef = inject((ElementRef));
|
|
3583
4486
|
#injector = inject(Injector);
|
|
4487
|
+
#droppableRegistry = inject(DroppableRegistryService);
|
|
4488
|
+
#scheduler = inject(DragSchedulerService);
|
|
3584
4489
|
#dropAnimator = this.#createDropAnimator();
|
|
3585
4490
|
/** Optional custom template for the preview */
|
|
3586
4491
|
previewTemplate = input(...(ngDevMode ? [undefined, { debugName: "previewTemplate" }] : []));
|
|
@@ -3598,6 +4503,39 @@ class DragPreviewComponent {
|
|
|
3598
4503
|
isSettling = computed(() => this.#settling() !== null, ...(ngDevMode ? [{ debugName: "isSettling" }] : []));
|
|
3599
4504
|
/** The item shown: the live dragged item, or the dropped one while it settles */
|
|
3600
4505
|
displayedItem = computed(() => this.dragState.draggedItem() ?? this.#settling()?.item ?? null, ...(ngDevMode ? [{ debugName: "displayedItem" }] : []));
|
|
4506
|
+
/**
|
|
4507
|
+
* Where the preview goes, from everything but the cursor. The template and its effects never
|
|
4508
|
+
* read the cursor: a signal read there would schedule a full application render on every
|
|
4509
|
+
* pointer frame, and that render lands after the frame is painted (one frame late).
|
|
4510
|
+
*/
|
|
4511
|
+
#placement = computed(() => {
|
|
4512
|
+
const settling = this.#settling();
|
|
4513
|
+
if (settling && !this.dragState.isDragging()) {
|
|
4514
|
+
return {
|
|
4515
|
+
settled: settling.position,
|
|
4516
|
+
offset: { x: 0, y: 0 },
|
|
4517
|
+
initialPosition: null,
|
|
4518
|
+
lockAxis: null,
|
|
4519
|
+
};
|
|
4520
|
+
}
|
|
4521
|
+
return this.#placementFor({
|
|
4522
|
+
grabOffset: this.dragState.grabOffset(),
|
|
4523
|
+
initialPosition: this.dragState.initialPosition(),
|
|
4524
|
+
lockAxis: this.dragState.lockAxis(),
|
|
4525
|
+
});
|
|
4526
|
+
}, ...(ngDevMode ? [{ debugName: "#placement" }] : []));
|
|
4527
|
+
/**
|
|
4528
|
+
* Moves the preview inside the drag scheduler's frame, right after the hit-test wrote the
|
|
4529
|
+
* cursor (bound for stable add/remove). The render-time write in the constructor covers the
|
|
4530
|
+
* rest: the preview appearing, keyboard drags and the settle.
|
|
4531
|
+
*/
|
|
4532
|
+
#writeFramePosition = () => {
|
|
4533
|
+
const preview = this.previewElement()?.nativeElement;
|
|
4534
|
+
if (!preview || this.#settling() || !this.dragState.isDragging()) {
|
|
4535
|
+
return;
|
|
4536
|
+
}
|
|
4537
|
+
this.#place(preview, this.#placement(), this.dragState.cursorPosition());
|
|
4538
|
+
};
|
|
3601
4539
|
/** Whether this preview's template registration is currently counted (kept balanced). */
|
|
3602
4540
|
#templateRegistered = false;
|
|
3603
4541
|
/** The cloned element from drag state (used when no custom template is provided) */
|
|
@@ -3608,6 +4546,7 @@ class DragPreviewComponent {
|
|
|
3608
4546
|
return this.displayedItem()?.clonedElement ?? null;
|
|
3609
4547
|
}, ...(ngDevMode ? [{ debugName: "clonedElement" }] : []));
|
|
3610
4548
|
constructor() {
|
|
4549
|
+
this.#scheduler.addFrameWriter(this.#writeFramePosition);
|
|
3611
4550
|
// Teleport host element into the body-level overlay container after first render.
|
|
3612
4551
|
// This escapes any ancestor CSS transforms that would break position: fixed.
|
|
3613
4552
|
afterNextRender(() => {
|
|
@@ -3616,6 +4555,18 @@ class DragPreviewComponent {
|
|
|
3616
4555
|
container.appendChild(this.#elementRef.nativeElement);
|
|
3617
4556
|
}
|
|
3618
4557
|
});
|
|
4558
|
+
// Place the preview when it appears or anything but the cursor changes (drag start,
|
|
4559
|
+
// keyboard drags, the settle). The cursor is read untracked; pointer moves are written by
|
|
4560
|
+
// #writeFramePosition. Runs before the settle's afterNextRender reads the transform.
|
|
4561
|
+
afterRenderEffect({
|
|
4562
|
+
write: () => {
|
|
4563
|
+
const preview = this.previewElement()?.nativeElement;
|
|
4564
|
+
const placement = this.#placement();
|
|
4565
|
+
if (preview) {
|
|
4566
|
+
this.#place(preview, placement, untracked(this.dragState.cursorPosition));
|
|
4567
|
+
}
|
|
4568
|
+
},
|
|
4569
|
+
});
|
|
3619
4570
|
// Publish whether this preview renders via a custom template so the drag
|
|
3620
4571
|
// directives can skip the drag-start element clone when it won't be shown.
|
|
3621
4572
|
effect(() => {
|
|
@@ -3660,6 +4611,7 @@ class DragPreviewComponent {
|
|
|
3660
4611
|
});
|
|
3661
4612
|
}
|
|
3662
4613
|
ngOnDestroy() {
|
|
4614
|
+
this.#scheduler.removeFrameWriter(this.#writeFramePosition);
|
|
3663
4615
|
this.#dropAnimator?.cancel();
|
|
3664
4616
|
if (this.#templateRegistered) {
|
|
3665
4617
|
this.#overlayContainer.setTemplatePreviewActive(false);
|
|
@@ -3667,32 +4619,31 @@ class DragPreviewComponent {
|
|
|
3667
4619
|
}
|
|
3668
4620
|
this.#elementRef.nativeElement.remove();
|
|
3669
4621
|
}
|
|
3670
|
-
/**
|
|
4622
|
+
/**
|
|
4623
|
+
* Whether the preview is visible. A drag shows it once it has a position: `initialPosition`
|
|
4624
|
+
* is set from the drag-start cursor and, unlike `cursorPosition`, does not change per frame.
|
|
4625
|
+
*/
|
|
3671
4626
|
isVisible = computed(() => {
|
|
3672
|
-
return ((this.dragState.isDragging() && this.dragState.
|
|
4627
|
+
return ((this.dragState.isDragging() && this.dragState.initialPosition() !== null) ||
|
|
4628
|
+
this.isSettling());
|
|
3673
4629
|
}, ...(ngDevMode ? [{ debugName: "isVisible" }] : []));
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
4630
|
+
#placementFor(state) {
|
|
4631
|
+
return {
|
|
4632
|
+
settled: null,
|
|
4633
|
+
// Use grab offset if available (preserves grab position), otherwise fall back to cursorOffset input
|
|
4634
|
+
offset: state.grabOffset ?? this.cursorOffset(),
|
|
4635
|
+
initialPosition: state.initialPosition,
|
|
4636
|
+
lockAxis: state.lockAxis,
|
|
4637
|
+
};
|
|
4638
|
+
}
|
|
4639
|
+
#positionAt(placement, cursor) {
|
|
4640
|
+
if (placement.settled) {
|
|
4641
|
+
return placement.settled;
|
|
3679
4642
|
}
|
|
3680
|
-
return this.#positionFor({
|
|
3681
|
-
cursorPosition: this.dragState.cursorPosition(),
|
|
3682
|
-
grabOffset: this.dragState.grabOffset(),
|
|
3683
|
-
initialPosition: this.dragState.initialPosition(),
|
|
3684
|
-
lockAxis: this.dragState.lockAxis(),
|
|
3685
|
-
});
|
|
3686
|
-
}, ...(ngDevMode ? [{ debugName: "position" }] : []));
|
|
3687
|
-
#positionFor(state) {
|
|
3688
|
-
const cursor = state.cursorPosition;
|
|
3689
|
-
const initialPosition = state.initialPosition;
|
|
3690
|
-
const lockAxis = state.lockAxis;
|
|
3691
4643
|
if (!cursor) {
|
|
3692
4644
|
return { x: 0, y: 0 };
|
|
3693
4645
|
}
|
|
3694
|
-
|
|
3695
|
-
const offset = state.grabOffset ?? this.cursorOffset();
|
|
4646
|
+
const { offset, initialPosition, lockAxis } = placement;
|
|
3696
4647
|
let x = cursor.x - offset.x;
|
|
3697
4648
|
let y = cursor.y - offset.y;
|
|
3698
4649
|
// Apply axis locking if configured
|
|
@@ -3709,10 +4660,10 @@ class DragPreviewComponent {
|
|
|
3709
4660
|
return { x, y };
|
|
3710
4661
|
}
|
|
3711
4662
|
/** Transform-based positioning for better performance (avoid layout from left/top). */
|
|
3712
|
-
|
|
3713
|
-
const { x, y } = this
|
|
3714
|
-
|
|
3715
|
-
}
|
|
4663
|
+
#place(preview, placement, cursor) {
|
|
4664
|
+
const { x, y } = this.#positionAt(placement, cursor);
|
|
4665
|
+
preview.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
|
4666
|
+
}
|
|
3716
4667
|
/** Dimensions of the preview */
|
|
3717
4668
|
dimensions = computed(() => {
|
|
3718
4669
|
const item = this.displayedItem();
|
|
@@ -3748,7 +4699,7 @@ class DragPreviewComponent {
|
|
|
3748
4699
|
}
|
|
3749
4700
|
const settling = {
|
|
3750
4701
|
item: ended.draggedItem,
|
|
3751
|
-
position: this.#
|
|
4702
|
+
position: this.#positionAt(this.#placementFor(ended), ended.cursorPosition),
|
|
3752
4703
|
};
|
|
3753
4704
|
this.#settling.set(settling);
|
|
3754
4705
|
// A normal drop lands in the target list; a cancel, a rejected drop or one the consumer
|
|
@@ -3765,7 +4716,7 @@ class DragPreviewComponent {
|
|
|
3765
4716
|
this.#settling.set(null);
|
|
3766
4717
|
return;
|
|
3767
4718
|
}
|
|
3768
|
-
const target = findDropTarget(settling.item.draggableId, droppableIds);
|
|
4719
|
+
const target = findDropTarget(settling.item.draggableId, droppableIds, (id) => this.#droppableRegistry.getById(id));
|
|
3769
4720
|
this.#dropAnimator.play(ghost, target, () => {
|
|
3770
4721
|
if (this.#settling() === settling) {
|
|
3771
4722
|
this.#settling.set(null);
|
|
@@ -3785,7 +4736,6 @@ class DragPreviewComponent {
|
|
|
3785
4736
|
class="vdnd-drag-preview"
|
|
3786
4737
|
[class.vdnd-drag-preview-dropping]="isSettling()"
|
|
3787
4738
|
[attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
|
|
3788
|
-
[style.transform]="transform()"
|
|
3789
4739
|
[style.width.px]="dimensions().width"
|
|
3790
4740
|
[style.height.px]="dimensions().height"
|
|
3791
4741
|
>
|
|
@@ -3812,7 +4762,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
3812
4762
|
class="vdnd-drag-preview"
|
|
3813
4763
|
[class.vdnd-drag-preview-dropping]="isSettling()"
|
|
3814
4764
|
[attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
|
|
3815
|
-
[style.transform]="transform()"
|
|
3816
4765
|
[style.width.px]="dimensions().width"
|
|
3817
4766
|
[style.height.px]="dimensions().height"
|
|
3818
4767
|
>
|
|
@@ -4013,7 +4962,8 @@ class DroppableDirective {
|
|
|
4013
4962
|
#elementRef = inject((ElementRef));
|
|
4014
4963
|
#dragState = inject(DragStateService);
|
|
4015
4964
|
#autoScroll = inject(AutoScrollService);
|
|
4016
|
-
#
|
|
4965
|
+
#registry = inject(DroppableRegistryService);
|
|
4966
|
+
#ngZone = inject(NgZone);
|
|
4017
4967
|
#parentGroup = inject(VDND_GROUP_TOKEN, { optional: true });
|
|
4018
4968
|
/** Unique identifier for this droppable */
|
|
4019
4969
|
vdndDroppable = input.required(...(ngDevMode ? [{ debugName: "vdndDroppable" }] : []));
|
|
@@ -4042,7 +4992,7 @@ class DroppableDirective {
|
|
|
4042
4992
|
autoScrollConfig = input({}, ...(ngDevMode ? [{ debugName: "autoScrollConfig" }] : []));
|
|
4043
4993
|
/** Constrain drag preview and placeholder to container boundaries */
|
|
4044
4994
|
constrainToContainer = input(false, ...(ngDevMode ? [{ debugName: "constrainToContainer" }] : []));
|
|
4045
|
-
/** Emits when an item is dropped on this droppable */
|
|
4995
|
+
/** Emits when an item is dropped on this droppable: at release, right after its `dragEnd` */
|
|
4046
4996
|
// eslint-disable-next-line @angular-eslint/no-output-native
|
|
4047
4997
|
drop = output();
|
|
4048
4998
|
/**
|
|
@@ -4087,14 +5037,6 @@ class DroppableDirective {
|
|
|
4087
5037
|
#lastPlaceholderIndex = null;
|
|
4088
5038
|
/** Drag whose initial placeholder position has already been seen by this droppable */
|
|
4089
5039
|
#placeholderTrackedDrag = null;
|
|
4090
|
-
/** Track previous active state to detect the drag-end transition */
|
|
4091
|
-
#wasActive = false;
|
|
4092
|
-
/**
|
|
4093
|
-
* The terminal drag snapshot this droppable has already processed. `endedDragState`
|
|
4094
|
-
* persists until the next drag starts, so this guards against replaying the same drop
|
|
4095
|
-
* when the effect re-runs (e.g. `disabled()` toggling) after the drag has ended.
|
|
4096
|
-
*/
|
|
4097
|
-
#handledEndedState = null;
|
|
4098
5040
|
/** Whether this droppable has rendered, so its inputs have values */
|
|
4099
5041
|
#rendered = false;
|
|
4100
5042
|
constructor() {
|
|
@@ -4109,59 +5051,33 @@ class DroppableDirective {
|
|
|
4109
5051
|
// would never register. AutoScrollService filters by live scroll geometry per drag.
|
|
4110
5052
|
canRegister: () => Boolean(this.effectiveGroup()),
|
|
4111
5053
|
});
|
|
4112
|
-
|
|
4113
|
-
//
|
|
4114
|
-
//
|
|
4115
|
-
|
|
4116
|
-
|
|
5054
|
+
afterNextRender(() => (this.#rendered = true));
|
|
5055
|
+
// Register with the droppable registry, and again under the new key when the ID or group
|
|
5056
|
+
// changes. Drag code finds drop targets through the registry instead of querying the
|
|
5057
|
+
// document; a registration during an active drag makes this droppable a target from the
|
|
5058
|
+
// next hit-test. The registry also calls #handleDrop when a drag ends on this droppable.
|
|
5059
|
+
// The cleanup unregisters it, including on destroy.
|
|
5060
|
+
//
|
|
5061
|
+
// Not deferred to afterNextRender: this runs in the change detection that creates the
|
|
5062
|
+
// droppable (inputs are set by then), so afterNextRender hooks of that same render (drop
|
|
5063
|
+
// animation, keyboard focus restore) already find it, and so does a view refreshed only
|
|
5064
|
+
// by its own detectChanges(). Nothing reads the registry before change detection ends.
|
|
5065
|
+
effect((onCleanup) => {
|
|
4117
5066
|
const group = this.effectiveGroup();
|
|
4118
|
-
if (group) {
|
|
4119
|
-
|
|
5067
|
+
if (!group) {
|
|
5068
|
+
return;
|
|
4120
5069
|
}
|
|
5070
|
+
onCleanup(this.#registry.register(this.#elementRef.nativeElement, this.vdndDroppable(), group, (endedState) => this.#handleDrop(endedState)));
|
|
4121
5071
|
});
|
|
4122
5072
|
effect(() => {
|
|
4123
5073
|
const currentIndex = this.#placeholderInsertionIndex();
|
|
4124
5074
|
untracked(() => this.#handlePlaceholderMove(currentIndex));
|
|
4125
5075
|
});
|
|
4126
|
-
// React to state changes and handle drop events.
|
|
4127
|
-
//
|
|
4128
|
-
// Keyed only on the low-frequency fields (isActive/isDragging/draggedItem). The final
|
|
4129
|
-
// placeholder/index values #handleDrop needs are captured by DragStateService.endDrag()
|
|
4130
|
-
// into endedDragState() at the drag-end boundary, so this effect never has to poll the
|
|
4131
|
-
// high-frequency cursor/placeholder signals — reading them here would make it re-run and
|
|
4132
|
-
// re-allocate a snapshot on every 60fps frame, defeating the service's signal splitting.
|
|
4133
|
-
effect(() => {
|
|
4134
|
-
const active = this.isActive();
|
|
4135
|
-
const draggedItem = this.#dragState.draggedItem();
|
|
4136
|
-
const isDragging = this.#dragState.isDragging();
|
|
4137
|
-
// Handle drag end (drop). This droppable is the release target when it was either
|
|
4138
|
-
// observed active during the drag (#wasActive) OR named as the target in the ended
|
|
4139
|
-
// drag snapshot. The snapshot path covers the pointer-up flush, where the final
|
|
4140
|
-
// position is processed and the state cleared in the same synchronous task, so this
|
|
4141
|
-
// effect never observes isActive() === true mid-drag and #wasActive stays false.
|
|
4142
|
-
const endedState = untracked(() => this.#dragState.endedDragState());
|
|
4143
|
-
const endedTargetedThis = endedState?.activeDroppableId === this.vdndDroppable();
|
|
4144
|
-
if (!isDragging && draggedItem === null && (this.#wasActive || endedTargetedThis)) {
|
|
4145
|
-
// Consume each terminal snapshot exactly once. endedDragState persists until the
|
|
4146
|
-
// next drag starts and disabled()/other inputs re-run this effect, so without a
|
|
4147
|
-
// per-snapshot guard a later state change would replay the same historical drop.
|
|
4148
|
-
if (endedState !== this.#handledEndedState) {
|
|
4149
|
-
this.#handledEndedState = endedState;
|
|
4150
|
-
// Emit only for an enabled target that ended normally. A target disabled at
|
|
4151
|
-
// release still consumes the snapshot (so re-enabling can't resurrect the drop)
|
|
4152
|
-
// but emits nothing, keeping dragEnd and drop consistent.
|
|
4153
|
-
if (!this.disabled() && !this.#dragState.wasCancelled()) {
|
|
4154
|
-
this.#handleDrop();
|
|
4155
|
-
}
|
|
4156
|
-
}
|
|
4157
|
-
}
|
|
4158
|
-
this.#wasActive = active;
|
|
4159
|
-
});
|
|
4160
5076
|
}
|
|
4161
5077
|
ngOnDestroy() {
|
|
4162
|
-
// Destroyed before it rendered: its inputs may have no values yet
|
|
4163
|
-
// drop target
|
|
4164
|
-
//
|
|
5078
|
+
// Destroyed before it rendered: its inputs may have no values yet and it never became the
|
|
5079
|
+
// active drop target. (Not an ngOnInit flag: a subclass with its own ngOnInit would skip
|
|
5080
|
+
// it.) A registry registration, if any, is removed by its effect's cleanup.
|
|
4165
5081
|
if (!this.#rendered) {
|
|
4166
5082
|
return;
|
|
4167
5083
|
}
|
|
@@ -4169,13 +5085,7 @@ class DroppableDirective {
|
|
|
4169
5085
|
if (this.isActive()) {
|
|
4170
5086
|
this.#dragState.setActiveDroppable(null);
|
|
4171
5087
|
}
|
|
4172
|
-
// Auto-scroll
|
|
4173
|
-
// If this droppable unmounts mid-drag, tell the calculator so it drops it from the
|
|
4174
|
-
// frozen candidate list (deferred re-query runs once the element has left the DOM).
|
|
4175
|
-
const group = untracked(() => this.effectiveGroup());
|
|
4176
|
-
if (group) {
|
|
4177
|
-
this.#positionCalculator.notifyCandidatesChanged(group);
|
|
4178
|
-
}
|
|
5088
|
+
// Auto-scroll and the droppable registry unregister through their effects' cleanup.
|
|
4179
5089
|
}
|
|
4180
5090
|
/**
|
|
4181
5091
|
* Emit placeholderMove when the placeholder moved within (or entered) this droppable.
|
|
@@ -4209,14 +5119,14 @@ class DroppableDirective {
|
|
|
4209
5119
|
});
|
|
4210
5120
|
}
|
|
4211
5121
|
/**
|
|
4212
|
-
*
|
|
5122
|
+
* Emit `drop` for a drag that ended on this droppable. Called by the registry from
|
|
5123
|
+
* `DragStateService.endDrag()`, with the state captured just before the reset. Returns
|
|
5124
|
+
* whether it emitted.
|
|
4213
5125
|
*/
|
|
4214
|
-
#handleDrop() {
|
|
4215
|
-
//
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
if (!state?.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
|
|
4219
|
-
return;
|
|
5126
|
+
#handleDrop(state) {
|
|
5127
|
+
// A target disabled at release is not a valid drop (dragEnd reports no destination).
|
|
5128
|
+
if (this.disabled() || !state.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
|
|
5129
|
+
return false;
|
|
4220
5130
|
}
|
|
4221
5131
|
const sourceDroppableId = state.sourceDroppableId ?? '';
|
|
4222
5132
|
const placeholderId = state.placeholderId ?? END_OF_LIST;
|
|
@@ -4236,7 +5146,7 @@ class DroppableDirective {
|
|
|
4236
5146
|
sourceDroppableId,
|
|
4237
5147
|
activeDroppableId: this.vdndDroppable(),
|
|
4238
5148
|
});
|
|
4239
|
-
|
|
5149
|
+
const event = {
|
|
4240
5150
|
source: {
|
|
4241
5151
|
draggableId: state.draggedItem.draggableId,
|
|
4242
5152
|
droppableId: sourceDroppableId,
|
|
@@ -4249,7 +5159,13 @@ class DroppableDirective {
|
|
|
4249
5159
|
index: destinationIndex,
|
|
4250
5160
|
data: this.vdndDroppableData(),
|
|
4251
5161
|
},
|
|
4252
|
-
}
|
|
5162
|
+
};
|
|
5163
|
+
// Delivered from the pointer/keyboard listener, which runs outside Angular's zone. With
|
|
5164
|
+
// zone.js a template listener marks its view dirty without scheduling a render, so emit
|
|
5165
|
+
// inside the zone for the consumer's handler (and async work it starts) to render. A
|
|
5166
|
+
// no-op when zoneless.
|
|
5167
|
+
this.#ngZone.run(() => this.drop.emit(event));
|
|
5168
|
+
return true;
|
|
4253
5169
|
}
|
|
4254
5170
|
/**
|
|
4255
5171
|
* Get the index of an item in a droppable.
|
|
@@ -4257,7 +5173,7 @@ class DroppableDirective {
|
|
|
4257
5173
|
*/
|
|
4258
5174
|
#getItemIndex(draggableId, droppableId) {
|
|
4259
5175
|
// Find all draggables in the source droppable
|
|
4260
|
-
const droppable =
|
|
5176
|
+
const droppable = this.#registry.getById(droppableId);
|
|
4261
5177
|
if (!droppable) {
|
|
4262
5178
|
return 0;
|
|
4263
5179
|
}
|
|
@@ -4330,6 +5246,30 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4330
5246
|
}]
|
|
4331
5247
|
}], ctorParameters: () => [], propDecorators: { vdndDroppable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppable", required: true }] }], vdndDroppableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableGroup", required: false }] }], vdndDroppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }], placeholderMove: [{ type: i0.Output, args: ["placeholderMove"] }] } });
|
|
4332
5248
|
|
|
5249
|
+
/**
|
|
5250
|
+
* Forwards the inner droppable's `drop` and `placeholderMove` to the sortable list's outputs.
|
|
5251
|
+
*
|
|
5252
|
+
* Not template listeners: Angular marks the listening view and every ancestor up to the root
|
|
5253
|
+
* dirty before running one, so each placeholder move would re-render the consumer's whole
|
|
5254
|
+
* component chain even when nothing listens to `placeholderMove`. A subscription marks nothing;
|
|
5255
|
+
* a consumer's own listener on the sortable list marks only its own view. Subscribed in the
|
|
5256
|
+
* constructor, before the droppable's first change detection can emit; both outputs drop their
|
|
5257
|
+
* listeners when the element is destroyed.
|
|
5258
|
+
*/
|
|
5259
|
+
class SortableListOutputsDirective {
|
|
5260
|
+
constructor() {
|
|
5261
|
+
const droppable = inject(DroppableDirective);
|
|
5262
|
+
const list = inject(VirtualSortableListComponent);
|
|
5263
|
+
droppable.drop.subscribe((event) => list.drop.emit(event));
|
|
5264
|
+
droppable.placeholderMove.subscribe((event) => list.placeholderMove.emit(event));
|
|
5265
|
+
}
|
|
5266
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: SortableListOutputsDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
5267
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.6", type: SortableListOutputsDirective, isStandalone: true, selector: "[vdndSortableListOutputs]", ngImport: i0 });
|
|
5268
|
+
}
|
|
5269
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: SortableListOutputsDirective, decorators: [{
|
|
5270
|
+
type: Directive,
|
|
5271
|
+
args: [{ selector: '[vdndSortableListOutputs]' }]
|
|
5272
|
+
}], ctorParameters: () => [] });
|
|
4333
5273
|
/**
|
|
4334
5274
|
* A high-level component that combines droppable, virtual scroll, and placeholder
|
|
4335
5275
|
* functionality into a single, easy-to-use component.
|
|
@@ -4407,6 +5347,13 @@ class VirtualSortableListComponent {
|
|
|
4407
5347
|
containerHeight = input(...(ngDevMode ? [undefined, { debugName: "containerHeight" }] : []));
|
|
4408
5348
|
/** Number of items to render above/below the visible area */
|
|
4409
5349
|
overscan = input(3, ...(ngDevMode ? [{ debugName: "overscan" }] : []));
|
|
5350
|
+
/**
|
|
5351
|
+
* Reuse the views of rows that scroll out to render the rows that scroll in, instead of
|
|
5352
|
+
* destroying them and creating new ones. Scrolling creates fewer components and elements, but
|
|
5353
|
+
* a row's components, element and DOM state (focus aside) then carry over to other items.
|
|
5354
|
+
* @default false
|
|
5355
|
+
*/
|
|
5356
|
+
recycleRows = input(false, ...(ngDevMode ? [{ debugName: "recycleRows" }] : []));
|
|
4410
5357
|
/** Enable auto-scroll when dragging near edges */
|
|
4411
5358
|
autoScrollEnabled = input(true, ...(ngDevMode ? [{ debugName: "autoScrollEnabled" }] : []));
|
|
4412
5359
|
/** Auto-scroll configuration */
|
|
@@ -4420,7 +5367,7 @@ class VirtualSortableListComponent {
|
|
|
4420
5367
|
/** Emits each time the placeholder moves within this list (every item displacement) */
|
|
4421
5368
|
placeholderMove = output();
|
|
4422
5369
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
4423
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualSortableListComponent, isStandalone: true, selector: "vdnd-sortable-list", inputs: { droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: true, transformFunction: null }, group: { classPropertyName: "group", publicName: "group", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableData: { classPropertyName: "droppableData", publicName: "droppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, constrainToContainer: { classPropertyName: "constrainToContainer", publicName: "constrainToContainer", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { drop: "drop", placeholderMove: "placeholderMove" }, host: { classAttribute: "vdnd-sortable-list" }, ngImport: i0, template: `
|
|
5370
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualSortableListComponent, isStandalone: true, selector: "vdnd-sortable-list", inputs: { droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: true, transformFunction: null }, group: { classPropertyName: "group", publicName: "group", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableData: { classPropertyName: "droppableData", publicName: "droppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, recycleRows: { classPropertyName: "recycleRows", publicName: "recycleRows", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, constrainToContainer: { classPropertyName: "constrainToContainer", publicName: "constrainToContainer", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { drop: "drop", placeholderMove: "placeholderMove" }, host: { classAttribute: "vdnd-sortable-list" }, ngImport: i0, template: `
|
|
4424
5371
|
<div
|
|
4425
5372
|
class="vdnd-sortable-list-droppable"
|
|
4426
5373
|
[vdndDroppable]="droppableId()"
|
|
@@ -4428,8 +5375,7 @@ class VirtualSortableListComponent {
|
|
|
4428
5375
|
[vdndDroppableData]="droppableData()"
|
|
4429
5376
|
[disabled]="disabled()"
|
|
4430
5377
|
[constrainToContainer]="constrainToContainer()"
|
|
4431
|
-
|
|
4432
|
-
(placeholderMove)="placeholderMove.emit($event)"
|
|
5378
|
+
vdndSortableListOutputs
|
|
4433
5379
|
>
|
|
4434
5380
|
<vdnd-virtual-scroll
|
|
4435
5381
|
[items]="items()"
|
|
@@ -4442,16 +5388,17 @@ class VirtualSortableListComponent {
|
|
|
4442
5388
|
[autoStickyDraggedItem]="true"
|
|
4443
5389
|
[containerHeight]="containerHeight()"
|
|
4444
5390
|
[overscan]="overscan()"
|
|
5391
|
+
[recycleRows]="recycleRows()"
|
|
4445
5392
|
[autoScrollEnabled]="autoScrollEnabled()"
|
|
4446
5393
|
[autoScrollConfig]="autoScrollConfig()"
|
|
4447
5394
|
>
|
|
4448
5395
|
</vdnd-virtual-scroll>
|
|
4449
5396
|
</div>
|
|
4450
|
-
`, isInline: true, styles: [":host{display:block}\n"], dependencies: [{ kind: "component", type: VirtualScrollContainerComponent, selector: "vdnd-virtual-scroll", inputs: ["itemTemplate", "scrollContainerId", "autoScrollEnabled", "autoScrollConfig", "items", "itemHeight", "dynamicItemHeight", "containerHeight", "overscan", "stickyItemIds", "itemIdFn", "trackByFn", "droppableId", "autoStickyDraggedItem"] }, { kind: "directive", type: DroppableDirective, selector: "[vdndDroppable]", inputs: ["vdndDroppable", "vdndDroppableGroup", "vdndDroppableData", "disabled", "autoScrollEnabled", "autoScrollConfig", "constrainToContainer"], outputs: ["drop", "placeholderMove"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
5397
|
+
`, isInline: true, styles: [":host{display:block}\n"], dependencies: [{ kind: "component", type: VirtualScrollContainerComponent, selector: "vdnd-virtual-scroll", inputs: ["itemTemplate", "scrollContainerId", "autoScrollEnabled", "autoScrollConfig", "items", "itemHeight", "dynamicItemHeight", "containerHeight", "overscan", "stickyItemIds", "itemIdFn", "trackByFn", "droppableId", "recycleRows", "autoStickyDraggedItem"] }, { kind: "directive", type: DroppableDirective, selector: "[vdndDroppable]", inputs: ["vdndDroppable", "vdndDroppableGroup", "vdndDroppableData", "disabled", "autoScrollEnabled", "autoScrollConfig", "constrainToContainer"], outputs: ["drop", "placeholderMove"] }, { kind: "directive", type: SortableListOutputsDirective, selector: "[vdndSortableListOutputs]" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
4451
5398
|
}
|
|
4452
5399
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, decorators: [{
|
|
4453
5400
|
type: Component,
|
|
4454
|
-
args: [{ selector: 'vdnd-sortable-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [VirtualScrollContainerComponent, DroppableDirective], host: {
|
|
5401
|
+
args: [{ selector: 'vdnd-sortable-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [VirtualScrollContainerComponent, DroppableDirective, SortableListOutputsDirective], host: {
|
|
4455
5402
|
class: 'vdnd-sortable-list',
|
|
4456
5403
|
}, template: `
|
|
4457
5404
|
<div
|
|
@@ -4461,8 +5408,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4461
5408
|
[vdndDroppableData]="droppableData()"
|
|
4462
5409
|
[disabled]="disabled()"
|
|
4463
5410
|
[constrainToContainer]="constrainToContainer()"
|
|
4464
|
-
|
|
4465
|
-
(placeholderMove)="placeholderMove.emit($event)"
|
|
5411
|
+
vdndSortableListOutputs
|
|
4466
5412
|
>
|
|
4467
5413
|
<vdnd-virtual-scroll
|
|
4468
5414
|
[items]="items()"
|
|
@@ -4475,13 +5421,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4475
5421
|
[autoStickyDraggedItem]="true"
|
|
4476
5422
|
[containerHeight]="containerHeight()"
|
|
4477
5423
|
[overscan]="overscan()"
|
|
5424
|
+
[recycleRows]="recycleRows()"
|
|
4478
5425
|
[autoScrollEnabled]="autoScrollEnabled()"
|
|
4479
5426
|
[autoScrollConfig]="autoScrollConfig()"
|
|
4480
5427
|
>
|
|
4481
5428
|
</vdnd-virtual-scroll>
|
|
4482
5429
|
</div>
|
|
4483
5430
|
`, styles: [":host{display:block}\n"] }]
|
|
4484
|
-
}], propDecorators: { droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: true }] }], group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }], placeholderMove: [{ type: i0.Output, args: ["placeholderMove"] }] } });
|
|
5431
|
+
}], propDecorators: { droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: true }] }], group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], recycleRows: [{ type: i0.Input, args: [{ isSignal: true, alias: "recycleRows", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }], placeholderMove: [{ type: i0.Output, args: ["placeholderMove"] }] } });
|
|
4485
5432
|
|
|
4486
5433
|
/**
|
|
4487
5434
|
* A virtual viewport component that provides efficient wrapper-based positioning
|
|
@@ -5129,22 +6076,25 @@ class KeyboardDragHandler {
|
|
|
5129
6076
|
this.#cleanupDocumentListener();
|
|
5130
6077
|
// Clear droppable metadata cache from this drag session
|
|
5131
6078
|
this.#deps.dragIndexCalculator.clearCache();
|
|
5132
|
-
|
|
5133
|
-
this.#deps.callbacks.onDragEnd({
|
|
6079
|
+
const event = {
|
|
5134
6080
|
draggableId: ctx.draggableId,
|
|
5135
6081
|
droppableId: this.#deps.callbacks.getParentDroppableId() ?? '',
|
|
5136
6082
|
cancelled: false,
|
|
5137
6083
|
data: ctx.data,
|
|
5138
6084
|
sourceIndex,
|
|
5139
6085
|
destinationIndex,
|
|
5140
|
-
}
|
|
6086
|
+
};
|
|
5141
6087
|
// The item stays in its source list when there is no valid target
|
|
5142
6088
|
const fallbackDroppableId = hasValidTarget
|
|
5143
6089
|
? activeDroppableId
|
|
5144
6090
|
: this.#deps.dragState.sourceDroppableId();
|
|
5145
|
-
this.#
|
|
5146
|
-
|
|
5147
|
-
|
|
6091
|
+
this.#endInZone(() => {
|
|
6092
|
+
this.#deps.callbacks.onDragEnd(event);
|
|
6093
|
+
// Ends the drag state, which delivers the drop
|
|
6094
|
+
this.#deps.keyboardDrag.completeKeyboardDrag();
|
|
6095
|
+
// Restore focus to the moved element after state updates
|
|
6096
|
+
this.#restoreFocus(ctx.draggableId, fallbackDroppableId);
|
|
6097
|
+
});
|
|
5148
6098
|
}
|
|
5149
6099
|
/**
|
|
5150
6100
|
* Cancel a keyboard drag operation.
|
|
@@ -5158,18 +6108,30 @@ class KeyboardDragHandler {
|
|
|
5158
6108
|
this.#cleanupDocumentListener();
|
|
5159
6109
|
// Clear droppable metadata cache from this drag session
|
|
5160
6110
|
this.#deps.dragIndexCalculator.clearCache();
|
|
5161
|
-
|
|
5162
|
-
this.#deps.callbacks.onDragEnd({
|
|
6111
|
+
const event = {
|
|
5163
6112
|
draggableId: ctx.draggableId,
|
|
5164
6113
|
droppableId: this.#deps.callbacks.getParentDroppableId() ?? '',
|
|
5165
6114
|
cancelled: true,
|
|
5166
6115
|
data: ctx.data,
|
|
5167
6116
|
sourceIndex,
|
|
5168
6117
|
destinationIndex: null,
|
|
6118
|
+
};
|
|
6119
|
+
this.#endInZone(() => {
|
|
6120
|
+
this.#deps.callbacks.onDragEnd(event);
|
|
6121
|
+
this.#deps.keyboardDrag.cancelKeyboardDrag();
|
|
6122
|
+
// Restore focus to the original element after state updates
|
|
6123
|
+
this.#restoreFocus(ctx.draggableId, sourceDroppableId);
|
|
5169
6124
|
});
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
6125
|
+
}
|
|
6126
|
+
/**
|
|
6127
|
+
* Run a drag end (dragEnd, the state reset that delivers drop, the focus restore) in one
|
|
6128
|
+
* entry into Angular's zone. The document listener runs outside it; with zone.js a
|
|
6129
|
+
* template listener marks its view dirty but schedules no render, and each entry renders
|
|
6130
|
+
* when it is left, so this renders the end once, with focus restored in that render. A
|
|
6131
|
+
* no-op when zoneless.
|
|
6132
|
+
*/
|
|
6133
|
+
#endInZone(end) {
|
|
6134
|
+
this.#deps.ngZone.run(end);
|
|
5173
6135
|
}
|
|
5174
6136
|
/**
|
|
5175
6137
|
* Teardown — remove any active document listener.
|
|
@@ -5197,7 +6159,7 @@ class KeyboardDragHandler {
|
|
|
5197
6159
|
}
|
|
5198
6160
|
else if (fallbackDroppableId) {
|
|
5199
6161
|
// Fallback: focus the first draggable in the list the item ended up in
|
|
5200
|
-
const container =
|
|
6162
|
+
const container = this.#deps.positionCalculator.getDroppableById(fallbackDroppableId);
|
|
5201
6163
|
const firstDraggable = container?.querySelector('[data-draggable-id]');
|
|
5202
6164
|
firstDraggable?.focus();
|
|
5203
6165
|
}
|
|
@@ -5291,6 +6253,10 @@ class PointerDragHandler {
|
|
|
5291
6253
|
#startPosition = null;
|
|
5292
6254
|
/** Whether we're currently tracking a potential drag */
|
|
5293
6255
|
#isTracking = false;
|
|
6256
|
+
/** The item the tracked press landed on */
|
|
6257
|
+
#pressedId = null;
|
|
6258
|
+
/** Whether the document and window listeners of a press are added */
|
|
6259
|
+
#listening = false;
|
|
5294
6260
|
/** Identifier of the finger that pressed, for touch gestures (other fingers are ignored) */
|
|
5295
6261
|
#touchId = null;
|
|
5296
6262
|
/** Bound event handlers for cleanup */
|
|
@@ -5373,6 +6339,7 @@ class PointerDragHandler {
|
|
|
5373
6339
|
return;
|
|
5374
6340
|
}
|
|
5375
6341
|
this.#isTracking = true;
|
|
6342
|
+
this.#pressedId = ctx.draggableId;
|
|
5376
6343
|
if (touch) {
|
|
5377
6344
|
this.#touchId = touch.identifier;
|
|
5378
6345
|
this.#startPosition = { x: touch.clientX, y: touch.clientY };
|
|
@@ -5387,9 +6354,10 @@ class PointerDragHandler {
|
|
|
5387
6354
|
if (delay > 0) {
|
|
5388
6355
|
this.#delayTimerId = setTimeout(() => {
|
|
5389
6356
|
this.#delayTimerId = null;
|
|
5390
|
-
// Another drag started while this press was held
|
|
5391
|
-
// as ready to drag
|
|
5392
|
-
if (this.#deps.callbacks.isOtherDragActive()
|
|
6357
|
+
// Another drag started while this press was held, or the press no longer applies (see
|
|
6358
|
+
// #isStalePress): drop it rather than show it as ready to drag.
|
|
6359
|
+
if (this.#deps.callbacks.isOtherDragActive() ||
|
|
6360
|
+
this.#isStalePress(this.#deps.getContext())) {
|
|
5393
6361
|
this.cleanup();
|
|
5394
6362
|
return;
|
|
5395
6363
|
}
|
|
@@ -5418,6 +6386,7 @@ class PointerDragHandler {
|
|
|
5418
6386
|
window.addEventListener('blur', this.#boundWindowBlur);
|
|
5419
6387
|
document.addEventListener('visibilitychange', this.#boundVisibilityChange);
|
|
5420
6388
|
});
|
|
6389
|
+
this.#listening = true;
|
|
5421
6390
|
}
|
|
5422
6391
|
/**
|
|
5423
6392
|
* Remove listeners + cancel timers.
|
|
@@ -5425,14 +6394,17 @@ class PointerDragHandler {
|
|
|
5425
6394
|
*/
|
|
5426
6395
|
cleanup() {
|
|
5427
6396
|
this.#isTracking = false;
|
|
6397
|
+
this.#pressedId = null;
|
|
5428
6398
|
this.#startPosition = null;
|
|
5429
6399
|
this.#touchId = null;
|
|
5430
6400
|
this.#deps.callbacks.onPendingChange(false); // Clear pending state on cleanup
|
|
5431
6401
|
this.#cancelDelayTimer();
|
|
5432
|
-
//
|
|
5433
|
-
|
|
6402
|
+
// Nothing to remove unless a press added listeners (most draggables are never pressed; a
|
|
6403
|
+
// virtual list destroys one each time a row scrolls out). Server rendering adds none.
|
|
6404
|
+
if (!this.#listening) {
|
|
5434
6405
|
return;
|
|
5435
6406
|
}
|
|
6407
|
+
this.#listening = false;
|
|
5436
6408
|
// Remove event listeners
|
|
5437
6409
|
if (this.#boundPointerMove) {
|
|
5438
6410
|
document.removeEventListener('mousemove', this.#boundPointerMove);
|
|
@@ -5483,9 +6455,14 @@ class PointerDragHandler {
|
|
|
5483
6455
|
else {
|
|
5484
6456
|
position = { x: event.clientX, y: event.clientY };
|
|
5485
6457
|
}
|
|
5486
|
-
const ctx = this.#deps.getContext();
|
|
5487
6458
|
// Check if we've moved past the threshold
|
|
5488
6459
|
if (!this.#deps.callbacks.isDragging() && this.#startPosition) {
|
|
6460
|
+
// Read only before the drag starts, not on every move of a drag: nothing in it matters then
|
|
6461
|
+
const ctx = this.#deps.getContext();
|
|
6462
|
+
if (this.#isStalePress(ctx)) {
|
|
6463
|
+
this.cleanup();
|
|
6464
|
+
return;
|
|
6465
|
+
}
|
|
5489
6466
|
const distance = Math.sqrt(Math.pow(position.x - this.#startPosition.x, 2) +
|
|
5490
6467
|
Math.pow(position.y - this.#startPosition.y, 2));
|
|
5491
6468
|
if (distance < ctx.dragThreshold) {
|
|
@@ -5537,13 +6514,25 @@ class PointerDragHandler {
|
|
|
5537
6514
|
}
|
|
5538
6515
|
// Only prevent default if we were actually dragging
|
|
5539
6516
|
// Otherwise, allow native touch behavior (like scroll momentum) to complete
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
6517
|
+
// The drag end runs consumer handlers (dragEnd, drop); stop tracking even if one throws
|
|
6518
|
+
try {
|
|
6519
|
+
if (this.#deps.callbacks.isDragging()) {
|
|
6520
|
+
event.preventDefault();
|
|
6521
|
+
// The system took the touch (an incoming call, an OS gesture, too many fingers): the
|
|
6522
|
+
// user never released, so this is a cancel, not a drop
|
|
6523
|
+
this.#deps.callbacks.onDragEnd(event.type === 'touchcancel');
|
|
6524
|
+
}
|
|
5545
6525
|
}
|
|
5546
|
-
|
|
6526
|
+
finally {
|
|
6527
|
+
this.cleanup();
|
|
6528
|
+
}
|
|
6529
|
+
}
|
|
6530
|
+
/**
|
|
6531
|
+
* Whether the tracked press no longer applies to the draggable: it renders another item now, or
|
|
6532
|
+
* it left the page (a virtual list recycled or pooled its row).
|
|
6533
|
+
*/
|
|
6534
|
+
#isStalePress(ctx) {
|
|
6535
|
+
return ctx.draggableId !== this.#pressedId || !ctx.element.isConnected;
|
|
5547
6536
|
}
|
|
5548
6537
|
/**
|
|
5549
6538
|
* Cancel the delay timer if active.
|
|
@@ -5560,9 +6549,12 @@ class PointerDragHandler {
|
|
|
5560
6549
|
*/
|
|
5561
6550
|
#onEscapeKeyDown(event) {
|
|
5562
6551
|
if (event.key === 'Escape' && this.#deps.callbacks.isDragging()) {
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
6552
|
+
try {
|
|
6553
|
+
this.#deps.callbacks.onDragEnd(true);
|
|
6554
|
+
}
|
|
6555
|
+
finally {
|
|
6556
|
+
this.cleanup();
|
|
6557
|
+
}
|
|
5566
6558
|
}
|
|
5567
6559
|
}
|
|
5568
6560
|
/**
|
|
@@ -5572,10 +6564,14 @@ class PointerDragHandler {
|
|
|
5572
6564
|
if (!this.#isTracking) {
|
|
5573
6565
|
return;
|
|
5574
6566
|
}
|
|
5575
|
-
|
|
5576
|
-
this.#deps.callbacks.
|
|
6567
|
+
try {
|
|
6568
|
+
if (this.#deps.callbacks.isDragging()) {
|
|
6569
|
+
this.#deps.callbacks.onDragEnd(true);
|
|
6570
|
+
}
|
|
6571
|
+
}
|
|
6572
|
+
finally {
|
|
6573
|
+
this.cleanup();
|
|
5577
6574
|
}
|
|
5578
|
-
this.cleanup();
|
|
5579
6575
|
}
|
|
5580
6576
|
/**
|
|
5581
6577
|
* Treat the page being hidden (tab switch, app switch on mobile) as focus loss.
|
|
@@ -5587,10 +6583,20 @@ class PointerDragHandler {
|
|
|
5587
6583
|
}
|
|
5588
6584
|
/**
|
|
5589
6585
|
* The tracked finger in `touches`, or null. Synthetic touch events (e.g. from test tools) can
|
|
5590
|
-
* leave a list undefined, so a missing list counts as empty.
|
|
6586
|
+
* leave a list undefined, so a missing list counts as empty. Read by index, without copying the
|
|
6587
|
+
* list or creating an iterator: this runs for every touch move.
|
|
5591
6588
|
*/
|
|
5592
6589
|
#trackedTouch(touches) {
|
|
5593
|
-
|
|
6590
|
+
if (!touches) {
|
|
6591
|
+
return null;
|
|
6592
|
+
}
|
|
6593
|
+
// eslint-disable-next-line @typescript-eslint/prefer-for-of -- for...of creates an iterator
|
|
6594
|
+
for (let i = 0; i < touches.length; i++) {
|
|
6595
|
+
if (touches[i].identifier === this.#touchId) {
|
|
6596
|
+
return touches[i];
|
|
6597
|
+
}
|
|
6598
|
+
}
|
|
6599
|
+
return null;
|
|
5594
6600
|
}
|
|
5595
6601
|
/**
|
|
5596
6602
|
* The finger that pressed this draggable: of the touches that started with this event, the
|
|
@@ -5604,6 +6610,26 @@ class PointerDragHandler {
|
|
|
5604
6610
|
}
|
|
5605
6611
|
}
|
|
5606
6612
|
|
|
6613
|
+
/** Key names as Angular's `keydown.<key>` bindings spell them, for the `event.key` values that differ */
|
|
6614
|
+
const KEY_NAMES = {
|
|
6615
|
+
' ': 'space',
|
|
6616
|
+
Esc: 'escape',
|
|
6617
|
+
'\x1B': 'escape',
|
|
6618
|
+
Up: 'arrowup',
|
|
6619
|
+
Down: 'arrowdown',
|
|
6620
|
+
Left: 'arrowleft',
|
|
6621
|
+
Right: 'arrowright',
|
|
6622
|
+
};
|
|
6623
|
+
/** The keys an item handles */
|
|
6624
|
+
const HANDLED_KEYS = new Set([
|
|
6625
|
+
'space',
|
|
6626
|
+
'enter',
|
|
6627
|
+
'arrowup',
|
|
6628
|
+
'arrowdown',
|
|
6629
|
+
'arrowleft',
|
|
6630
|
+
'arrowright',
|
|
6631
|
+
'escape',
|
|
6632
|
+
]);
|
|
5607
6633
|
/**
|
|
5608
6634
|
* Makes an element draggable within the virtual scroll drag-and-drop system.
|
|
5609
6635
|
*
|
|
@@ -5680,9 +6706,16 @@ class DraggableDirective {
|
|
|
5680
6706
|
dragStart = output();
|
|
5681
6707
|
/** Emits when drag ends */
|
|
5682
6708
|
dragEnd = output();
|
|
5683
|
-
/**
|
|
5684
|
-
#
|
|
5685
|
-
|
|
6709
|
+
/** The item whose press is ready to drag (its delay has passed) */
|
|
6710
|
+
#pendingId = signal(null, ...(ngDevMode ? [{ debugName: "#pendingId" }] : []));
|
|
6711
|
+
/**
|
|
6712
|
+
* Whether this element is in the "ready to drag" state (delay has passed). Tied to the pressed
|
|
6713
|
+
* item: a recycled row that renders another item is not ready to drag.
|
|
6714
|
+
*/
|
|
6715
|
+
isPending = computed(() => {
|
|
6716
|
+
const pendingId = this.#pendingId();
|
|
6717
|
+
return pendingId !== null && pendingId === this.vdndDraggable();
|
|
6718
|
+
}, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
|
|
5686
6719
|
/** Whether this element is currently being dragged (based on global drag state) */
|
|
5687
6720
|
isDragging = computed(() => {
|
|
5688
6721
|
const draggedItem = this.#dragState.draggedItem();
|
|
@@ -5692,6 +6725,62 @@ class DraggableDirective {
|
|
|
5692
6725
|
#pointerHandler;
|
|
5693
6726
|
/** Set by ngOnInit, which creates the handlers */
|
|
5694
6727
|
#initialized = false;
|
|
6728
|
+
/**
|
|
6729
|
+
* Keys pressed on the item: one listener, added outside Angular's zone, instead of a
|
|
6730
|
+
* `(keydown.<key>)` host binding per key, as Angular adds a listener to every row for each.
|
|
6731
|
+
* Like those bindings, it ignores a key pressed with Shift, Ctrl, Alt or Meta, runs the handler
|
|
6732
|
+
* of a key it handles in the zone (so zone.js apps still render after it), prevents the key's
|
|
6733
|
+
* default action when the handler returns false, and reports what the handler throws to the
|
|
6734
|
+
* ErrorHandler. Added in the constructor, so it runs before the element's template listeners,
|
|
6735
|
+
* as host listeners do.
|
|
6736
|
+
*/
|
|
6737
|
+
#onKeydown = (event) => {
|
|
6738
|
+
if (!this.#initialized || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
|
|
6739
|
+
return;
|
|
6740
|
+
}
|
|
6741
|
+
const key = event.key ? (KEY_NAMES[event.key] ?? event.key.toLowerCase()) : '';
|
|
6742
|
+
if (!HANDLED_KEYS.has(key)) {
|
|
6743
|
+
return;
|
|
6744
|
+
}
|
|
6745
|
+
this.#ngZone.run(() => {
|
|
6746
|
+
try {
|
|
6747
|
+
if (this.#handleKey(key, event) === false) {
|
|
6748
|
+
event.preventDefault();
|
|
6749
|
+
}
|
|
6750
|
+
}
|
|
6751
|
+
catch (error) {
|
|
6752
|
+
this.#envInjector.get(ErrorHandler).handleError(error);
|
|
6753
|
+
}
|
|
6754
|
+
});
|
|
6755
|
+
};
|
|
6756
|
+
/**
|
|
6757
|
+
* Touch presses on the item. Added programmatically, not as a host binding (which Angular adds
|
|
6758
|
+
* without options, so non-passive): a swipe that starts on an element with a non-passive
|
|
6759
|
+
* touchstart listener can't scroll until the main thread has run it. With a drag delay the press
|
|
6760
|
+
* never cancels the scroll (the page must scroll when the user swipes before the delay passes),
|
|
6761
|
+
* so the listener is passive; without one it prevents the default action and is non-passive.
|
|
6762
|
+
* Like the keydown listener, it runs the handler in the zone (as the mousedown host binding
|
|
6763
|
+
* does) and reports what it throws to the ErrorHandler. Added once the inputs are set, so unlike
|
|
6764
|
+
* a host listener it runs after the element's template listeners.
|
|
6765
|
+
*/
|
|
6766
|
+
#onTouchStart = (event) => {
|
|
6767
|
+
if (!this.#initialized) {
|
|
6768
|
+
return;
|
|
6769
|
+
}
|
|
6770
|
+
this.#ngZone.run(() => {
|
|
6771
|
+
try {
|
|
6772
|
+
this.onPointerDown(event, true);
|
|
6773
|
+
}
|
|
6774
|
+
catch (error) {
|
|
6775
|
+
this.#envInjector.get(ErrorHandler).handleError(error);
|
|
6776
|
+
}
|
|
6777
|
+
});
|
|
6778
|
+
};
|
|
6779
|
+
/** Whether the touchstart listener is passive, or null while it isn't added */
|
|
6780
|
+
#touchStartPassive = null;
|
|
6781
|
+
constructor() {
|
|
6782
|
+
this.#ngZone.runOutsideAngular(() => this.#elementRef.nativeElement.addEventListener('keydown', this.#onKeydown));
|
|
6783
|
+
}
|
|
5695
6784
|
/** Cached constraint flag from source droppable */
|
|
5696
6785
|
#constrainToContainer = false;
|
|
5697
6786
|
/** Element whose rect is used for clamping (scroll container or droppable itself) */
|
|
@@ -5702,9 +6791,7 @@ class DraggableDirective {
|
|
|
5702
6791
|
* Update the pending state and emit the change event.
|
|
5703
6792
|
*/
|
|
5704
6793
|
#setPending(pending) {
|
|
5705
|
-
|
|
5706
|
-
this.#isPending.set(pending);
|
|
5707
|
-
}
|
|
6794
|
+
this.#pendingId.set(pending ? this.vdndDraggable() : null);
|
|
5708
6795
|
}
|
|
5709
6796
|
/**
|
|
5710
6797
|
* Find the element to use for container constraint clamping.
|
|
@@ -5729,7 +6816,8 @@ class DraggableDirective {
|
|
|
5729
6816
|
ngZone: this.#ngZone,
|
|
5730
6817
|
envInjector: this.#envInjector,
|
|
5731
6818
|
callbacks: {
|
|
5732
|
-
onDragStart: (event) => this.dragStart.emit(event),
|
|
6819
|
+
onDragStart: (event) => this.#ngZone.run(() => this.dragStart.emit(event)),
|
|
6820
|
+
// The keyboard handler ends the drag inside the zone (dragEnd, drop, focus restore)
|
|
5733
6821
|
onDragEnd: (event) => this.dragEnd.emit(event),
|
|
5734
6822
|
getParentDroppableId: () => this.#getParentDroppableId(),
|
|
5735
6823
|
calculateSourceIndex: (el, droppable) => this.#calculateSourceIndex(el, droppable),
|
|
@@ -5758,6 +6846,7 @@ class DraggableDirective {
|
|
|
5758
6846
|
},
|
|
5759
6847
|
getContext: () => ({
|
|
5760
6848
|
element: this.#elementRef.nativeElement,
|
|
6849
|
+
draggableId: this.vdndDraggable(),
|
|
5761
6850
|
groupName: this.#effectiveGroup(),
|
|
5762
6851
|
disabled: this.disabled(),
|
|
5763
6852
|
dragHandle: this.dragHandle(),
|
|
@@ -5766,8 +6855,14 @@ class DraggableDirective {
|
|
|
5766
6855
|
}),
|
|
5767
6856
|
});
|
|
5768
6857
|
this.#initialized = true;
|
|
6858
|
+
this.#listenForTouchStart();
|
|
6859
|
+
}
|
|
6860
|
+
ngOnChanges() {
|
|
6861
|
+
this.#listenForTouchStart();
|
|
5769
6862
|
}
|
|
5770
6863
|
ngOnDestroy() {
|
|
6864
|
+
this.#elementRef.nativeElement.removeEventListener('keydown', this.#onKeydown);
|
|
6865
|
+
this.#elementRef.nativeElement.removeEventListener('touchstart', this.#onTouchStart);
|
|
5771
6866
|
// Destroyed before its first change detection: there are no handlers yet, and its inputs
|
|
5772
6867
|
// have no values (reading a bound ID would throw).
|
|
5773
6868
|
if (!this.#initialized) {
|
|
@@ -5780,6 +6875,35 @@ class DraggableDirective {
|
|
|
5780
6875
|
this.#pointerHandler.destroy();
|
|
5781
6876
|
this.#keyboardHandler.destroy();
|
|
5782
6877
|
}
|
|
6878
|
+
/**
|
|
6879
|
+
* Add the touchstart listener, or re-add it when the drag delay changes whether it can be
|
|
6880
|
+
* passive. Lifecycle hooks, not an effect: creating a view effect schedules a change detection
|
|
6881
|
+
* that visits every ancestor, and a virtual list creates rows on each scroll step.
|
|
6882
|
+
*/
|
|
6883
|
+
#listenForTouchStart() {
|
|
6884
|
+
// The handler cancels the scroll of a touch press only when there is no delay
|
|
6885
|
+
const passive = this.dragDelay() !== 0;
|
|
6886
|
+
if (passive === this.#touchStartPassive) {
|
|
6887
|
+
return;
|
|
6888
|
+
}
|
|
6889
|
+
const element = this.#elementRef.nativeElement;
|
|
6890
|
+
element.removeEventListener('touchstart', this.#onTouchStart);
|
|
6891
|
+
this.#ngZone.runOutsideAngular(() => element.addEventListener('touchstart', this.#onTouchStart, { passive }));
|
|
6892
|
+
this.#touchStartPassive = passive;
|
|
6893
|
+
}
|
|
6894
|
+
/** Run the handler of a key the item handles (see HANDLED_KEYS), returning what it returns. */
|
|
6895
|
+
#handleKey(key, event) {
|
|
6896
|
+
switch (key) {
|
|
6897
|
+
case 'space':
|
|
6898
|
+
return this.onKeyboardActivate(event);
|
|
6899
|
+
case 'enter':
|
|
6900
|
+
return this.onEnterKey(event);
|
|
6901
|
+
case 'escape':
|
|
6902
|
+
return this.onEscape();
|
|
6903
|
+
default:
|
|
6904
|
+
return this.onArrowKey(event);
|
|
6905
|
+
}
|
|
6906
|
+
}
|
|
5783
6907
|
/**
|
|
5784
6908
|
* Handle pointer down (mouse or touch).
|
|
5785
6909
|
*/
|
|
@@ -5957,13 +7081,16 @@ class DraggableDirective {
|
|
|
5957
7081
|
// Start auto-scroll monitoring — registers as a scheduler participant.
|
|
5958
7082
|
this.#autoScroll.startMonitoring(() => this.#recalculatePlaceholder());
|
|
5959
7083
|
// Emit drag start event
|
|
5960
|
-
|
|
7084
|
+
// Pointer listeners run outside Angular's zone. With zone.js a template listener marks
|
|
7085
|
+
// its view dirty but schedules no render, so emit inside the zone (a no-op when zoneless).
|
|
7086
|
+
const event = {
|
|
5961
7087
|
draggableId: this.vdndDraggable(),
|
|
5962
7088
|
droppableId: parentDroppableId ?? '',
|
|
5963
7089
|
data: this.vdndDraggableData(),
|
|
5964
7090
|
position,
|
|
5965
7091
|
sourceIndex,
|
|
5966
|
-
}
|
|
7092
|
+
};
|
|
7093
|
+
this.#ngZone.run(() => this.dragStart.emit(event));
|
|
5967
7094
|
}
|
|
5968
7095
|
/**
|
|
5969
7096
|
* Calculate the source index of the dragged element BEFORE it's hidden.
|
|
@@ -6179,22 +7306,27 @@ class DraggableDirective {
|
|
|
6179
7306
|
sourceDroppableId: this.#dragState.sourceDroppableId(),
|
|
6180
7307
|
activeDroppableId: this.#dragState.activeDroppableId(),
|
|
6181
7308
|
});
|
|
6182
|
-
|
|
6183
|
-
this.dragEnd.emit({
|
|
7309
|
+
const event = {
|
|
6184
7310
|
draggableId: this.vdndDraggable(),
|
|
6185
7311
|
droppableId: this.#getParentDroppableId() ?? '',
|
|
6186
7312
|
cancelled,
|
|
6187
7313
|
data: this.vdndDraggableData(),
|
|
6188
7314
|
sourceIndex,
|
|
6189
7315
|
destinationIndex,
|
|
7316
|
+
};
|
|
7317
|
+
// dragEnd, then the state reset that delivers drop, in ONE zone entry: listeners run
|
|
7318
|
+
// outside Angular's zone, and with zone.js each entry renders when it is left, so
|
|
7319
|
+
// separate entries would render the drag end twice. A no-op when zoneless.
|
|
7320
|
+
this.#ngZone.run(() => {
|
|
7321
|
+
this.dragEnd.emit(event);
|
|
7322
|
+
// Clear drag state - this triggers isDragging computed to become false
|
|
7323
|
+
if (cancelled) {
|
|
7324
|
+
this.#dragState.cancelDrag();
|
|
7325
|
+
}
|
|
7326
|
+
else {
|
|
7327
|
+
this.#dragState.endDrag();
|
|
7328
|
+
}
|
|
6190
7329
|
});
|
|
6191
|
-
// Clear drag state - this triggers isDragging computed to become false
|
|
6192
|
-
if (cancelled) {
|
|
6193
|
-
this.#dragState.cancelDrag();
|
|
6194
|
-
}
|
|
6195
|
-
else {
|
|
6196
|
-
this.#dragState.endDrag();
|
|
6197
|
-
}
|
|
6198
7330
|
}
|
|
6199
7331
|
/**
|
|
6200
7332
|
* Get the parent droppable ID.
|
|
@@ -6208,7 +7340,7 @@ class DraggableDirective {
|
|
|
6208
7340
|
return droppable ? this.#positionCalculator.getDroppableId(droppable) : null;
|
|
6209
7341
|
}
|
|
6210
7342
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DraggableDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
6211
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: DraggableDirective, isStandalone: true, selector: "[vdndDraggable]", inputs: { vdndDraggable: { classPropertyName: "vdndDraggable", publicName: "vdndDraggable", isSignal: true, isRequired: true, transformFunction: null }, vdndDraggableGroup: { classPropertyName: "vdndDraggableGroup", publicName: "vdndDraggableGroup", isSignal: true, isRequired: false, transformFunction: null }, vdndDraggableData: { classPropertyName: "vdndDraggableData", publicName: "vdndDraggableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, dragHandle: { classPropertyName: "dragHandle", publicName: "dragHandle", isSignal: true, isRequired: false, transformFunction: null }, dragThreshold: { classPropertyName: "dragThreshold", publicName: "dragThreshold", isSignal: true, isRequired: false, transformFunction: null }, dragDelay: { classPropertyName: "dragDelay", publicName: "dragDelay", isSignal: true, isRequired: false, transformFunction: null }, lockAxis: { classPropertyName: "lockAxis", publicName: "lockAxis", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dragStart: "dragStart", dragEnd: "dragEnd" }, host: { listeners: { "mousedown": "onPointerDown($event, false)"
|
|
7343
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: DraggableDirective, isStandalone: true, selector: "[vdndDraggable]", inputs: { vdndDraggable: { classPropertyName: "vdndDraggable", publicName: "vdndDraggable", isSignal: true, isRequired: true, transformFunction: null }, vdndDraggableGroup: { classPropertyName: "vdndDraggableGroup", publicName: "vdndDraggableGroup", isSignal: true, isRequired: false, transformFunction: null }, vdndDraggableData: { classPropertyName: "vdndDraggableData", publicName: "vdndDraggableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, dragHandle: { classPropertyName: "dragHandle", publicName: "dragHandle", isSignal: true, isRequired: false, transformFunction: null }, dragThreshold: { classPropertyName: "dragThreshold", publicName: "dragThreshold", isSignal: true, isRequired: false, transformFunction: null }, dragDelay: { classPropertyName: "dragDelay", publicName: "dragDelay", isSignal: true, isRequired: false, transformFunction: null }, lockAxis: { classPropertyName: "lockAxis", publicName: "lockAxis", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dragStart: "dragStart", dragEnd: "dragEnd" }, host: { listeners: { "mousedown": "onPointerDown($event, false)" }, properties: { "attr.data-draggable-id": "vdndDraggable()", "class.vdnd-draggable": "true", "class.vdnd-draggable-dragging": "isDragging()", "class.vdnd-draggable-disabled": "disabled()", "class.vdnd-drag-pending": "isPending()", "style.display": "isDragging() ? \"none\" : null", "attr.aria-grabbed": "isDragging() ? \"true\" : \"false\"", "tabindex": "disabled() ? -1 : 0" } }, usesOnChanges: true, ngImport: i0 });
|
|
6212
7344
|
}
|
|
6213
7345
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DraggableDirective, decorators: [{
|
|
6214
7346
|
type: Directive,
|
|
@@ -6224,17 +7356,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
6224
7356
|
'[attr.aria-grabbed]': 'isDragging() ? "true" : "false"',
|
|
6225
7357
|
'[tabindex]': 'disabled() ? -1 : 0',
|
|
6226
7358
|
'(mousedown)': 'onPointerDown($event, false)',
|
|
6227
|
-
'(touchstart)': 'onPointerDown($event, true)',
|
|
6228
|
-
'(keydown.space)': 'onKeyboardActivate($event)',
|
|
6229
|
-
'(keydown.enter)': 'onEnterKey($event)',
|
|
6230
|
-
'(keydown.arrowup)': 'onArrowKey($event)',
|
|
6231
|
-
'(keydown.arrowdown)': 'onArrowKey($event)',
|
|
6232
|
-
'(keydown.arrowleft)': 'onArrowKey($event)',
|
|
6233
|
-
'(keydown.arrowright)': 'onArrowKey($event)',
|
|
6234
|
-
'(keydown.escape)': 'onEscape()',
|
|
6235
7359
|
},
|
|
6236
7360
|
}]
|
|
6237
|
-
}], propDecorators: { vdndDraggable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggable", required: true }] }], vdndDraggableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableGroup", required: false }] }], vdndDraggableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }], dragThreshold: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragThreshold", required: false }] }], dragDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragDelay", required: false }] }], lockAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "lockAxis", required: false }] }], dragStart: [{ type: i0.Output, args: ["dragStart"] }], dragEnd: [{ type: i0.Output, args: ["dragEnd"] }] } });
|
|
7361
|
+
}], ctorParameters: () => [], propDecorators: { vdndDraggable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggable", required: true }] }], vdndDraggableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableGroup", required: false }] }], vdndDraggableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }], dragThreshold: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragThreshold", required: false }] }], dragDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragDelay", required: false }] }], lockAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "lockAxis", required: false }] }], dragStart: [{ type: i0.Output, args: ["dragStart"] }], dragEnd: [{ type: i0.Output, args: ["dragEnd"] }] } });
|
|
6238
7362
|
|
|
6239
7363
|
/**
|
|
6240
7364
|
* Directive that marks an element as a scrollable container for virtual scrolling.
|
|
@@ -6548,38 +7672,47 @@ class VirtualForDirective {
|
|
|
6548
7672
|
const end = Math.min(total - 1, first + visible + overscan);
|
|
6549
7673
|
return { start, end };
|
|
6550
7674
|
}, ...(ngDevMode ? [{ debugName: "#renderRange" }] : []));
|
|
6551
|
-
/**
|
|
6552
|
-
|
|
6553
|
-
|
|
7675
|
+
/**
|
|
7676
|
+
* Every item's track key, in order. Computed once per items change, and only when something
|
|
7677
|
+
* reads it: a dynamic-height strategy or a drag. A fixed-height strategy needs only the count.
|
|
7678
|
+
*/
|
|
7679
|
+
#itemKeys = computed(() => {
|
|
6554
7680
|
const trackByFn = this.vdndVirtualForTrackBy();
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
/** Index of the dragged item in this list (-1 if not present or not dragging) */
|
|
7681
|
+
return this.vdndVirtualForOf().map((item, i) => trackByFn(i, item));
|
|
7682
|
+
}, ...(ngDevMode ? [{ debugName: "#itemKeys" }] : []));
|
|
7683
|
+
/**
|
|
7684
|
+
* Index of the dragged item in this list (-1 if not present or not dragging). The index where
|
|
7685
|
+
* the drag found its item is checked first: it spares a search of every track key.
|
|
7686
|
+
*/
|
|
6562
7687
|
#draggedItemIndex = computed(() => {
|
|
6563
7688
|
const draggedItem = this.#dragState.draggedItem();
|
|
6564
7689
|
if (!draggedItem)
|
|
6565
7690
|
return -1;
|
|
6566
|
-
const
|
|
6567
|
-
|
|
7691
|
+
const items = this.vdndVirtualForOf();
|
|
7692
|
+
const sourceIndex = this.#dragState.sourceIndex();
|
|
7693
|
+
if (sourceIndex !== null && sourceIndex >= 0 && sourceIndex < items.length) {
|
|
7694
|
+
const item = items[sourceIndex];
|
|
7695
|
+
if (this.vdndVirtualForTrackBy()(sourceIndex, item) === draggedItem.draggableId ||
|
|
7696
|
+
(draggedItem.data !== null && draggedItem.data !== undefined && item === draggedItem.data)) {
|
|
7697
|
+
return sourceIndex;
|
|
7698
|
+
}
|
|
7699
|
+
}
|
|
7700
|
+
// The last item with the key, as an index map filled in order would give
|
|
7701
|
+
const byId = this.#itemKeys().lastIndexOf(draggedItem.draggableId);
|
|
7702
|
+
if (byId >= 0) {
|
|
6568
7703
|
return byId;
|
|
6569
7704
|
}
|
|
6570
7705
|
const data = draggedItem.data;
|
|
6571
7706
|
if (data !== null && data !== undefined) {
|
|
6572
|
-
return
|
|
7707
|
+
return items.indexOf(data);
|
|
6573
7708
|
}
|
|
6574
7709
|
return -1;
|
|
6575
7710
|
}, ...(ngDevMode ? [{ debugName: "#draggedItemIndex" }] : []));
|
|
6576
7711
|
constructor() {
|
|
6577
|
-
// Keep strategy
|
|
7712
|
+
// Keep the strategy's items in sync. A fixed-height strategy needs only their count, which
|
|
7713
|
+
// spares computing every item's track key on each items change (each drop).
|
|
6578
7714
|
effect(() => {
|
|
6579
|
-
|
|
6580
|
-
const trackByFn = this.vdndVirtualForTrackBy();
|
|
6581
|
-
const keys = items.map((item, i) => trackByFn(i, item));
|
|
6582
|
-
this.#strategy().setItemKeys(keys);
|
|
7715
|
+
setStrategyItems(this.#strategy(), this.vdndVirtualForOf().length, () => this.#itemKeys());
|
|
6583
7716
|
});
|
|
6584
7717
|
// Register strategy with drag index calculator for accurate position lookups
|
|
6585
7718
|
effect((onCleanup) => {
|
|
@@ -6595,12 +7728,13 @@ class VirtualForDirective {
|
|
|
6595
7728
|
// correctly skip the hidden item — not just findIndexAtOffset.
|
|
6596
7729
|
effect(() => {
|
|
6597
7730
|
const strategy = this.#strategy();
|
|
6598
|
-
const draggedIndex = this.#draggedItemIndex();
|
|
6599
7731
|
const droppableId = this.#effectiveDroppableId();
|
|
6600
7732
|
const sourceDroppableId = this.#dragState.sourceDroppableId();
|
|
6601
7733
|
const isDragging = this.#dragState.isDragging();
|
|
6602
7734
|
const isSourceList = isDragging && droppableId !== undefined && droppableId === sourceDroppableId;
|
|
6603
|
-
|
|
7735
|
+
// Only the source list looks for the dragged item (in all its keys)
|
|
7736
|
+
const draggedIndex = isSourceList ? this.#draggedItemIndex() : -1;
|
|
7737
|
+
if (draggedIndex >= 0) {
|
|
6604
7738
|
strategy.setExcludedIndex(draggedIndex);
|
|
6605
7739
|
}
|
|
6606
7740
|
else {
|
|
@@ -6799,14 +7933,12 @@ class VirtualForDirective {
|
|
|
6799
7933
|
strategy.version();
|
|
6800
7934
|
const placeholderIndex = this.#placeholderIndex();
|
|
6801
7935
|
const showPlaceholder = this.#shouldShowPlaceholder();
|
|
6802
|
-
const draggedIndex = this.#draggedItemIndex();
|
|
6803
7936
|
const droppableId = this.#effectiveDroppableId();
|
|
6804
7937
|
const sourceDroppableId = this.#dragState.sourceDroppableId();
|
|
6805
7938
|
const isSourceList = droppableId ? droppableId === sourceDroppableId : true;
|
|
6806
|
-
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
draggedIndex < items.length;
|
|
7939
|
+
// Only the source list keeps the dragged item rendered, so only it looks for the item
|
|
7940
|
+
const draggedIndex = this.#dragState.isDragging() && isSourceList ? this.#draggedItemIndex() : -1;
|
|
7941
|
+
const shouldKeepDragged = draggedIndex >= 0 && draggedIndex < items.length;
|
|
6810
7942
|
// Snapshot positions before the DOM changes so displaced items can slide
|
|
6811
7943
|
this.#shiftAnimator?.beforeUpdate(this.#dragState.isDragging(), placeholderIndex);
|
|
6812
7944
|
// Notify viewport of render start index for wrapper positioning
|
|
@@ -6916,14 +8048,8 @@ class VirtualForDirective {
|
|
|
6916
8048
|
// Move unused views to pool
|
|
6917
8049
|
for (const [key, view] of this.#activeViews) {
|
|
6918
8050
|
if (!neededKeys.has(key)) {
|
|
6919
|
-
const index = this.#viewContainer.indexOf(view);
|
|
6920
|
-
if (index >= 0) {
|
|
6921
|
-
this.#viewContainer.detach(index);
|
|
6922
|
-
}
|
|
6923
|
-
this.#unobserveViewElements(view);
|
|
6924
|
-
this.#cancelShiftAnimation(view);
|
|
6925
|
-
this.#viewPool.push(view);
|
|
6926
8051
|
this.#activeViews.delete(key);
|
|
8052
|
+
this.#poolView(view);
|
|
6927
8053
|
}
|
|
6928
8054
|
}
|
|
6929
8055
|
// Remove placeholder from DOM if not needed
|
|
@@ -6935,6 +8061,7 @@ class VirtualForDirective {
|
|
|
6935
8061
|
let viewContainerIndex = 0;
|
|
6936
8062
|
let placeholderDomPosition = -1;
|
|
6937
8063
|
const renderedKeys = new Set();
|
|
8064
|
+
const changedViews = [];
|
|
6938
8065
|
for (const entry of itemsToRender) {
|
|
6939
8066
|
if (entry.type === 'placeholder') {
|
|
6940
8067
|
placeholderDomPosition = viewContainerIndex;
|
|
@@ -6945,7 +8072,7 @@ class VirtualForDirective {
|
|
|
6945
8072
|
continue;
|
|
6946
8073
|
}
|
|
6947
8074
|
renderedKeys.add(entry.key);
|
|
6948
|
-
const view = this.#getOrCreateView(entry.key, entry.context);
|
|
8075
|
+
const view = this.#getOrCreateView(entry.key, entry.context, changedViews);
|
|
6949
8076
|
// Ensure view is at correct position in ViewContainerRef
|
|
6950
8077
|
const currentIndex = this.#viewContainer.indexOf(view);
|
|
6951
8078
|
if (currentIndex !== viewContainerIndex) {
|
|
@@ -6970,8 +8097,33 @@ class VirtualForDirective {
|
|
|
6970
8097
|
}
|
|
6971
8098
|
viewContainerIndex++;
|
|
6972
8099
|
}
|
|
8100
|
+
this.#renderChangedViews(changedViews);
|
|
6973
8101
|
return placeholderDomPosition;
|
|
6974
8102
|
}
|
|
8103
|
+
/**
|
|
8104
|
+
* Take a row's view out of the container and pool it. It keeps its element, so its shift
|
|
8105
|
+
* animation is cancelled (the item it renders next must not slide from this one's position),
|
|
8106
|
+
* and a drop animation that hides it shows it again.
|
|
8107
|
+
*/
|
|
8108
|
+
#poolView(view) {
|
|
8109
|
+
const index = this.#viewContainer.indexOf(view);
|
|
8110
|
+
if (index >= 0) {
|
|
8111
|
+
this.#viewContainer.detach(index);
|
|
8112
|
+
}
|
|
8113
|
+
this.#unobserveViewElements(view);
|
|
8114
|
+
// A leave animation (`animate.leave`) keeps the row in the DOM, and Angular removes it when
|
|
8115
|
+
// the animation ends: in the row of another item by then, if the view were reused. A row
|
|
8116
|
+
// whose element is dragged (its draggable ID is not its track key) is destroyed too, which
|
|
8117
|
+
// cancels the drag instead of handing it to another item.
|
|
8118
|
+
const draggedElement = this.#dragState.draggedItem()?.element;
|
|
8119
|
+
if (view.rootNodes.some((node) => node.parentNode !== null || (!!draggedElement && node.contains(draggedElement)))) {
|
|
8120
|
+
view.destroy();
|
|
8121
|
+
return;
|
|
8122
|
+
}
|
|
8123
|
+
this.#cancelShiftAnimation(view);
|
|
8124
|
+
revealDropTargetIn(view.rootNodes);
|
|
8125
|
+
this.#viewPool.push(view);
|
|
8126
|
+
}
|
|
6975
8127
|
/**
|
|
6976
8128
|
* Clamp target indices for move/insert to avoid out-of-range operations when
|
|
6977
8129
|
* reconciliation is given invalid duplicate keys or transiently inconsistent view state.
|
|
@@ -6997,28 +8149,59 @@ class VirtualForDirective {
|
|
|
6997
8149
|
}
|
|
6998
8150
|
/**
|
|
6999
8151
|
* Get an existing view or create/recycle one from the pool.
|
|
8152
|
+
* Adds the view to `changedViews` when it needs rendering: new, taken from the pool, or with a
|
|
8153
|
+
* changed context.
|
|
7000
8154
|
*/
|
|
7001
|
-
#getOrCreateView(key, context) {
|
|
7002
|
-
|
|
8155
|
+
#getOrCreateView(key, context, changedViews) {
|
|
8156
|
+
const activeView = this.#activeViews.get(key);
|
|
8157
|
+
let view = activeView ?? this.#viewPool.pop();
|
|
7003
8158
|
if (view) {
|
|
7004
|
-
//
|
|
7005
|
-
|
|
7006
|
-
view.
|
|
8159
|
+
// A pooled view was detached, so change detection skipped it: render it even when it
|
|
8160
|
+
// comes back with the same context, as what its template shows may have changed since
|
|
8161
|
+
if (this.#updateContext(view.context, context) || !activeView) {
|
|
8162
|
+
changedViews.push(view);
|
|
8163
|
+
}
|
|
7007
8164
|
}
|
|
7008
8165
|
else {
|
|
7009
|
-
|
|
7010
|
-
|
|
7011
|
-
if (view) {
|
|
7012
|
-
Object.assign(view.context, context);
|
|
7013
|
-
view.markForCheck();
|
|
7014
|
-
}
|
|
7015
|
-
else {
|
|
7016
|
-
view = this.#templateRef.createEmbeddedView(context);
|
|
7017
|
-
}
|
|
7018
|
-
this.#activeViews.set(key, view);
|
|
8166
|
+
view = this.#templateRef.createEmbeddedView(context);
|
|
8167
|
+
changedViews.push(view);
|
|
7019
8168
|
}
|
|
8169
|
+
this.#activeViews.set(key, view);
|
|
7020
8170
|
return view;
|
|
7021
8171
|
}
|
|
8172
|
+
/** Copy the fields of `next` into `context`. Returns whether any of them changed. */
|
|
8173
|
+
#updateContext(context, next) {
|
|
8174
|
+
if (Object.is(context.$implicit, next.$implicit) &&
|
|
8175
|
+
context.index === next.index &&
|
|
8176
|
+
context.first === next.first &&
|
|
8177
|
+
context.last === next.last &&
|
|
8178
|
+
context.count === next.count) {
|
|
8179
|
+
return false;
|
|
8180
|
+
}
|
|
8181
|
+
context.$implicit = next.$implicit;
|
|
8182
|
+
context.index = next.index;
|
|
8183
|
+
context.first = next.first;
|
|
8184
|
+
context.last = next.last;
|
|
8185
|
+
context.count = next.count;
|
|
8186
|
+
return true;
|
|
8187
|
+
}
|
|
8188
|
+
/**
|
|
8189
|
+
* Render the views whose context changed, and only those. `markForCheck()` would mark every
|
|
8190
|
+
* ancestor view up to the root as well, re-rendering all of them (and every row of the list)
|
|
8191
|
+
* on each scroll step. Untracked, so the rows' signal reads do not become this effect's
|
|
8192
|
+
* dependencies.
|
|
8193
|
+
*/
|
|
8194
|
+
#renderChangedViews(changedViews) {
|
|
8195
|
+
if (changedViews.length === 0)
|
|
8196
|
+
return;
|
|
8197
|
+
untracked(() => {
|
|
8198
|
+
for (const view of changedViews) {
|
|
8199
|
+
if (!view.destroyed) {
|
|
8200
|
+
view.detectChanges();
|
|
8201
|
+
}
|
|
8202
|
+
}
|
|
8203
|
+
});
|
|
8204
|
+
}
|
|
7022
8205
|
/**
|
|
7023
8206
|
* Apply absolute positioning styles to a view's root nodes.
|
|
7024
8207
|
*/
|