ngx-virtual-dnd 3.2.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -13
- package/fesm2022/ngx-virtual-dnd.mjs +1899 -673
- package/fesm2022/ngx-virtual-dnd.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-virtual-dnd.d.ts +64 -27
|
@@ -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 */
|
|
@@ -521,11 +788,13 @@ class DragStateService {
|
|
|
521
788
|
#placeholderId = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderId" }] : []));
|
|
522
789
|
/** Index where the placeholder should be inserted — updated on each placeholder move */
|
|
523
790
|
#placeholderIndex = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderIndex" }] : []));
|
|
791
|
+
/** Whether the drag in progress is a touch drag (no cursor shows, so no grabbing cursor) */
|
|
792
|
+
#isTouchDrag = signal(false, ...(ngDevMode ? [{ debugName: "#isTouchDrag" }] : []));
|
|
524
793
|
/** Snapshot captured synchronously immediately before the last drag state reset. */
|
|
525
794
|
#endedDragState = signal(null, ...(ngDevMode ? [{ debugName: "#endedDragState" }] : []));
|
|
526
795
|
/** Flag indicating if the last drag was cancelled (not dropped) */
|
|
527
796
|
#wasCancelled = signal(false, ...(ngDevMode ? [{ debugName: "#wasCancelled" }] : []));
|
|
528
|
-
/** Whether the last drag was cancelled (
|
|
797
|
+
/** Whether the last drag was cancelled (a cancelled drag delivers no drop) */
|
|
529
798
|
wasCancelled = this.#wasCancelled.asReadonly();
|
|
530
799
|
/** Snapshot captured synchronously before the last drag reset. */
|
|
531
800
|
endedDragState = this.#endedDragState.asReadonly();
|
|
@@ -562,37 +831,44 @@ class DragStateService {
|
|
|
562
831
|
/** Target index during keyboard navigation */
|
|
563
832
|
keyboardTargetIndex = this.#keyboardTargetIndex.asReadonly();
|
|
564
833
|
constructor() {
|
|
565
|
-
// Inject cursor styles once
|
|
834
|
+
// Inject the grabbing cursor styles once. They target lists and items only: a rule matching
|
|
835
|
+
// every element (`body.vdnd-dragging *`, or a `*` anywhere right of the body class) restyles
|
|
836
|
+
// the whole document each time the body class changes, at drag start and at drop. Touch drags
|
|
837
|
+
// show no cursor, so they skip it.
|
|
566
838
|
if (typeof document !== 'undefined') {
|
|
567
839
|
const styleId = 'vdnd-cursor-styles';
|
|
568
840
|
if (!document.getElementById(styleId)) {
|
|
569
841
|
const style = document.createElement('style');
|
|
570
842
|
style.id = styleId;
|
|
571
843
|
style.textContent = `
|
|
572
|
-
body.vdnd-dragging,
|
|
573
|
-
body.vdnd-dragging
|
|
844
|
+
body.vdnd-dragging:not(.vdnd-dragging-touch) .vdnd-droppable,
|
|
845
|
+
body.vdnd-dragging:not(.vdnd-dragging-touch) .vdnd-draggable {
|
|
574
846
|
cursor: grabbing !important;
|
|
575
847
|
}
|
|
576
848
|
`;
|
|
577
849
|
document.head.appendChild(style);
|
|
578
850
|
}
|
|
579
851
|
}
|
|
580
|
-
// Effect to toggle body
|
|
852
|
+
// Effect to toggle the body classes during drag
|
|
581
853
|
effect(() => {
|
|
582
854
|
if (typeof document === 'undefined')
|
|
583
855
|
return;
|
|
584
856
|
const isDragging = this.isDragging();
|
|
585
|
-
document.body.classList
|
|
857
|
+
const classList = document.body.classList;
|
|
858
|
+
classList.toggle('vdnd-dragging', isDragging);
|
|
859
|
+
classList.toggle('vdnd-dragging-touch', isDragging && this.#isTouchDrag());
|
|
586
860
|
});
|
|
587
861
|
}
|
|
588
862
|
/**
|
|
589
863
|
* Start a drag operation.
|
|
590
864
|
* `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
|
|
865
|
+
* `isTouchDrag` marks a drag started by touch, which gets no grabbing cursor.
|
|
591
866
|
*/
|
|
592
|
-
startDrag(item, cursorPosition, grabOffset, lockAxis, activeDroppableId, placeholderId, placeholderIndex, sourceIndex, isKeyboardDrag, axisLockPosition) {
|
|
867
|
+
startDrag(item, cursorPosition, grabOffset, lockAxis, activeDroppableId, placeholderId, placeholderIndex, sourceIndex, isKeyboardDrag, axisLockPosition, isTouchDrag) {
|
|
593
868
|
// Reset terminal drag metadata at start of new drag
|
|
594
869
|
this.#endedDragState.set(null);
|
|
595
870
|
this.#wasCancelled.set(false);
|
|
871
|
+
this.#isTouchDrag.set(isTouchDrag ?? false);
|
|
596
872
|
this.#cursorPosition.set(cursorPosition ?? null);
|
|
597
873
|
this.#keyboardTargetIndex.set(isKeyboardDrag ? (sourceIndex ?? 0) : null);
|
|
598
874
|
this.#activeDroppableId.set(activeDroppableId ?? null);
|
|
@@ -662,32 +938,49 @@ class DragStateService {
|
|
|
662
938
|
this.#placeholderIndex.set(placeholderIndex);
|
|
663
939
|
}
|
|
664
940
|
/**
|
|
665
|
-
* End the drag operation and reset state (normal drop)
|
|
941
|
+
* End the drag operation and reset state (normal drop), then deliver the drop: the target
|
|
942
|
+
* droppable (`activeDroppableId`) emits `drop` before this returns.
|
|
666
943
|
*/
|
|
667
944
|
endDrag() {
|
|
668
|
-
|
|
945
|
+
// Only a drag in progress ends. Again (e.g. from a drop handler) it would overwrite the
|
|
946
|
+
// ended drag, which the drop animation reads, with an empty one.
|
|
947
|
+
if (!this.#state().isDragging) {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const endedState = this.getStateSnapshot();
|
|
951
|
+
this.#endedDragState.set(endedState);
|
|
669
952
|
this.#wasCancelled.set(false);
|
|
670
|
-
this.#
|
|
953
|
+
this.#resetSignalsOutsideCoreState();
|
|
671
954
|
this.#state.set(INITIAL_CORE_STATE);
|
|
955
|
+
// After the reset, so the drop handler sees the drag as over. Delivered here rather than
|
|
956
|
+
// from a droppable effect on the next change detection: by then the target may already
|
|
957
|
+
// be destroyed or re-created (a drop zone rendered only while dragging, a list replaced
|
|
958
|
+
// by a (dragEnd) handler), and every other droppable would have to tell a new drop from
|
|
959
|
+
// an old one.
|
|
960
|
+
this.#droppableRegistry.deliverDrop(endedState);
|
|
672
961
|
}
|
|
673
962
|
/**
|
|
674
963
|
* Cancel the drag operation (escape key, disabled, etc.).
|
|
675
964
|
*/
|
|
676
965
|
cancelDrag() {
|
|
966
|
+
if (!this.#state().isDragging) {
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
677
969
|
this.#endedDragState.set(this.getStateSnapshot());
|
|
678
970
|
this.#wasCancelled.set(true);
|
|
679
|
-
this.#
|
|
971
|
+
this.#resetSignalsOutsideCoreState();
|
|
680
972
|
this.#state.set(INITIAL_CORE_STATE);
|
|
681
973
|
}
|
|
682
974
|
/**
|
|
683
|
-
* Reset the
|
|
975
|
+
* Reset the signals kept outside the core state (high-frequency fields, touch flag) to idle.
|
|
684
976
|
*/
|
|
685
|
-
#
|
|
977
|
+
#resetSignalsOutsideCoreState() {
|
|
686
978
|
this.#cursorPosition.set(null);
|
|
687
979
|
this.#keyboardTargetIndex.set(null);
|
|
688
980
|
this.#activeDroppableId.set(null);
|
|
689
981
|
this.#placeholderId.set(null);
|
|
690
982
|
this.#placeholderIndex.set(null);
|
|
983
|
+
this.#isTouchDrag.set(false);
|
|
691
984
|
}
|
|
692
985
|
/**
|
|
693
986
|
* Check if a specific droppable is currently active.
|
|
@@ -760,26 +1053,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
760
1053
|
}]
|
|
761
1054
|
}], ctorParameters: () => [] });
|
|
762
1055
|
|
|
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
1056
|
/** The parent lookups stop below `<body>`: it and `<html>` never count as list or item. */
|
|
784
1057
|
function isPageRoot(element) {
|
|
785
1058
|
return element.tagName === 'BODY' || element.tagName === 'HTML';
|
|
@@ -790,6 +1063,7 @@ function isPageRoot(element) {
|
|
|
790
1063
|
*/
|
|
791
1064
|
class PositionCalculatorService {
|
|
792
1065
|
#ngZone = inject(NgZone);
|
|
1066
|
+
#registry = inject(DroppableRegistryService);
|
|
793
1067
|
/** Data attribute used to identify droppable elements */
|
|
794
1068
|
#DROPPABLE_ID_ATTR = 'data-droppable-id';
|
|
795
1069
|
/** Data attribute used to identify droppable groups */
|
|
@@ -802,6 +1076,15 @@ class PositionCalculatorService {
|
|
|
802
1076
|
#nearEdgeResult = { top: false, bottom: false, left: false, right: false };
|
|
803
1077
|
/** Active drag session rect snapshot, or null when no drag is in progress. */
|
|
804
1078
|
#session = null;
|
|
1079
|
+
constructor() {
|
|
1080
|
+
// A droppable (un)registering mid-drag makes the next hit-test re-read the candidates.
|
|
1081
|
+
// Removed on destroy in case the service is provided below root.
|
|
1082
|
+
const removeListener = this.#registry.onChange((group) => this.notifyCandidatesChanged(group));
|
|
1083
|
+
inject(DestroyRef).onDestroy(() => {
|
|
1084
|
+
removeListener();
|
|
1085
|
+
this.endDragSession();
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
805
1088
|
/**
|
|
806
1089
|
* Begin a drag session: snapshot the candidate droppables for `groupName` and
|
|
807
1090
|
* their rects once, then watch for viewport changes (scroll/resize) that would
|
|
@@ -821,7 +1104,7 @@ class PositionCalculatorService {
|
|
|
821
1104
|
this.#session = {
|
|
822
1105
|
groupName,
|
|
823
1106
|
candidates,
|
|
824
|
-
rects:
|
|
1107
|
+
rects: this.#measureRects(candidates, []),
|
|
825
1108
|
dirty: false,
|
|
826
1109
|
candidatesStale: false,
|
|
827
1110
|
onViewportChange,
|
|
@@ -867,10 +1150,11 @@ class PositionCalculatorService {
|
|
|
867
1150
|
}
|
|
868
1151
|
}
|
|
869
1152
|
/**
|
|
870
|
-
* Re-
|
|
871
|
-
* rects. Unlike {@link invalidateDroppableRects} (which only refreshes
|
|
872
|
-
* picks up droppables added or removed since the snapshot was captured at
|
|
873
|
-
*
|
|
1153
|
+
* Re-read the candidate droppable *list* for the active session from the registry and
|
|
1154
|
+
* re-read their rects. Unlike {@link invalidateDroppableRects} (which only refreshes
|
|
1155
|
+
* rects), this picks up droppables added or removed since the snapshot was captured at
|
|
1156
|
+
* drag start. Registration changes already trigger it on the next hit-test; this is the
|
|
1157
|
+
* manual escape hatch.
|
|
874
1158
|
*
|
|
875
1159
|
* Safe to call when no session is active (no-op).
|
|
876
1160
|
*/
|
|
@@ -880,19 +1164,18 @@ class PositionCalculatorService {
|
|
|
880
1164
|
return;
|
|
881
1165
|
}
|
|
882
1166
|
session.candidates = this.#queryDroppables(session.groupName);
|
|
883
|
-
session.rects = session.candidates
|
|
1167
|
+
session.rects = this.#measureRects(session.candidates, []);
|
|
884
1168
|
session.dirty = false;
|
|
885
1169
|
session.candidatesStale = false;
|
|
886
1170
|
this.#observeCandidates(session);
|
|
887
1171
|
}
|
|
888
1172
|
/**
|
|
889
1173
|
* 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.
|
|
1174
|
+
* registered or unregistered mid-drag). Defers the re-read to the next hit-test, keeping
|
|
1175
|
+
* this notification path cheap.
|
|
893
1176
|
*
|
|
894
|
-
* Called
|
|
895
|
-
*
|
|
1177
|
+
* Called for every droppable registry change. No-op when the active session belongs to
|
|
1178
|
+
* a different group or no session is active.
|
|
896
1179
|
*/
|
|
897
1180
|
notifyCandidatesChanged(groupName) {
|
|
898
1181
|
if (this.#session && this.#session.groupName === groupName) {
|
|
@@ -922,23 +1205,20 @@ class PositionCalculatorService {
|
|
|
922
1205
|
this.refreshCandidates();
|
|
923
1206
|
}
|
|
924
1207
|
else if (session.dirty) {
|
|
925
|
-
|
|
926
|
-
session.rects[i] = this.#measureRect(session.candidates[i]);
|
|
927
|
-
}
|
|
1208
|
+
this.#measureRects(session.candidates, session.rects);
|
|
928
1209
|
session.dirty = false;
|
|
929
1210
|
}
|
|
930
1211
|
return this.#hitTest(x, y, session.candidates, session.rects);
|
|
931
1212
|
}
|
|
932
1213
|
// No active session: one-shot geometric query (still avoids elementFromPoint).
|
|
933
1214
|
const candidates = this.#queryDroppables(groupName);
|
|
934
|
-
|
|
935
|
-
return this.#hitTest(x, y, candidates, rects);
|
|
1215
|
+
return this.#hitTest(x, y, candidates, this.#measureRects(candidates, []));
|
|
936
1216
|
}
|
|
937
1217
|
/**
|
|
938
1218
|
* Look up a droppable element by its ID.
|
|
939
1219
|
*
|
|
940
|
-
* When a drag session is active, searches the cached candidate list
|
|
941
|
-
*
|
|
1220
|
+
* When a drag session is active, searches the cached candidate list. Otherwise looks the
|
|
1221
|
+
* ID up in the droppable registry. Neither queries the document.
|
|
942
1222
|
*
|
|
943
1223
|
* Intended for the autoscroll scroll-only fast path, where the active droppable is already
|
|
944
1224
|
* known and only the placeholder index needs recalculation.
|
|
@@ -952,13 +1232,10 @@ class PositionCalculatorService {
|
|
|
952
1232
|
}
|
|
953
1233
|
return null;
|
|
954
1234
|
}
|
|
955
|
-
|
|
956
|
-
return null;
|
|
957
|
-
}
|
|
958
|
-
return queryByAttribute(document, this.#DROPPABLE_ID_ATTR, id);
|
|
1235
|
+
return this.#registry.getById(id);
|
|
959
1236
|
}
|
|
960
1237
|
/**
|
|
961
|
-
*
|
|
1238
|
+
* All rendered droppables belonging to a group, in document order, from the registry.
|
|
962
1239
|
*
|
|
963
1240
|
* All group members are included — the disabled check happens per-frame in
|
|
964
1241
|
* {@link #hitTest} rather than here, because a droppable can be disabled or
|
|
@@ -966,30 +1243,46 @@ class PositionCalculatorService {
|
|
|
966
1243
|
* `(dragStart)` handler, which fires AFTER the candidate snapshot is captured).
|
|
967
1244
|
*/
|
|
968
1245
|
#queryDroppables(groupName) {
|
|
969
|
-
|
|
970
|
-
return [];
|
|
971
|
-
}
|
|
972
|
-
return queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
|
|
1246
|
+
return this.#registry.getGroup(groupName);
|
|
973
1247
|
}
|
|
974
1248
|
/**
|
|
975
|
-
*
|
|
976
|
-
* ancestor
|
|
977
|
-
* still hit-tests over its full unclipped rect (issue #23
|
|
978
|
-
*
|
|
979
|
-
* `#hitTest` bounds check can never match it.
|
|
1249
|
+
* The ancestor that clips a candidate's hit-test rect: its nearest `.vdnd-scrollable`
|
|
1250
|
+
* ancestor, or null when it has none or is one itself. Without clipping a droppable scrolled
|
|
1251
|
+
* mostly out of a clipping container still hit-tests over its full unclipped rect (issue #23
|
|
1252
|
+
* case 3).
|
|
980
1253
|
*/
|
|
981
|
-
#
|
|
982
|
-
const rect = el.getBoundingClientRect();
|
|
1254
|
+
#clipOf(el) {
|
|
983
1255
|
const scrollable = el.closest('.vdnd-scrollable');
|
|
984
|
-
|
|
985
|
-
|
|
1256
|
+
return scrollable === el ? null : scrollable;
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Measure the candidates' hit-test rects into `rects`, each clipped to its clip ancestor (see
|
|
1260
|
+
* `#clipOf`). Consecutive candidates in one scroller (lists side by side in it) read its rect
|
|
1261
|
+
* once. The ancestor is looked up on each pass: a list moved to another scroller mid-drag keeps
|
|
1262
|
+
* its registration. The intersection is built as a plain DOMRect; an empty intersection yields
|
|
1263
|
+
* a negative width/height so the `#hitTest` bounds check can never match it.
|
|
1264
|
+
*/
|
|
1265
|
+
#measureRects(candidates, rects) {
|
|
1266
|
+
let lastClip = null;
|
|
1267
|
+
let lastClipRect = null;
|
|
1268
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
1269
|
+
const rect = candidates[i].getBoundingClientRect();
|
|
1270
|
+
const clip = this.#clipOf(candidates[i]);
|
|
1271
|
+
if (!clip) {
|
|
1272
|
+
rects[i] = rect;
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
if (clip !== lastClip || !lastClipRect) {
|
|
1276
|
+
lastClip = clip;
|
|
1277
|
+
lastClipRect = clip.getBoundingClientRect();
|
|
1278
|
+
}
|
|
1279
|
+
const top = Math.max(rect.top, lastClipRect.top);
|
|
1280
|
+
const left = Math.max(rect.left, lastClipRect.left);
|
|
1281
|
+
const right = Math.min(rect.right, lastClipRect.right);
|
|
1282
|
+
const bottom = Math.min(rect.bottom, lastClipRect.bottom);
|
|
1283
|
+
rects[i] = new DOMRect(left, top, right - left, bottom - top);
|
|
986
1284
|
}
|
|
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);
|
|
1285
|
+
return rects;
|
|
993
1286
|
}
|
|
994
1287
|
/**
|
|
995
1288
|
* Create a ResizeObserver (run outside Angular per CLAUDE.md) that marks the session's
|
|
@@ -1191,21 +1484,20 @@ class PositionCalculatorService {
|
|
|
1191
1484
|
// current container — otherwise a container disabled mid-drag (its own index becomes
|
|
1192
1485
|
// -1) would trap the drag with no reachable neighbour. Disabled droppables are skipped
|
|
1193
1486
|
// as *targets* during the outward scan below instead.
|
|
1194
|
-
const allDroppables =
|
|
1487
|
+
const allDroppables = this.#registry.getGroup(groupName);
|
|
1195
1488
|
if (allDroppables.length <= 1) {
|
|
1196
1489
|
return null;
|
|
1197
1490
|
}
|
|
1198
1491
|
// Get bounding rects, IDs, and disabled state, sorted by X position
|
|
1199
1492
|
const droppableInfos = [];
|
|
1200
1493
|
allDroppables.forEach((el) => {
|
|
1201
|
-
const
|
|
1202
|
-
const id = htmlEl.getAttribute(this.#DROPPABLE_ID_ATTR);
|
|
1494
|
+
const id = el.getAttribute(this.#DROPPABLE_ID_ATTR);
|
|
1203
1495
|
if (id) {
|
|
1204
1496
|
droppableInfos.push({
|
|
1205
|
-
element:
|
|
1497
|
+
element: el,
|
|
1206
1498
|
id,
|
|
1207
|
-
rect:
|
|
1208
|
-
disabled: this.#isDroppableDisabled(
|
|
1499
|
+
rect: el.getBoundingClientRect(),
|
|
1500
|
+
disabled: this.#isDroppableDisabled(el),
|
|
1209
1501
|
});
|
|
1210
1502
|
}
|
|
1211
1503
|
});
|
|
@@ -1249,7 +1541,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
1249
1541
|
args: [{
|
|
1250
1542
|
providedIn: 'root',
|
|
1251
1543
|
}]
|
|
1252
|
-
}] });
|
|
1544
|
+
}], ctorParameters: () => [] });
|
|
1253
1545
|
|
|
1254
1546
|
/**
|
|
1255
1547
|
* Single RAF loop that coordinates all drag-frame work in ordered phases:
|
|
@@ -1262,6 +1554,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
1262
1554
|
* latest cursor and a `cursorDirty` flag. Callers skip their hit-test when
|
|
1263
1555
|
* the flag is false, so autoscroll-only frames (cursor stationary) incur
|
|
1264
1556
|
* zero hit-test work.
|
|
1557
|
+
* 3. Frame writer phase (WRITE): registered writers apply the state computed this
|
|
1558
|
+
* frame to the DOM. DragPreviewComponent writes the preview transform here, so
|
|
1559
|
+
* the preview moves in the frame that processed the pointer instead of waiting
|
|
1560
|
+
* for a change detection tick, which Angular schedules after this frame.
|
|
1265
1561
|
*
|
|
1266
1562
|
* PointerDragHandler calls `queueCursorUpdate()` instead of managing its own
|
|
1267
1563
|
* RAF per pointermove. This coalesces all moves within a frame into one update
|
|
@@ -1277,6 +1573,8 @@ class DragSchedulerService {
|
|
|
1277
1573
|
#cursorDirty = false;
|
|
1278
1574
|
/** Tick participants called before the main compute phase each frame. */
|
|
1279
1575
|
#participants = [];
|
|
1576
|
+
/** Frame writers called after the main compute phase each frame. */
|
|
1577
|
+
#frameWriters = [];
|
|
1280
1578
|
/**
|
|
1281
1579
|
* Start the scheduler RAF loop.
|
|
1282
1580
|
* No-op if already running.
|
|
@@ -1333,6 +1631,26 @@ class DragSchedulerService {
|
|
|
1333
1631
|
this.#participants.splice(idx, 1);
|
|
1334
1632
|
}
|
|
1335
1633
|
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Register a function to be called at the end of each frame, after the main tick.
|
|
1636
|
+
* DragPreviewComponent registers here to move the preview within the same frame.
|
|
1637
|
+
* No-op if the same function reference is already registered.
|
|
1638
|
+
*/
|
|
1639
|
+
addFrameWriter(fn) {
|
|
1640
|
+
if (!this.#frameWriters.includes(fn)) {
|
|
1641
|
+
this.#frameWriters.push(fn);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Unregister a previously added frame writer.
|
|
1646
|
+
* No-op if the function was not registered.
|
|
1647
|
+
*/
|
|
1648
|
+
removeFrameWriter(fn) {
|
|
1649
|
+
const idx = this.#frameWriters.indexOf(fn);
|
|
1650
|
+
if (idx !== -1) {
|
|
1651
|
+
this.#frameWriters.splice(idx, 1);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1336
1654
|
#scheduleNextTick() {
|
|
1337
1655
|
this.#rafId = requestAnimationFrame(() => this.#tick());
|
|
1338
1656
|
}
|
|
@@ -1360,7 +1678,15 @@ class DragSchedulerService {
|
|
|
1360
1678
|
}
|
|
1361
1679
|
// Phase 2 — main compute (cursor-based hit-test + signal write)
|
|
1362
1680
|
onTick(this.#pendingCursor, cursorDirty);
|
|
1363
|
-
//
|
|
1681
|
+
// stop() may have been called by onTick (the drag ended): nothing left to write.
|
|
1682
|
+
if (this.#onTick === null) {
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
// Phase 3 — frame writers (apply this frame's state to the DOM)
|
|
1686
|
+
for (const writer of this.#frameWriters) {
|
|
1687
|
+
writer();
|
|
1688
|
+
}
|
|
1689
|
+
// Reschedule only if still active (a writer is not expected to stop it, but may).
|
|
1364
1690
|
if (this.#onTick !== null) {
|
|
1365
1691
|
this.#scheduleNextTick();
|
|
1366
1692
|
}
|
|
@@ -1411,6 +1737,14 @@ class AutoScrollService {
|
|
|
1411
1737
|
/** Last tick cursor position for stationary detection */
|
|
1412
1738
|
#lastTickCursorX = NaN;
|
|
1413
1739
|
#lastTickCursorY = NaN;
|
|
1740
|
+
/**
|
|
1741
|
+
* The containers the last tick that ran asked to scroll but had no room left: the first
|
|
1742
|
+
* #exhaustedCount entries (the objects are reused from tick to tick). A resting cursor skips the
|
|
1743
|
+
* tick only while they stay as they were on the axes it asked for: content that grows (rows
|
|
1744
|
+
* measured taller, items added), a resize or a scroll can give one room, and the scroll resumes.
|
|
1745
|
+
*/
|
|
1746
|
+
#exhausted = [];
|
|
1747
|
+
#exhaustedCount = 0;
|
|
1414
1748
|
/**
|
|
1415
1749
|
* Last timestamp used to scale per-frame autoscroll distance by elapsed frame time.
|
|
1416
1750
|
* Scale is intentionally floored at 1 to preserve the existing feel on 60fps+ displays while
|
|
@@ -1514,6 +1848,8 @@ class AutoScrollService {
|
|
|
1514
1848
|
this.#cursorOverride = null;
|
|
1515
1849
|
this.#lastTickCursorX = NaN;
|
|
1516
1850
|
this.#lastTickCursorY = NaN;
|
|
1851
|
+
this.#exhausted.length = 0;
|
|
1852
|
+
this.#exhaustedCount = 0;
|
|
1517
1853
|
this.#lastScrollTimestamp = 0;
|
|
1518
1854
|
this.#scrollState.containerId = null;
|
|
1519
1855
|
this.#scrollState.direction.x = 0;
|
|
@@ -1542,14 +1878,17 @@ class AutoScrollService {
|
|
|
1542
1878
|
if (!cursor) {
|
|
1543
1879
|
return;
|
|
1544
1880
|
}
|
|
1545
|
-
// Skip the container iteration when cursor hasn't moved and scroll is idle
|
|
1881
|
+
// Skip the container iteration when the cursor hasn't moved and scroll is idle, unless a
|
|
1882
|
+
// container it asked to scroll without room has changed since (see #exhausted).
|
|
1546
1883
|
if (cursor.x === this.#lastTickCursorX &&
|
|
1547
1884
|
cursor.y === this.#lastTickCursorY &&
|
|
1548
|
-
!this.isScrolling()
|
|
1885
|
+
!this.isScrolling() &&
|
|
1886
|
+
!this.#exhaustedChanged()) {
|
|
1549
1887
|
return;
|
|
1550
1888
|
}
|
|
1551
1889
|
this.#lastTickCursorX = cursor.x;
|
|
1552
1890
|
this.#lastTickCursorY = cursor.y;
|
|
1891
|
+
this.#exhaustedCount = 0;
|
|
1553
1892
|
let scrollPerformed = false;
|
|
1554
1893
|
// Frame scale is shared across candidates within a tick; computed lazily on
|
|
1555
1894
|
// the first scroll attempt so stationary/idle frames don't advance the clock.
|
|
@@ -1609,8 +1948,19 @@ class AutoScrollService {
|
|
|
1609
1948
|
this.#scrollState.direction.y = direction.y;
|
|
1610
1949
|
this.#scrollState.speed = speed;
|
|
1611
1950
|
scrollPerformed = true;
|
|
1951
|
+
// Notify callback IMMEDIATELY in the same frame (no RAF delay)
|
|
1952
|
+
// Delaying via RAF causes cumulative drift during continuous autoscroll
|
|
1953
|
+
// because multiple scrolls happen before each delayed callback runs.
|
|
1954
|
+
// Note: No ngZone.run() needed here - the callback (DraggableDirective.#recalculatePlaceholder)
|
|
1955
|
+
// updates signals and plain fields, and enters the zone itself to emit dragEnd if it ends
|
|
1956
|
+
// the drag.
|
|
1957
|
+
// It runs after the scroll state is recorded: it may end the drag, and the reset of
|
|
1958
|
+
// stopMonitoring() must not be overwritten.
|
|
1959
|
+
this.#onScrollCallback?.();
|
|
1612
1960
|
break;
|
|
1613
1961
|
}
|
|
1962
|
+
// No room in any requested direction: a resting cursor retries it once it changes
|
|
1963
|
+
this.#recordExhausted(element, direction);
|
|
1614
1964
|
}
|
|
1615
1965
|
}
|
|
1616
1966
|
if (!scrollPerformed) {
|
|
@@ -1670,16 +2020,46 @@ class AutoScrollService {
|
|
|
1670
2020
|
}
|
|
1671
2021
|
}
|
|
1672
2022
|
}
|
|
1673
|
-
|
|
1674
|
-
|
|
2023
|
+
return scrolled;
|
|
2024
|
+
}
|
|
2025
|
+
/** Record a container that had no room in `direction` (see #exhausted). */
|
|
2026
|
+
#recordExhausted(element, direction) {
|
|
2027
|
+
const exhausted = (this.#exhausted[this.#exhaustedCount] ??= {
|
|
2028
|
+
element,
|
|
2029
|
+
vertical: false,
|
|
2030
|
+
horizontal: false,
|
|
2031
|
+
scrollTop: 0,
|
|
2032
|
+
scrollLeft: 0,
|
|
2033
|
+
maxScrollTop: 0,
|
|
2034
|
+
maxScrollLeft: 0,
|
|
2035
|
+
});
|
|
2036
|
+
exhausted.element = element;
|
|
2037
|
+
exhausted.vertical = direction.y !== 0;
|
|
2038
|
+
exhausted.horizontal = direction.x !== 0;
|
|
2039
|
+
exhausted.scrollTop = element.scrollTop;
|
|
2040
|
+
exhausted.scrollLeft = element.scrollLeft;
|
|
2041
|
+
exhausted.maxScrollTop = element.scrollHeight - element.clientHeight;
|
|
2042
|
+
exhausted.maxScrollLeft = element.scrollWidth - element.clientWidth;
|
|
2043
|
+
this.#exhaustedCount++;
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Whether a container in #exhausted has scrolled or changed size on an axis it was asked to
|
|
2047
|
+
* scroll, so it may have room now.
|
|
2048
|
+
*/
|
|
2049
|
+
#exhaustedChanged() {
|
|
2050
|
+
for (let i = 0; i < this.#exhaustedCount; i++) {
|
|
2051
|
+
const exhausted = this.#exhausted[i];
|
|
2052
|
+
const { element } = exhausted;
|
|
2053
|
+
if ((exhausted.vertical &&
|
|
2054
|
+
(element.scrollTop !== exhausted.scrollTop ||
|
|
2055
|
+
element.scrollHeight - element.clientHeight !== exhausted.maxScrollTop)) ||
|
|
2056
|
+
(exhausted.horizontal &&
|
|
2057
|
+
(element.scrollLeft !== exhausted.scrollLeft ||
|
|
2058
|
+
element.scrollWidth - element.clientWidth !== exhausted.maxScrollLeft))) {
|
|
2059
|
+
return true;
|
|
2060
|
+
}
|
|
1675
2061
|
}
|
|
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;
|
|
2062
|
+
return false;
|
|
1683
2063
|
}
|
|
1684
2064
|
#getFrameScale() {
|
|
1685
2065
|
const now = performance.now();
|
|
@@ -1720,20 +2100,19 @@ class ElementCloneService {
|
|
|
1720
2100
|
* These are the visual properties that affect appearance. Keeping them
|
|
1721
2101
|
* kebab-case avoids a camelCase→kebab conversion on every property of every
|
|
1722
2102
|
* node during the recursive clone walk (a drag-start hot path).
|
|
2103
|
+
*
|
|
2104
|
+
* A shorthand carries more than the longhands listed with it (`background-size`,
|
|
2105
|
+
* `font-style`, …), so it is copied first, and the longhands only when the engine can't
|
|
2106
|
+
* serialize the shorthand (it gives '' for `font` with `font-variant-ligatures: none`, for
|
|
2107
|
+
* example). Copying both would read and write the same values twice.
|
|
1723
2108
|
*/
|
|
1724
|
-
#
|
|
1725
|
-
'background',
|
|
1726
|
-
'background-color',
|
|
1727
|
-
'background-image',
|
|
2109
|
+
#stylesToCopy = [
|
|
2110
|
+
['background', ['background-color', 'background-image']],
|
|
1728
2111
|
'border',
|
|
1729
2112
|
'border-radius',
|
|
1730
2113
|
'box-shadow',
|
|
1731
2114
|
'color',
|
|
1732
|
-
'font',
|
|
1733
|
-
'font-family',
|
|
1734
|
-
'font-size',
|
|
1735
|
-
'font-weight',
|
|
1736
|
-
'line-height',
|
|
2115
|
+
['font', ['font-family', 'font-size', 'font-weight', 'line-height']],
|
|
1737
2116
|
'padding',
|
|
1738
2117
|
'margin',
|
|
1739
2118
|
'display',
|
|
@@ -1775,10 +2154,22 @@ class ElementCloneService {
|
|
|
1775
2154
|
#applyComputedStyles(source, target) {
|
|
1776
2155
|
const computed = window.getComputedStyle(source);
|
|
1777
2156
|
// Copy essential visual properties (keys are already kebab-case)
|
|
1778
|
-
for (const
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
2157
|
+
for (const style of this.#stylesToCopy) {
|
|
2158
|
+
if (typeof style === 'string') {
|
|
2159
|
+
this.#copyStyle(computed, target, style);
|
|
2160
|
+
continue;
|
|
2161
|
+
}
|
|
2162
|
+
// The longhands only when the shorthand was not copied: the engine gave no value, or the
|
|
2163
|
+
// clone rejected it. A shorthand sets all its longhands, so reading one back tells (the
|
|
2164
|
+
// shorthand itself would be serialized again to be read). The clone may carry that
|
|
2165
|
+
// longhand inline from the source, so it goes first.
|
|
2166
|
+
const [shorthand, longhands] = style;
|
|
2167
|
+
target.style.removeProperty(longhands[0]);
|
|
2168
|
+
if (!this.#copyStyle(computed, target, shorthand) ||
|
|
2169
|
+
!target.style.getPropertyValue(longhands[0])) {
|
|
2170
|
+
for (const longhand of longhands) {
|
|
2171
|
+
this.#copyStyle(computed, target, longhand);
|
|
2172
|
+
}
|
|
1782
2173
|
}
|
|
1783
2174
|
}
|
|
1784
2175
|
// Disable animations and transitions on clone
|
|
@@ -1795,6 +2186,18 @@ class ElementCloneService {
|
|
|
1795
2186
|
}
|
|
1796
2187
|
}
|
|
1797
2188
|
}
|
|
2189
|
+
/**
|
|
2190
|
+
* Copy one computed property to the target's inline style. Returns whether the engine gave a
|
|
2191
|
+
* value for it.
|
|
2192
|
+
*/
|
|
2193
|
+
#copyStyle(computed, target, property) {
|
|
2194
|
+
const value = computed.getPropertyValue(property);
|
|
2195
|
+
if (!value) {
|
|
2196
|
+
return false;
|
|
2197
|
+
}
|
|
2198
|
+
target.style.setProperty(property, value);
|
|
2199
|
+
return true;
|
|
2200
|
+
}
|
|
1798
2201
|
/**
|
|
1799
2202
|
* Handle special elements that require extra processing.
|
|
1800
2203
|
*/
|
|
@@ -2149,9 +2552,7 @@ class DragIndexCalculatorService {
|
|
|
2149
2552
|
return cached;
|
|
2150
2553
|
const isViewport = droppableElement.hasAttribute('data-virtual-viewport');
|
|
2151
2554
|
const virtualScrollElement = droppableElement.querySelector('vdnd-virtual-scroll');
|
|
2152
|
-
const virtualContentElement = (droppableElement
|
|
2153
|
-
? droppableElement
|
|
2154
|
-
: droppableElement.closest('vdnd-virtual-content'));
|
|
2555
|
+
const virtualContentElement = this.#findVirtualContent(droppableElement);
|
|
2155
2556
|
let containerType;
|
|
2156
2557
|
let scrollContainer;
|
|
2157
2558
|
let scrollableParent = null;
|
|
@@ -2198,7 +2599,7 @@ class DragIndexCalculatorService {
|
|
|
2198
2599
|
return entry;
|
|
2199
2600
|
}
|
|
2200
2601
|
getTotalItemCount(args) {
|
|
2201
|
-
return this.#getTotalItemCount(args.droppableElement, args.
|
|
2602
|
+
return this.#getTotalItemCount(args.droppableElement, args.draggedItemHeight);
|
|
2202
2603
|
}
|
|
2203
2604
|
/**
|
|
2204
2605
|
* Live scroll geometry of a droppable's rows (see {@link DroppableScrollGeometry}). The same
|
|
@@ -2270,7 +2671,7 @@ class DragIndexCalculatorService {
|
|
|
2270
2671
|
totalItems = Math.max(0, cachedItemCount);
|
|
2271
2672
|
}
|
|
2272
2673
|
else {
|
|
2273
|
-
totalItems = this.#getTotalItemCount(droppableElement,
|
|
2674
|
+
totalItems = this.#getTotalItemCount(droppableElement, draggedItemHeight, cache);
|
|
2274
2675
|
}
|
|
2275
2676
|
// Edge detection: allow dropping at the END of the list when cursor is near bottom edge.
|
|
2276
2677
|
// Due to max scroll limits, the math alone can't reach totalItems when the list is longer
|
|
@@ -2329,7 +2730,12 @@ class DragIndexCalculatorService {
|
|
|
2329
2730
|
const offset = parseFloat(element?.getAttribute('data-content-offset') ?? '');
|
|
2330
2731
|
return Number.isFinite(offset) ? offset : 0;
|
|
2331
2732
|
}
|
|
2332
|
-
|
|
2733
|
+
/**
|
|
2734
|
+
* The number of items in a droppable. With `cache`, the drag's resolved metadata, the virtual
|
|
2735
|
+
* containers it knows are not looked up in the DOM again: this runs on every frame of a drag
|
|
2736
|
+
* over a list without a registered strategy.
|
|
2737
|
+
*/
|
|
2738
|
+
#getTotalItemCount(droppableElement, draggedItemHeight, cache) {
|
|
2333
2739
|
// Check if a registered strategy exists
|
|
2334
2740
|
const droppableId = this.#positionCalculator.getDroppableId(droppableElement);
|
|
2335
2741
|
const strategy = droppableId ? this.#strategies.get(droppableId) : null;
|
|
@@ -2338,7 +2744,9 @@ class DragIndexCalculatorService {
|
|
|
2338
2744
|
return Math.max(0, strategyItemCount);
|
|
2339
2745
|
}
|
|
2340
2746
|
// Check for embedded virtual scroll component
|
|
2341
|
-
const virtualScroll =
|
|
2747
|
+
const virtualScroll = cache
|
|
2748
|
+
? cache.virtualScrollElement
|
|
2749
|
+
: droppableElement.querySelector('vdnd-virtual-scroll');
|
|
2342
2750
|
if (virtualScroll) {
|
|
2343
2751
|
// Use data-total-items attribute if available (always the true N)
|
|
2344
2752
|
const totalItemsAttr = virtualScroll.getAttribute('data-total-items');
|
|
@@ -2375,9 +2783,9 @@ class DragIndexCalculatorService {
|
|
|
2375
2783
|
return count;
|
|
2376
2784
|
}
|
|
2377
2785
|
// Check for page-level scroll (vdnd-virtual-content)
|
|
2378
|
-
const virtualContent =
|
|
2379
|
-
?
|
|
2380
|
-
: droppableElement
|
|
2786
|
+
const virtualContent = cache
|
|
2787
|
+
? cache.virtualContentElement
|
|
2788
|
+
: this.#findVirtualContent(droppableElement);
|
|
2381
2789
|
if (virtualContent) {
|
|
2382
2790
|
// Prefer explicit total items attribute (works for both fixed and dynamic heights)
|
|
2383
2791
|
const totalItemsAttr = virtualContent.getAttribute('data-total-items');
|
|
@@ -2404,6 +2812,12 @@ class DragIndexCalculatorService {
|
|
|
2404
2812
|
const items = droppableElement.querySelectorAll('[data-draggable-id]');
|
|
2405
2813
|
return items.length;
|
|
2406
2814
|
}
|
|
2815
|
+
/** The `vdnd-virtual-content` the droppable is or is inside (page-level scroll), if any. */
|
|
2816
|
+
#findVirtualContent(droppableElement) {
|
|
2817
|
+
return droppableElement.matches('vdnd-virtual-content')
|
|
2818
|
+
? droppableElement
|
|
2819
|
+
: droppableElement.closest('vdnd-virtual-content');
|
|
2820
|
+
}
|
|
2407
2821
|
#getDraggedItemHeightFallback(height, fallback) {
|
|
2408
2822
|
return Number.isFinite(height) && height > 0 ? height : fallback;
|
|
2409
2823
|
}
|
|
@@ -2547,6 +2961,66 @@ function createAutoScrollRegistration(options) {
|
|
|
2547
2961
|
destroyRef.onDestroy(unregisterCurrent);
|
|
2548
2962
|
}
|
|
2549
2963
|
|
|
2964
|
+
/**
|
|
2965
|
+
* Give a list's strategy its items: `count` of them, with the track keys `keys` returns (the
|
|
2966
|
+
* list's own array, which it goes on reading).
|
|
2967
|
+
*
|
|
2968
|
+
* - FixedHeightStrategy's `setItemKeys` only counts the keys: it gets the count, and the keys are
|
|
2969
|
+
* never computed.
|
|
2970
|
+
* - DynamicHeightStrategy's copies the keys: it gets the list's array.
|
|
2971
|
+
* - Any other `setItemKeys` (a strategy of the consumer's own, or a subclass that overrides it)
|
|
2972
|
+
* gets a copy, which it may keep and change.
|
|
2973
|
+
* @internal
|
|
2974
|
+
*/
|
|
2975
|
+
function setStrategyItems(strategy, count, keys) {
|
|
2976
|
+
if (strategy instanceof FixedHeightStrategy &&
|
|
2977
|
+
strategy.setItemKeys === FixedHeightStrategy.prototype.setItemKeys) {
|
|
2978
|
+
strategy.setItemCount(count);
|
|
2979
|
+
}
|
|
2980
|
+
else if (strategy instanceof DynamicHeightStrategy &&
|
|
2981
|
+
strategy.setItemKeys === DynamicHeightStrategy.prototype.setItemKeys) {
|
|
2982
|
+
strategy.setItemKeys(keys());
|
|
2983
|
+
}
|
|
2984
|
+
else {
|
|
2985
|
+
strategy.setItemKeys(keys().slice());
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/**
|
|
2990
|
+
* Query by an exact attribute value without interpolating consumer data into a CSS selector.
|
|
2991
|
+
*
|
|
2992
|
+
* CSS attribute selectors need string-specific escaping that differs from `CSS.escape()`.
|
|
2993
|
+
* Filtering candidates by attribute presence avoids selector syntax errors for IDs containing
|
|
2994
|
+
* quotes, brackets, backslashes, or other selector-sensitive characters.
|
|
2995
|
+
*/
|
|
2996
|
+
function queryByAttribute(root, attributeName, attributeValue) {
|
|
2997
|
+
const candidates = root.querySelectorAll(`[${attributeName}]`);
|
|
2998
|
+
for (const candidate of candidates) {
|
|
2999
|
+
if (candidate.getAttribute(attributeName) === attributeValue) {
|
|
3000
|
+
return candidate;
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
return null;
|
|
3004
|
+
}
|
|
3005
|
+
/**
|
|
3006
|
+
* The elements carrying an attribute, by its value (the first in document order when several
|
|
3007
|
+
* share one, as `queryByAttribute` finds). One query for many lookups, where `queryByAttribute`
|
|
3008
|
+
* scans every candidate on each call.
|
|
3009
|
+
*/
|
|
3010
|
+
function mapByAttribute(root, attributeName) {
|
|
3011
|
+
const elements = new Map();
|
|
3012
|
+
for (const candidate of root.querySelectorAll(`[${attributeName}]`)) {
|
|
3013
|
+
const value = candidate.getAttribute(attributeName);
|
|
3014
|
+
if (!elements.has(value)) {
|
|
3015
|
+
elements.set(value, candidate);
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
return elements;
|
|
3019
|
+
}
|
|
3020
|
+
function queryAllByAttribute(root, attributeName, attributeValue) {
|
|
3021
|
+
return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
|
|
3022
|
+
}
|
|
3023
|
+
|
|
2550
3024
|
/** Layout changes smaller than this are ignored (sub-pixel rounding noise). */
|
|
2551
3025
|
const MIN_SHIFT_PX = 0.5;
|
|
2552
3026
|
/**
|
|
@@ -2554,8 +3028,15 @@ const MIN_SHIFT_PX = 0.5;
|
|
|
2554
3028
|
*
|
|
2555
3029
|
* Call `beforeUpdate()` before the DOM reflects a new drag/placeholder state. When the
|
|
2556
3030
|
* placeholder moved (or the drag ended), it snapshots every rendered element's visual position, then after
|
|
2557
|
-
* the render measures again and plays a
|
|
2558
|
-
*
|
|
3031
|
+
* the render measures again and plays a `translate` animation from the old position to the
|
|
3032
|
+
* new one.
|
|
3033
|
+
*
|
|
3034
|
+
* The slide animates the individual `translate` property with a replace effect, so it runs
|
|
3035
|
+
* on the compositor (Chromium never composites a `composite: 'add'` keyframe effect) and
|
|
3036
|
+
* composes with any `transform` the consumer sets on the row. `translate` applies before
|
|
3037
|
+
* `transform`, in the parent's coordinate space, where the deltas are measured. A row
|
|
3038
|
+
* that sets its own `translate` gets an additive effect instead, which keeps that value
|
|
3039
|
+
* but is ticked on the main thread.
|
|
2559
3040
|
*
|
|
2560
3041
|
* Positions are measured relative to the scroll content, so scrolling (including
|
|
2561
3042
|
* programmatic scroll in the same tick) never animates. Measurements include any
|
|
@@ -2692,36 +3173,51 @@ class ShiftAnimator {
|
|
|
2692
3173
|
}
|
|
2693
3174
|
// Both measurements include the in-flight offset, so the new animation must
|
|
2694
3175
|
// start from that offset plus the layout delta to continue from the current spot.
|
|
2695
|
-
const
|
|
2696
|
-
|
|
3176
|
+
const active = this.#activeShift(element);
|
|
3177
|
+
const remaining = active ? 1 - active.progress : 0;
|
|
3178
|
+
shifts.push({
|
|
3179
|
+
element,
|
|
3180
|
+
x: dx + (active?.shift.x ?? 0) * remaining,
|
|
3181
|
+
y: dy + (active?.shift.y ?? 0) * remaining,
|
|
3182
|
+
// While a slide applies, computed style includes it: keep the decision it was made with.
|
|
3183
|
+
additive: active?.shift.additive ?? this.#hasOwnTranslate(element),
|
|
3184
|
+
});
|
|
2697
3185
|
}
|
|
2698
3186
|
// Write phase
|
|
2699
3187
|
for (const element of stale) {
|
|
2700
3188
|
this.cancel(element);
|
|
2701
3189
|
}
|
|
2702
3190
|
for (const shift of shifts) {
|
|
2703
|
-
this.#start(shift
|
|
3191
|
+
this.#start(shift);
|
|
2704
3192
|
}
|
|
2705
3193
|
}
|
|
2706
|
-
|
|
2707
|
-
|
|
3194
|
+
/**
|
|
3195
|
+
* Whether the row sets its own `translate` (which a replace effect would hide while it
|
|
3196
|
+
* runs). Only read while no slide runs on the element: the computed value would include it.
|
|
3197
|
+
*/
|
|
3198
|
+
#hasOwnTranslate(element) {
|
|
3199
|
+
const translate = getComputedStyle(element).translate;
|
|
3200
|
+
return !!translate && translate !== 'none';
|
|
3201
|
+
}
|
|
3202
|
+
/** The element's running slide and its progress, or null when none is in effect. */
|
|
3203
|
+
#activeShift(element) {
|
|
3204
|
+
const shift = this.#running.get(element);
|
|
2708
3205
|
// Effect-level easing makes `progress` the eased progress, i.e. the fraction travelled.
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
3206
|
+
// It is null once the animation was cancelled or finished (even before `onfinish` ran).
|
|
3207
|
+
const progress = shift?.animation.effect?.getComputedTiming().progress;
|
|
3208
|
+
if (!shift || progress === null || progress === undefined) {
|
|
3209
|
+
return null;
|
|
2712
3210
|
}
|
|
2713
|
-
|
|
2714
|
-
return { x: running.x * remaining, y: running.y * remaining };
|
|
3211
|
+
return { shift, progress };
|
|
2715
3212
|
}
|
|
2716
|
-
#start(element, x, y) {
|
|
3213
|
+
#start({ element, x, y, additive }) {
|
|
2717
3214
|
this.#running.get(element)?.animation.cancel();
|
|
2718
|
-
const animation = element.animate([{
|
|
3215
|
+
const animation = element.animate([{ translate: `${x}px ${y}px` }, { translate: '0px 0px' }], {
|
|
2719
3216
|
duration: this.#duration(),
|
|
2720
3217
|
easing: this.#config.shiftEasing ?? DEFAULT_SHIFT_EASING,
|
|
2721
|
-
|
|
2722
|
-
composite: 'add',
|
|
3218
|
+
composite: additive ? 'add' : 'replace',
|
|
2723
3219
|
});
|
|
2724
|
-
this.#running.set(element, { animation, x, y });
|
|
3220
|
+
this.#running.set(element, { animation, x, y, additive });
|
|
2725
3221
|
animation.onfinish = () => {
|
|
2726
3222
|
if (this.#running.get(element)?.animation === animation) {
|
|
2727
3223
|
this.#running.delete(element);
|
|
@@ -2730,25 +3226,227 @@ class ShiftAnimator {
|
|
|
2730
3226
|
}
|
|
2731
3227
|
}
|
|
2732
3228
|
|
|
3229
|
+
/** The elements drop animations hide, with the animation hiding each */
|
|
3230
|
+
const hiddenTargets = new Map();
|
|
2733
3231
|
/**
|
|
2734
|
-
*
|
|
2735
|
-
*
|
|
2736
|
-
*
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
3232
|
+
* Show again an element a drop animation hides, if one of these nodes holds it. A virtual list
|
|
3233
|
+
* calls it for the row it pools: that row's element renders another item next.
|
|
3234
|
+
* @internal
|
|
3235
|
+
*/
|
|
3236
|
+
function revealDropTargetIn(nodes) {
|
|
3237
|
+
for (const [element, animation] of hiddenTargets) {
|
|
3238
|
+
if (nodes.some((node) => node.contains(element))) {
|
|
3239
|
+
hiddenTargets.delete(element);
|
|
3240
|
+
animation.cancel();
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
/**
|
|
3245
|
+
* Plays the drop ("settle") animation: the drag preview glides from where it was released
|
|
3246
|
+
* into the dropped item's final position, while that item stays invisible underneath.
|
|
2743
3247
|
*
|
|
2744
|
-
*
|
|
2745
|
-
*
|
|
2746
|
-
*
|
|
2747
|
-
*
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
3248
|
+
* Purely visual. The drag state and the drop events are already final when it starts, so it
|
|
3249
|
+
* never delays or changes a drop. Without a target (the item is not rendered, or scrolled out
|
|
3250
|
+
* of view) the ghost fades out where it was released.
|
|
3251
|
+
* @internal
|
|
3252
|
+
*/
|
|
3253
|
+
class DropAnimator {
|
|
3254
|
+
#config;
|
|
3255
|
+
#ghostAnimation = null;
|
|
3256
|
+
#hidden = null;
|
|
3257
|
+
/** The reduced-motion media query, created on first use (null without `matchMedia`) */
|
|
3258
|
+
#reducedMotionQuery;
|
|
3259
|
+
constructor(config) {
|
|
3260
|
+
this.#config = config;
|
|
3261
|
+
}
|
|
3262
|
+
/** Whether a drop animation would play right now (duration, WAAPI, reduced motion). */
|
|
3263
|
+
isEnabled() {
|
|
3264
|
+
if (this.#duration() <= 0)
|
|
3265
|
+
return false;
|
|
3266
|
+
if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
|
|
3267
|
+
return false;
|
|
3268
|
+
}
|
|
3269
|
+
return !this.#prefersReducedMotion();
|
|
3270
|
+
}
|
|
3271
|
+
/**
|
|
3272
|
+
* Animate `ghost` onto `target` (or fade it out without one), then call `onDone`.
|
|
3273
|
+
* `onDone` is not called when the animation is cancelled.
|
|
3274
|
+
*/
|
|
3275
|
+
play(ghost, target, onDone) {
|
|
3276
|
+
this.cancel();
|
|
3277
|
+
const timing = {
|
|
3278
|
+
duration: this.#duration(),
|
|
3279
|
+
easing: this.#config.dropEasing ?? DEFAULT_DROP_EASING,
|
|
3280
|
+
fill: 'forwards',
|
|
3281
|
+
};
|
|
3282
|
+
let ghostAnimation;
|
|
3283
|
+
if (target) {
|
|
3284
|
+
const from = ghost.getBoundingClientRect();
|
|
3285
|
+
const dx = target.rect.left - from.left;
|
|
3286
|
+
const dy = target.rect.top - from.top;
|
|
3287
|
+
// Travel on top of the preview's own position transform (width/height must not be
|
|
3288
|
+
// composited, so the base transform is repeated instead of using composite: 'add').
|
|
3289
|
+
const base = ghost.style.transform;
|
|
3290
|
+
ghostAnimation = ghost.animate([
|
|
3291
|
+
{
|
|
3292
|
+
transform: `${base} translate(0px, 0px)`,
|
|
3293
|
+
width: `${from.width}px`,
|
|
3294
|
+
height: `${from.height}px`,
|
|
3295
|
+
},
|
|
3296
|
+
{
|
|
3297
|
+
transform: `${base} translate(${dx}px, ${dy}px)`,
|
|
3298
|
+
width: `${target.rect.width}px`,
|
|
3299
|
+
height: `${target.rect.height}px`,
|
|
3300
|
+
},
|
|
3301
|
+
], timing);
|
|
3302
|
+
// Keep the real item invisible until the ghost lands on it, so it is never seen twice.
|
|
3303
|
+
const hideAnimation = target.element.animate([{ opacity: 0 }, { opacity: 0 }], {
|
|
3304
|
+
duration: timing.duration,
|
|
3305
|
+
});
|
|
3306
|
+
this.#hidden = { element: target.element, animation: hideAnimation };
|
|
3307
|
+
hiddenTargets.set(target.element, hideAnimation);
|
|
3308
|
+
}
|
|
3309
|
+
else {
|
|
3310
|
+
ghostAnimation = ghost.animate([{ opacity: 1 }, { opacity: 0 }], timing);
|
|
3311
|
+
}
|
|
3312
|
+
this.#ghostAnimation = ghostAnimation;
|
|
3313
|
+
ghostAnimation.onfinish = () => {
|
|
3314
|
+
if (this.#ghostAnimation !== ghostAnimation)
|
|
3315
|
+
return;
|
|
3316
|
+
this.cancel();
|
|
3317
|
+
onDone();
|
|
3318
|
+
};
|
|
3319
|
+
}
|
|
3320
|
+
/** Stop the running animation immediately and reveal the real item. */
|
|
3321
|
+
cancel() {
|
|
3322
|
+
const ghostAnimation = this.#ghostAnimation;
|
|
3323
|
+
const hidden = this.#hidden;
|
|
3324
|
+
this.#ghostAnimation = null;
|
|
3325
|
+
this.#hidden = null;
|
|
3326
|
+
ghostAnimation?.cancel();
|
|
3327
|
+
if (hidden) {
|
|
3328
|
+
if (hiddenTargets.get(hidden.element) === hidden.animation) {
|
|
3329
|
+
hiddenTargets.delete(hidden.element);
|
|
3330
|
+
}
|
|
3331
|
+
hidden.animation.cancel();
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
#duration() {
|
|
3335
|
+
return this.#config.dropDuration ?? DEFAULT_DROP_DURATION;
|
|
3336
|
+
}
|
|
3337
|
+
/** One media query for every drop: it is live, so it follows a change of the preference. */
|
|
3338
|
+
#prefersReducedMotion() {
|
|
3339
|
+
if (this.#reducedMotionQuery === undefined) {
|
|
3340
|
+
this.#reducedMotionQuery =
|
|
3341
|
+
typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
|
|
3342
|
+
}
|
|
3343
|
+
return this.#reducedMotionQuery?.matches ?? false;
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
/**
|
|
3347
|
+
* Find the rendered element of a dropped item: first in the droppable it was dropped on, then
|
|
3348
|
+
* in the one it came from (a drop the consumer rejected or has not committed yet leaves it
|
|
3349
|
+
* there). Returns null when it is not rendered, or rendered but scrolled out of its list's view.
|
|
3350
|
+
*
|
|
3351
|
+
* `getDroppable` resolves a droppable ID to its element (the droppable registry lookup).
|
|
3352
|
+
*/
|
|
3353
|
+
function findDropTarget(draggableId, droppableIds, getDroppable) {
|
|
3354
|
+
for (const droppableId of droppableIds) {
|
|
3355
|
+
if (!droppableId)
|
|
3356
|
+
continue;
|
|
3357
|
+
const droppable = getDroppable(droppableId);
|
|
3358
|
+
const element = droppable
|
|
3359
|
+
? queryByAttribute(droppable, 'data-draggable-id', draggableId)
|
|
3360
|
+
: null;
|
|
3361
|
+
if (!droppable || !element)
|
|
3362
|
+
continue;
|
|
3363
|
+
const rect = element.getBoundingClientRect();
|
|
3364
|
+
// Hidden, or virtualized/scrolled out of the list's visible box: nowhere to land.
|
|
3365
|
+
if (rect.width === 0 && rect.height === 0)
|
|
3366
|
+
return null;
|
|
3367
|
+
const bounds = droppable.getBoundingClientRect();
|
|
3368
|
+
const visible = rect.bottom > bounds.top &&
|
|
3369
|
+
rect.top < bounds.bottom &&
|
|
3370
|
+
rect.right > bounds.left &&
|
|
3371
|
+
rect.left < bounds.right;
|
|
3372
|
+
return visible ? { element, rect } : null;
|
|
3373
|
+
}
|
|
3374
|
+
return null;
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3377
|
+
/**
|
|
3378
|
+
* How many views of rows that left a list stay pooled for rows that come in (recycleRows). A
|
|
3379
|
+
* scroll step reuses the views it pools right away; the pool keeps the rest, for example when
|
|
3380
|
+
* fewer rows render after the list shrinks.
|
|
3381
|
+
*/
|
|
3382
|
+
const MAX_POOLED_ROWS = 10;
|
|
3383
|
+
/** `Node.DOCUMENT_POSITION_FOLLOWING`, without the `Node` global (servers have none) */
|
|
3384
|
+
const DOCUMENT_POSITION_FOLLOWING = 4;
|
|
3385
|
+
/**
|
|
3386
|
+
* The node that comes first in the DOM. A view's `rootNodes` list a control flow block's anchor
|
|
3387
|
+
* before the nodes rendered in it, which the DOM puts before the anchor.
|
|
3388
|
+
*/
|
|
3389
|
+
function firstInDocumentOrder(nodes) {
|
|
3390
|
+
let first = null;
|
|
3391
|
+
for (const node of nodes) {
|
|
3392
|
+
if (first === null || node.compareDocumentPosition(first) & DOCUMENT_POSITION_FOLLOWING) {
|
|
3393
|
+
first = node;
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
return first;
|
|
3397
|
+
}
|
|
3398
|
+
/**
|
|
3399
|
+
* Which values belong to a longest increasing subsequence of `values` (negative values never
|
|
3400
|
+
* do). For rows: the views that can stay where they are while the others move around them, as
|
|
3401
|
+
* few as possible. A moved row loses focus and restarts its CSS transitions.
|
|
3402
|
+
*/
|
|
3403
|
+
function longestIncreasingRun(values) {
|
|
3404
|
+
// tails[k]: the index of the smallest value that ends an increasing run of length k + 1
|
|
3405
|
+
const tails = [];
|
|
3406
|
+
const previous = new Array(values.length).fill(-1);
|
|
3407
|
+
for (let i = 0; i < values.length; i++) {
|
|
3408
|
+
const value = values[i];
|
|
3409
|
+
if (value < 0)
|
|
3410
|
+
continue;
|
|
3411
|
+
let low = 0;
|
|
3412
|
+
let high = tails.length;
|
|
3413
|
+
while (low < high) {
|
|
3414
|
+
const middle = (low + high) >> 1;
|
|
3415
|
+
if (values[tails[middle]] < value) {
|
|
3416
|
+
low = middle + 1;
|
|
3417
|
+
}
|
|
3418
|
+
else {
|
|
3419
|
+
high = middle;
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
previous[i] = low > 0 ? tails[low - 1] : -1;
|
|
3423
|
+
tails[low] = i;
|
|
3424
|
+
}
|
|
3425
|
+
const inRun = new Array(values.length).fill(false);
|
|
3426
|
+
for (let i = tails.length > 0 ? tails[tails.length - 1] : -1; i >= 0; i = previous[i]) {
|
|
3427
|
+
inRun[i] = true;
|
|
3428
|
+
}
|
|
3429
|
+
return inRun;
|
|
3430
|
+
}
|
|
3431
|
+
/**
|
|
3432
|
+
* A virtual scroll container that only renders visible items.
|
|
3433
|
+
*
|
|
3434
|
+
* Key features:
|
|
3435
|
+
* - Only renders items within the visible viewport plus an overscan buffer
|
|
3436
|
+
* - Supports "sticky" items that are always rendered (used for dragged items)
|
|
3437
|
+
* - Uses spacer divs to maintain correct scroll height
|
|
3438
|
+
* - Integrates with the drag-and-drop system
|
|
3439
|
+
* - Automatic height detection via ResizeObserver when containerHeight is not provided
|
|
3440
|
+
* - Supports dynamic item heights via `dynamicItemHeight` input
|
|
3441
|
+
*
|
|
3442
|
+
* @example
|
|
3443
|
+
* ```html
|
|
3444
|
+
* <!-- With fixed height items -->
|
|
3445
|
+
* <vdnd-virtual-scroll
|
|
3446
|
+
* [items]="items()"
|
|
3447
|
+
* [itemHeight]="50"
|
|
3448
|
+
* [containerHeight]="400"
|
|
3449
|
+
* [trackByFn]="trackById">
|
|
2752
3450
|
* <ng-template let-item let-index="index">
|
|
2753
3451
|
* <div class="item">{{ item.name }}</div>
|
|
2754
3452
|
* </ng-template>
|
|
@@ -2775,6 +3473,7 @@ class VirtualScrollContainerComponent {
|
|
|
2775
3473
|
#keyboardDrag = inject(KeyboardDragService);
|
|
2776
3474
|
#ngZone = inject(NgZone);
|
|
2777
3475
|
#injector = inject(Injector);
|
|
3476
|
+
#environmentInjector = inject(EnvironmentInjector);
|
|
2778
3477
|
#isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
2779
3478
|
/** Cleanup function for scroll listener */
|
|
2780
3479
|
#scrollCleanup = null;
|
|
@@ -2789,6 +3488,38 @@ class VirtualScrollContainerComponent {
|
|
|
2789
3488
|
#measuredHeight = signal(0, ...(ngDevMode ? [{ debugName: "#measuredHeight" }] : []));
|
|
2790
3489
|
/** Slides items displaced by the placeholder (only when VDND_ANIMATION_CONFIG is provided) */
|
|
2791
3490
|
#shiftAnimator = this.#createShiftAnimator();
|
|
3491
|
+
/** The template's spacer (cannot use ES private with viewChild) */
|
|
3492
|
+
spacer = viewChild('spacer', ...(ngDevMode ? [{ debugName: "spacer" }] : []));
|
|
3493
|
+
/** The template's content wrapper (cannot use ES private with viewChild) */
|
|
3494
|
+
contentWrapper = viewChild('contentWrapper', ...(ngDevMode ? [{ debugName: "contentWrapper" }] : []));
|
|
3495
|
+
/** Where #renderRows inserts the rows (cannot use ES private with viewChild) */
|
|
3496
|
+
rowOutlet = viewChild('rows', { ...(ngDevMode ? { debugName: "rowOutlet" } : {}), read: ViewContainerRef });
|
|
3497
|
+
/** Rendered rows, in DOM order */
|
|
3498
|
+
#rows = [];
|
|
3499
|
+
/** The entries the rendered rows show */
|
|
3500
|
+
#rowEntries = null;
|
|
3501
|
+
/** The template the rendered rows were created from */
|
|
3502
|
+
#rowTemplate = null;
|
|
3503
|
+
/** The track function that gave the rendered rows their keys */
|
|
3504
|
+
#rowTrackBy = null;
|
|
3505
|
+
/** Views of rows that left, detached, to render rows that come in (recycleRows) */
|
|
3506
|
+
#rowPool = [];
|
|
3507
|
+
/**
|
|
3508
|
+
* Set while this component's view is about to be checked in the current change detection pass:
|
|
3509
|
+
* from an input change (ngOnChanges, which runs before its effects; the first render included)
|
|
3510
|
+
* until the check (ngAfterViewChecked). That check renders every row.
|
|
3511
|
+
*/
|
|
3512
|
+
#viewCheckPending = false;
|
|
3513
|
+
/** The placeholder, created on its first render and reused */
|
|
3514
|
+
#placeholder = null;
|
|
3515
|
+
/** Height last set on the placeholder */
|
|
3516
|
+
#placeholderHeightSet = null;
|
|
3517
|
+
/** Content offset last written to the content wrapper */
|
|
3518
|
+
#contentTransformSet = null;
|
|
3519
|
+
/** Whether duplicate track keys were reported (once per list) */
|
|
3520
|
+
#warnedDuplicateKeys = false;
|
|
3521
|
+
/** The row keys last checked for duplicates (checked again when the rows change) */
|
|
3522
|
+
#rowKeysChecked = null;
|
|
2792
3523
|
/** Template for rendering each item - passed as input instead of content child for reliability */
|
|
2793
3524
|
itemTemplate = input.required(...(ngDevMode ? [{ debugName: "itemTemplate" }] : []));
|
|
2794
3525
|
/** Unique ID for this scroll container (used for auto-scroll registration) */
|
|
@@ -2835,6 +3566,13 @@ class VirtualScrollContainerComponent {
|
|
|
2835
3566
|
* Required for placeholder positioning.
|
|
2836
3567
|
*/
|
|
2837
3568
|
droppableId = input(...(ngDevMode ? [undefined, { debugName: "droppableId" }] : []));
|
|
3569
|
+
/**
|
|
3570
|
+
* Reuse the views of rows that scroll out to render the rows that scroll in, instead of
|
|
3571
|
+
* destroying them and creating new ones. Scrolling creates fewer components and elements, but
|
|
3572
|
+
* a row's components, element and DOM state (focus aside) then carry over to other items.
|
|
3573
|
+
* @default false
|
|
3574
|
+
*/
|
|
3575
|
+
recycleRows = input(false, ...(ngDevMode ? [{ debugName: "recycleRows" }] : []));
|
|
2838
3576
|
/**
|
|
2839
3577
|
* Whether to automatically add the dragged item to the sticky list.
|
|
2840
3578
|
* This ensures the dragged item remains visible during virtual scrolling.
|
|
@@ -2931,22 +3669,50 @@ class VirtualScrollContainerComponent {
|
|
|
2931
3669
|
draggedItemId = computed(() => {
|
|
2932
3670
|
return this.#dragState.draggedItem()?.draggableId ?? null;
|
|
2933
3671
|
}, ...(ngDevMode ? [{ debugName: "draggedItemId" }] : []));
|
|
2934
|
-
/**
|
|
2935
|
-
|
|
2936
|
-
|
|
3672
|
+
/**
|
|
3673
|
+
* Every item's ID, in order. Computed once per items change, and only when something reads it:
|
|
3674
|
+
* a dynamic-height strategy, a drag, or a sticky item outside the rendered rows. A fixed-height
|
|
3675
|
+
* strategy needs only the item count.
|
|
3676
|
+
*/
|
|
3677
|
+
#itemIds = computed(() => {
|
|
2937
3678
|
const idFn = this.itemIdFn();
|
|
3679
|
+
return this.items().map((item) => idFn(item));
|
|
3680
|
+
}, ...(ngDevMode ? [{ debugName: "#itemIds" }] : []));
|
|
3681
|
+
/**
|
|
3682
|
+
* Index of each item ID (the last, when several share one), for the sticky items outside the
|
|
3683
|
+
* rendered rows. Built on first use, so an items change (a drop) builds none.
|
|
3684
|
+
*/
|
|
3685
|
+
#itemIndexMap = computed(() => {
|
|
3686
|
+
const ids = this.#itemIds();
|
|
2938
3687
|
const map = new Map();
|
|
2939
|
-
for (let i = 0; i <
|
|
2940
|
-
map.set(
|
|
3688
|
+
for (let i = 0; i < ids.length; i++) {
|
|
3689
|
+
map.set(ids[i], i);
|
|
2941
3690
|
}
|
|
2942
3691
|
return map;
|
|
2943
3692
|
}, ...(ngDevMode ? [{ debugName: "#itemIndexMap" }] : []));
|
|
2944
|
-
/**
|
|
3693
|
+
/**
|
|
3694
|
+
* The index of the currently dragged item in the items array (-1 if not found or not dragging).
|
|
3695
|
+
* Found without the index map, which only this lookup would need on a drag: a drag that started
|
|
3696
|
+
* in another list has no item here (IDs are unique across lists), the index where the drag found
|
|
3697
|
+
* its item is checked next, and only then every item ID is searched.
|
|
3698
|
+
*/
|
|
2945
3699
|
#draggedItemIndex = computed(() => {
|
|
2946
3700
|
const draggedId = this.draggedItemId();
|
|
2947
3701
|
if (!draggedId)
|
|
2948
3702
|
return -1;
|
|
2949
|
-
|
|
3703
|
+
const droppableId = this.droppableId();
|
|
3704
|
+
const sourceDroppableId = this.#dragState.sourceDroppableId();
|
|
3705
|
+
if (droppableId && sourceDroppableId && droppableId !== sourceDroppableId)
|
|
3706
|
+
return -1;
|
|
3707
|
+
const sourceIndex = this.#dragState.sourceIndex();
|
|
3708
|
+
const items = this.items();
|
|
3709
|
+
if (sourceIndex !== null &&
|
|
3710
|
+
sourceIndex >= 0 &&
|
|
3711
|
+
sourceIndex < items.length &&
|
|
3712
|
+
this.itemIdFn()(items[sourceIndex]) === draggedId) {
|
|
3713
|
+
return sourceIndex;
|
|
3714
|
+
}
|
|
3715
|
+
return this.#itemIds().lastIndexOf(draggedId);
|
|
2950
3716
|
}, ...(ngDevMode ? [{ debugName: "#draggedItemIndex" }] : []));
|
|
2951
3717
|
/** Memoized Set of sticky IDs - rebuilt only when effectiveStickyIds() changes */
|
|
2952
3718
|
#stickyIdsSet = computed(() => new Set(this.effectiveStickyIds()), ...(ngDevMode ? [{ debugName: "#stickyIdsSet" }] : []));
|
|
@@ -2971,31 +3737,20 @@ class VirtualScrollContainerComponent {
|
|
|
2971
3737
|
}
|
|
2972
3738
|
return this.itemHeight();
|
|
2973
3739
|
}, ...(ngDevMode ? [{ debugName: "placeholderHeight" }] : []));
|
|
2974
|
-
/**
|
|
2975
|
-
|
|
3740
|
+
/**
|
|
3741
|
+
* Rows to render: the items in range, then the sticky items outside it. Independent of the
|
|
3742
|
+
* placeholder, so a placeholder move leaves the rows as they are.
|
|
3743
|
+
*/
|
|
3744
|
+
#renderedRows = computed(() => {
|
|
2976
3745
|
const items = this.items();
|
|
2977
3746
|
const { start, end } = this.#renderRange();
|
|
2978
3747
|
const stickyIds = this.#stickyIdsSet();
|
|
2979
3748
|
const idFn = this.itemIdFn();
|
|
2980
|
-
const itemIndexMap = this.#itemIndexMap();
|
|
2981
3749
|
const draggedId = this.draggedItemId();
|
|
2982
|
-
const placeholderIdx = this.placeholderIndex();
|
|
2983
3750
|
const result = [];
|
|
2984
3751
|
const renderedIds = new Set();
|
|
2985
|
-
|
|
2986
|
-
// Add all items in the visible range, inserting placeholder at correct position
|
|
3752
|
+
// Add all items in the visible range
|
|
2987
3753
|
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
3754
|
const item = items[i];
|
|
3000
3755
|
const id = idFn(item);
|
|
3001
3756
|
result.push({
|
|
@@ -3007,24 +3762,14 @@ class VirtualScrollContainerComponent {
|
|
|
3007
3762
|
});
|
|
3008
3763
|
renderedIds.add(id);
|
|
3009
3764
|
}
|
|
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
3765
|
// Add any sticky items that aren't already rendered
|
|
3022
3766
|
const missingStickyIndices = [];
|
|
3023
3767
|
for (const id of stickyIds) {
|
|
3024
3768
|
if (renderedIds.has(id))
|
|
3025
3769
|
continue;
|
|
3026
|
-
|
|
3027
|
-
|
|
3770
|
+
// The drag knows its item's index; the index map serves the other sticky items
|
|
3771
|
+
const index = id === draggedId ? this.#draggedItemIndex() : (this.#itemIndexMap().get(id) ?? -1);
|
|
3772
|
+
if (index < 0)
|
|
3028
3773
|
continue;
|
|
3029
3774
|
missingStickyIndices.push({ id, index });
|
|
3030
3775
|
}
|
|
@@ -3044,6 +3789,45 @@ class VirtualScrollContainerComponent {
|
|
|
3044
3789
|
});
|
|
3045
3790
|
}
|
|
3046
3791
|
return result;
|
|
3792
|
+
}, ...(ngDevMode ? [{ debugName: "#renderedRows" }] : []));
|
|
3793
|
+
/** Track keys of #renderedRows, in order */
|
|
3794
|
+
#rowKeys = computed(() => this.#renderedRows().map((entry, i) => this.trackEntry(i, entry)), ...(ngDevMode ? [{ debugName: "#rowKeys" }] : []));
|
|
3795
|
+
/**
|
|
3796
|
+
* Where the placeholder renders among the rows: before the row at this position (after the
|
|
3797
|
+
* rows in range when it equals their count), or -1 when it is not rendered. It goes before the
|
|
3798
|
+
* item at the placeholder index when that item is in range, and after the rows in range when
|
|
3799
|
+
* the index is past the last item.
|
|
3800
|
+
*/
|
|
3801
|
+
#placeholderSlot = computed(() => {
|
|
3802
|
+
const placeholderIndex = this.placeholderIndex();
|
|
3803
|
+
if (placeholderIndex < 0)
|
|
3804
|
+
return -1;
|
|
3805
|
+
const itemCount = this.items().length;
|
|
3806
|
+
const { start, end } = this.#renderRange();
|
|
3807
|
+
const lastInRange = Math.min(end, itemCount - 1);
|
|
3808
|
+
if (placeholderIndex >= start && placeholderIndex <= lastInRange) {
|
|
3809
|
+
return placeholderIndex - start;
|
|
3810
|
+
}
|
|
3811
|
+
if (placeholderIndex >= itemCount) {
|
|
3812
|
+
return Math.max(0, lastInRange - start + 1);
|
|
3813
|
+
}
|
|
3814
|
+
return -1;
|
|
3815
|
+
}, ...(ngDevMode ? [{ debugName: "#placeholderSlot" }] : []));
|
|
3816
|
+
/** Items to render, including sticky items and placeholder */
|
|
3817
|
+
renderedItems = computed(() => {
|
|
3818
|
+
const rows = this.#renderedRows();
|
|
3819
|
+
const slot = this.#placeholderSlot();
|
|
3820
|
+
const result = [...rows];
|
|
3821
|
+
if (slot >= 0) {
|
|
3822
|
+
result.splice(slot, 0, {
|
|
3823
|
+
type: 'placeholder',
|
|
3824
|
+
data: null,
|
|
3825
|
+
index: this.placeholderIndex(),
|
|
3826
|
+
isSticky: false,
|
|
3827
|
+
isDragging: false,
|
|
3828
|
+
});
|
|
3829
|
+
}
|
|
3830
|
+
return result;
|
|
3047
3831
|
}, ...(ngDevMode ? [{ debugName: "renderedItems" }] : []));
|
|
3048
3832
|
/** Generated ID for auto-scroll registration */
|
|
3049
3833
|
#generatedScrollId = `vdnd-scroll-${Math.random().toString(36).slice(2, 9)}`;
|
|
@@ -3059,12 +3843,10 @@ class VirtualScrollContainerComponent {
|
|
|
3059
3843
|
enabled: () => this.autoScrollEnabled(),
|
|
3060
3844
|
config: () => this.autoScrollConfig(),
|
|
3061
3845
|
});
|
|
3062
|
-
// Keep strategy
|
|
3846
|
+
// Keep the strategy's items in sync. A fixed-height strategy needs only their count, which
|
|
3847
|
+
// spares computing every item's ID on each items change (each drop).
|
|
3063
3848
|
effect(() => {
|
|
3064
|
-
|
|
3065
|
-
const idFn = this.itemIdFn();
|
|
3066
|
-
const keys = items.map((item) => idFn(item));
|
|
3067
|
-
this.#strategy().setItemKeys(keys);
|
|
3849
|
+
setStrategyItems(this.#strategy(), this.items().length, () => this.#itemIds());
|
|
3068
3850
|
});
|
|
3069
3851
|
// Register strategy with drag index calculator for accurate drag calculations
|
|
3070
3852
|
effect((onCleanup) => {
|
|
@@ -3081,16 +3863,6 @@ class VirtualScrollContainerComponent {
|
|
|
3081
3863
|
const draggedIndex = this.#draggedItemIndex();
|
|
3082
3864
|
strategy.setExcludedIndex(draggedIndex >= 0 ? draggedIndex : null);
|
|
3083
3865
|
});
|
|
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
3866
|
// Keyboard drag autoscroll: keep the placeholder visible. Arrow keys call
|
|
3095
3867
|
// #revealPlaceholder synchronously (through KeyboardDragService) so a drop right after a
|
|
3096
3868
|
// move never lands outside the rendered range; this effect re-runs it when item
|
|
@@ -3156,6 +3928,18 @@ class VirtualScrollContainerComponent {
|
|
|
3156
3928
|
}
|
|
3157
3929
|
}
|
|
3158
3930
|
});
|
|
3931
|
+
effect(() => {
|
|
3932
|
+
const spacer = this.spacer()?.nativeElement;
|
|
3933
|
+
if (spacer) {
|
|
3934
|
+
spacer.style.height = `${this.totalHeight()}px`;
|
|
3935
|
+
}
|
|
3936
|
+
});
|
|
3937
|
+
// Last, so it renders what the effects above scrolled to. Component effects run before the
|
|
3938
|
+
// component's own view is checked, which has nothing to update: the template has no bindings.
|
|
3939
|
+
effect(() => this.#render());
|
|
3940
|
+
}
|
|
3941
|
+
ngOnChanges() {
|
|
3942
|
+
this.#viewCheckPending = true;
|
|
3159
3943
|
}
|
|
3160
3944
|
ngOnInit() {
|
|
3161
3945
|
// Measure rows in dynamic height mode. dynamicItemHeight can change at runtime, and the
|
|
@@ -3195,6 +3979,9 @@ class VirtualScrollContainerComponent {
|
|
|
3195
3979
|
// Observe rendered items for dynamic height measurement
|
|
3196
3980
|
this.#observeRenderedItems();
|
|
3197
3981
|
}
|
|
3982
|
+
ngAfterViewChecked() {
|
|
3983
|
+
this.#viewCheckPending = false;
|
|
3984
|
+
}
|
|
3198
3985
|
/**
|
|
3199
3986
|
* Scroll so the placeholder is fully visible during a keyboard drag into this list.
|
|
3200
3987
|
* Reads only signals and strategy offsets, so it works before the placeholder renders.
|
|
@@ -3239,6 +4026,279 @@ class VirtualScrollContainerComponent {
|
|
|
3239
4026
|
this.#scrollTop.set(newScrollTop);
|
|
3240
4027
|
}
|
|
3241
4028
|
}
|
|
4029
|
+
/**
|
|
4030
|
+
* Render the content offset, the rows and the placeholder. From an effect, not the template:
|
|
4031
|
+
* Angular checks every row view in this component's view each time a signal its template reads
|
|
4032
|
+
* changes, so reading the placeholder there re-rendered every row on each placeholder move.
|
|
4033
|
+
* Here a placeholder move only moves the placeholder, and a row renders when its context does.
|
|
4034
|
+
*/
|
|
4035
|
+
#render() {
|
|
4036
|
+
// Snapshot the rendered positions before anything moves, so displaced rows can slide. First,
|
|
4037
|
+
// as a subclass that renders its own template relies on it too.
|
|
4038
|
+
const isDragging = this.#dragState.isDragging();
|
|
4039
|
+
const placeholderIndex = this.placeholderIndex();
|
|
4040
|
+
untracked(() => this.#shiftAnimator?.beforeUpdate(isDragging, placeholderIndex));
|
|
4041
|
+
const outlet = this.rowOutlet();
|
|
4042
|
+
const wrapper = this.contentWrapper()?.nativeElement;
|
|
4043
|
+
// Not this component's template (a subclass's own): it renders renderedItems() itself
|
|
4044
|
+
if (!outlet || !wrapper)
|
|
4045
|
+
return;
|
|
4046
|
+
const template = this.itemTemplate() ?? null;
|
|
4047
|
+
const entries = this.#renderedRows();
|
|
4048
|
+
const keys = this.#rowKeys();
|
|
4049
|
+
const trackBy = this.effectiveTrackByFn();
|
|
4050
|
+
const recycle = this.recycleRows();
|
|
4051
|
+
const transform = this.contentTransform();
|
|
4052
|
+
const slot = this.#placeholderSlot();
|
|
4053
|
+
const placeholderHeight = slot >= 0 ? this.placeholderHeight() : null;
|
|
4054
|
+
// Untracked: the signals the rows' templates read must not become this effect's
|
|
4055
|
+
untracked(() => {
|
|
4056
|
+
if (transform !== this.#contentTransformSet) {
|
|
4057
|
+
wrapper.style.transform = transform;
|
|
4058
|
+
this.#contentTransformSet = transform;
|
|
4059
|
+
}
|
|
4060
|
+
this.#renderRows(outlet, template, entries, keys, trackBy, recycle);
|
|
4061
|
+
this.#renderPlaceholder(outlet, slot, placeholderHeight);
|
|
4062
|
+
});
|
|
4063
|
+
}
|
|
4064
|
+
/**
|
|
4065
|
+
* Give each entry a view of the item template, in order. As with `@for`, a row keeps its view
|
|
4066
|
+
* (and the components in it) while its track key stays rendered, and only the rows out of order
|
|
4067
|
+
* move. The view of a row that leaves is destroyed, or with `recycle` pooled and given to a row
|
|
4068
|
+
* that comes in. Only new, pooled and changed views are checked, with `detectChanges()`:
|
|
4069
|
+
* `markForCheck()` would mark every ancestor view up to the root too.
|
|
4070
|
+
*/
|
|
4071
|
+
#renderRows(outlet, template, entries, keys, trackBy, recycle) {
|
|
4072
|
+
if (!recycle) {
|
|
4073
|
+
this.#destroyRowPool();
|
|
4074
|
+
}
|
|
4075
|
+
// Nothing to do, as on a placeholder move
|
|
4076
|
+
if (entries === this.#rowEntries &&
|
|
4077
|
+
template === this.#rowTemplate &&
|
|
4078
|
+
trackBy === this.#rowTrackBy) {
|
|
4079
|
+
return;
|
|
4080
|
+
}
|
|
4081
|
+
// A new template renders every row anew; without one there is no row (as ngTemplateOutlet)
|
|
4082
|
+
if (template !== this.#rowTemplate) {
|
|
4083
|
+
for (const row of this.#rows) {
|
|
4084
|
+
row.view.destroy();
|
|
4085
|
+
}
|
|
4086
|
+
this.#rows = [];
|
|
4087
|
+
this.#destroyRowPool();
|
|
4088
|
+
this.#rowTemplate = template;
|
|
4089
|
+
}
|
|
4090
|
+
if (!template)
|
|
4091
|
+
return;
|
|
4092
|
+
// A new track function gives the rendered rows new keys; they keep their views (as with @for)
|
|
4093
|
+
if (trackBy !== this.#rowTrackBy) {
|
|
4094
|
+
for (const row of this.#rows) {
|
|
4095
|
+
const { $implicit, index } = row.view.context;
|
|
4096
|
+
row.key = this.trackEntry(index, { type: 'item', data: $implicit, index });
|
|
4097
|
+
}
|
|
4098
|
+
this.#rowTrackBy = trackBy;
|
|
4099
|
+
}
|
|
4100
|
+
// The last render's views by key, in order: an entry takes the first view left for its key
|
|
4101
|
+
const previous = new Map();
|
|
4102
|
+
for (const row of this.#rows) {
|
|
4103
|
+
if (row.view.destroyed)
|
|
4104
|
+
continue;
|
|
4105
|
+
const views = previous.get(row.key);
|
|
4106
|
+
if (views) {
|
|
4107
|
+
views.push(row);
|
|
4108
|
+
}
|
|
4109
|
+
else {
|
|
4110
|
+
previous.set(row.key, [row]);
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
const kept = keys.map((key) => previous.get(key)?.shift());
|
|
4114
|
+
this.#warnDuplicateKeys(keys);
|
|
4115
|
+
// Remove the rows that left, then keep in place the longest run of kept views that is
|
|
4116
|
+
// already in order (their positions in the outlet, which now holds only kept views)
|
|
4117
|
+
const dragged = this.#dragState.draggedItem();
|
|
4118
|
+
for (const views of previous.values()) {
|
|
4119
|
+
for (const row of views) {
|
|
4120
|
+
// The dragged item's view is destroyed, as without recycling: that cancels the drag
|
|
4121
|
+
// instead of handing it to another item
|
|
4122
|
+
if (recycle && !this.#rendersDraggedItem(row.view, dragged)) {
|
|
4123
|
+
this.#poolRow(outlet, row.view);
|
|
4124
|
+
}
|
|
4125
|
+
else {
|
|
4126
|
+
row.view.destroy();
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
4129
|
+
}
|
|
4130
|
+
const positions = new Map();
|
|
4131
|
+
for (let i = 0; i < outlet.length; i++) {
|
|
4132
|
+
positions.set(outlet.get(i), i);
|
|
4133
|
+
}
|
|
4134
|
+
const inPlace = longestIncreasingRun(kept.map((row) => (row ? positions.get(row.view) : -1)));
|
|
4135
|
+
// From the last row up, so each row goes right before the next one, already in place
|
|
4136
|
+
const rows = new Array(entries.length);
|
|
4137
|
+
const toCheck = [];
|
|
4138
|
+
let next = null;
|
|
4139
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
4140
|
+
const entry = entries[i];
|
|
4141
|
+
let row = kept[i];
|
|
4142
|
+
if (row) {
|
|
4143
|
+
if (!inPlace[i]) {
|
|
4144
|
+
this.#moveBefore(outlet, row.view, next);
|
|
4145
|
+
}
|
|
4146
|
+
if (this.#updateContext(row.view.context, entry)) {
|
|
4147
|
+
toCheck.push(row.view);
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
4150
|
+
else {
|
|
4151
|
+
const index = next ? outlet.indexOf(next) : outlet.length;
|
|
4152
|
+
// A pooled view is checked whatever its context: nothing checked it while detached
|
|
4153
|
+
let view = this.#rowPool.pop();
|
|
4154
|
+
if (view) {
|
|
4155
|
+
this.#updateContext(view.context, entry);
|
|
4156
|
+
outlet.insert(view, index);
|
|
4157
|
+
}
|
|
4158
|
+
else {
|
|
4159
|
+
const context = {
|
|
4160
|
+
$implicit: entry.data,
|
|
4161
|
+
index: entry.index,
|
|
4162
|
+
isSticky: entry.isSticky,
|
|
4163
|
+
};
|
|
4164
|
+
view = outlet.createEmbeddedView(template, context, index);
|
|
4165
|
+
}
|
|
4166
|
+
row = { key: keys[i], view };
|
|
4167
|
+
toCheck.push(view);
|
|
4168
|
+
}
|
|
4169
|
+
rows[i] = row;
|
|
4170
|
+
next = row.view;
|
|
4171
|
+
}
|
|
4172
|
+
this.#rows = rows;
|
|
4173
|
+
while (this.#rowPool.length > MAX_POOLED_ROWS) {
|
|
4174
|
+
this.#rowPool.pop().destroy();
|
|
4175
|
+
}
|
|
4176
|
+
// When this component's own view is checked later in this pass, that check renders every row
|
|
4177
|
+
if (!this.#viewCheckPending) {
|
|
4178
|
+
// Top to bottom (toCheck lists them bottom up)
|
|
4179
|
+
for (let i = toCheck.length - 1; i >= 0; i--) {
|
|
4180
|
+
toCheck[i].detectChanges();
|
|
4181
|
+
}
|
|
4182
|
+
}
|
|
4183
|
+
this.#rowEntries = entries;
|
|
4184
|
+
}
|
|
4185
|
+
/**
|
|
4186
|
+
* Whether a row's view renders the dragged item: its item has the dragged ID, or the view holds
|
|
4187
|
+
* the dragged element (its draggable ID need not be the item's ID).
|
|
4188
|
+
*/
|
|
4189
|
+
#rendersDraggedItem(view, dragged) {
|
|
4190
|
+
if (!dragged)
|
|
4191
|
+
return false;
|
|
4192
|
+
return (this.itemIdFn()(view.context.$implicit) === dragged.draggableId ||
|
|
4193
|
+
view.rootNodes.some((node) => node.contains(dragged.element)));
|
|
4194
|
+
}
|
|
4195
|
+
/**
|
|
4196
|
+
* Take a row's view out of the outlet and pool it. It keeps its element, so its shift
|
|
4197
|
+
* animation is cancelled (the item it renders next must not slide from this one's position),
|
|
4198
|
+
* and a drop animation that hides it shows it again.
|
|
4199
|
+
*/
|
|
4200
|
+
#poolRow(outlet, view) {
|
|
4201
|
+
outlet.detach(outlet.indexOf(view));
|
|
4202
|
+
const nodes = view.rootNodes;
|
|
4203
|
+
// A leave animation (`animate.leave`) keeps the row in the DOM, and Angular removes it when
|
|
4204
|
+
// the animation ends: in the row of another item by then, if the view were reused
|
|
4205
|
+
if (nodes.some((node) => node.parentNode !== null)) {
|
|
4206
|
+
view.destroy();
|
|
4207
|
+
return;
|
|
4208
|
+
}
|
|
4209
|
+
for (const node of nodes) {
|
|
4210
|
+
if (node instanceof HTMLElement) {
|
|
4211
|
+
this.#shiftAnimator?.cancel(node);
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
revealDropTargetIn(nodes);
|
|
4215
|
+
this.#rowPool.push(view);
|
|
4216
|
+
}
|
|
4217
|
+
#destroyRowPool() {
|
|
4218
|
+
for (const view of this.#rowPool) {
|
|
4219
|
+
view.destroy();
|
|
4220
|
+
}
|
|
4221
|
+
this.#rowPool = [];
|
|
4222
|
+
}
|
|
4223
|
+
/** Move a row's view right before `next` (last without one), unless it is there already. */
|
|
4224
|
+
#moveBefore(outlet, view, next) {
|
|
4225
|
+
const target = next ? outlet.indexOf(next) : outlet.length;
|
|
4226
|
+
const current = outlet.indexOf(view);
|
|
4227
|
+
if (current === target - 1)
|
|
4228
|
+
return;
|
|
4229
|
+
// move() takes the view out before inserting it, which shifts the views after it
|
|
4230
|
+
outlet.move(view, current < target ? target - 1 : target);
|
|
4231
|
+
}
|
|
4232
|
+
/** Copy an entry into a row's context. Returns whether the context changed. */
|
|
4233
|
+
#updateContext(context, entry) {
|
|
4234
|
+
if (Object.is(context.$implicit, entry.data) &&
|
|
4235
|
+
context.index === entry.index &&
|
|
4236
|
+
context.isSticky === entry.isSticky) {
|
|
4237
|
+
return false;
|
|
4238
|
+
}
|
|
4239
|
+
context.$implicit = entry.data;
|
|
4240
|
+
context.index = entry.index;
|
|
4241
|
+
context.isSticky = entry.isSticky;
|
|
4242
|
+
return true;
|
|
4243
|
+
}
|
|
4244
|
+
/** Items sharing a track key still render (a view each), but drag and drop needs unique IDs. */
|
|
4245
|
+
#warnDuplicateKeys(keys) {
|
|
4246
|
+
if (this.#warnedDuplicateKeys || keys === this.#rowKeysChecked || !isDevMode())
|
|
4247
|
+
return;
|
|
4248
|
+
this.#rowKeysChecked = keys;
|
|
4249
|
+
const seen = new Set();
|
|
4250
|
+
for (const key of keys) {
|
|
4251
|
+
if (seen.has(key)) {
|
|
4252
|
+
this.#warnedDuplicateKeys = true;
|
|
4253
|
+
console.warn(`[ngx-virtual-dnd] vdnd-virtual-scroll rendered two items with the track key ` +
|
|
4254
|
+
`${String(key)}. Give every item a unique ID (itemIdFn, or trackByFn).`);
|
|
4255
|
+
return;
|
|
4256
|
+
}
|
|
4257
|
+
seen.add(key);
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4260
|
+
/**
|
|
4261
|
+
* Put the placeholder before the row at `slot` (see #placeholderSlot), or take it out of the
|
|
4262
|
+
* DOM. It is a DragPlaceholderComponent of its own, outside this view: moving it or setting its
|
|
4263
|
+
* height checks no row.
|
|
4264
|
+
*/
|
|
4265
|
+
#renderPlaceholder(outlet, slot, height) {
|
|
4266
|
+
if (slot < 0 || height === null) {
|
|
4267
|
+
this.#placeholderElement()?.remove();
|
|
4268
|
+
return;
|
|
4269
|
+
}
|
|
4270
|
+
this.#placeholder ??= createComponent(DragPlaceholderComponent, {
|
|
4271
|
+
environmentInjector: this.#environmentInjector,
|
|
4272
|
+
});
|
|
4273
|
+
if (height !== this.#placeholderHeightSet) {
|
|
4274
|
+
this.#placeholder.setInput('itemHeight', height);
|
|
4275
|
+
this.#placeholder.changeDetectorRef.detectChanges();
|
|
4276
|
+
this.#placeholderHeightSet = height;
|
|
4277
|
+
}
|
|
4278
|
+
// Rows are inserted before the outlet's anchor, so the anchor follows the last of them
|
|
4279
|
+
const anchor = outlet.element.nativeElement;
|
|
4280
|
+
const parent = anchor.parentNode;
|
|
4281
|
+
const element = this.#placeholderElement();
|
|
4282
|
+
if (!parent || !element)
|
|
4283
|
+
return;
|
|
4284
|
+
const before = this.#firstNodeOfRows(slot, parent) ?? anchor;
|
|
4285
|
+
if (element.nextSibling !== before) {
|
|
4286
|
+
parent.insertBefore(element, before);
|
|
4287
|
+
}
|
|
4288
|
+
}
|
|
4289
|
+
#placeholderElement() {
|
|
4290
|
+
return this.#placeholder?.location.nativeElement ?? null;
|
|
4291
|
+
}
|
|
4292
|
+
/** The first node in `parent` of the rows from `index` on (null when none has one). */
|
|
4293
|
+
#firstNodeOfRows(index, parent) {
|
|
4294
|
+
for (let i = index; i < this.#rows.length; i++) {
|
|
4295
|
+
const nodes = this.#rows[i].view.rootNodes.filter((node) => node.parentNode === parent);
|
|
4296
|
+
const node = firstInDocumentOrder(nodes);
|
|
4297
|
+
if (node)
|
|
4298
|
+
return node;
|
|
4299
|
+
}
|
|
4300
|
+
return null;
|
|
4301
|
+
}
|
|
3242
4302
|
ngOnDestroy() {
|
|
3243
4303
|
this.#scrollCleanup?.();
|
|
3244
4304
|
this.#resizeCleanup?.();
|
|
@@ -3249,6 +4309,12 @@ class VirtualScrollContainerComponent {
|
|
|
3249
4309
|
this.#observedElementByKey.clear();
|
|
3250
4310
|
itemResizeObserver?.disconnect();
|
|
3251
4311
|
this.#shiftAnimator?.cancelAll();
|
|
4312
|
+
// Detached, so not destroyed with this view
|
|
4313
|
+
this.#destroyRowPool();
|
|
4314
|
+
// Created outside this view, so not destroyed with it (and destroy() leaves it in the DOM)
|
|
4315
|
+
this.#placeholderElement()?.remove();
|
|
4316
|
+
this.#placeholder?.destroy();
|
|
4317
|
+
this.#placeholder = null;
|
|
3252
4318
|
}
|
|
3253
4319
|
#createShiftAnimator() {
|
|
3254
4320
|
const config = inject(VDND_ANIMATION_CONFIG, { optional: true });
|
|
@@ -3262,10 +4328,25 @@ class VirtualScrollContainerComponent {
|
|
|
3262
4328
|
});
|
|
3263
4329
|
}
|
|
3264
4330
|
/**
|
|
3265
|
-
* Rendered item roots
|
|
3266
|
-
*
|
|
4331
|
+
* Rendered item roots, keyed by the row's track key (a recycled view renders another item, so
|
|
4332
|
+
* its element is no identity), and the placeholder.
|
|
3267
4333
|
*/
|
|
3268
4334
|
*#shiftAnimationEntries() {
|
|
4335
|
+
if (this.rowOutlet()) {
|
|
4336
|
+
for (const row of this.#rows) {
|
|
4337
|
+
for (const node of row.view.rootNodes) {
|
|
4338
|
+
if (node instanceof HTMLElement) {
|
|
4339
|
+
yield [row.key, node];
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
4342
|
+
}
|
|
4343
|
+
const placeholder = this.#placeholderElement();
|
|
4344
|
+
if (placeholder?.isConnected) {
|
|
4345
|
+
yield [placeholder, placeholder];
|
|
4346
|
+
}
|
|
4347
|
+
return;
|
|
4348
|
+
}
|
|
4349
|
+
// A subclass's own template (see #render) never recycles: each element is its own identity
|
|
3269
4350
|
const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
|
|
3270
4351
|
if (!wrapper)
|
|
3271
4352
|
return;
|
|
@@ -3299,25 +4380,29 @@ class VirtualScrollContainerComponent {
|
|
|
3299
4380
|
* item when a new observer is created.
|
|
3300
4381
|
*/
|
|
3301
4382
|
#observeRenderedItems() {
|
|
3302
|
-
// Re-observe whenever
|
|
3303
|
-
//
|
|
4383
|
+
// Re-observe whenever the rows change, once #render has put them in the DOM. The rows, not
|
|
4384
|
+
// renderedItems(): a placeholder move changes no row. A new item template changes no entry
|
|
4385
|
+
// but renders every row anew, as new elements.
|
|
3304
4386
|
afterRenderEffect(() => {
|
|
3305
4387
|
const observer = this.#itemResizeObserver();
|
|
3306
4388
|
if (!observer)
|
|
3307
4389
|
return;
|
|
3308
|
-
const rendered = this
|
|
4390
|
+
const rendered = this.#renderedRows();
|
|
3309
4391
|
const idFn = this.itemIdFn();
|
|
4392
|
+
this.itemTemplate();
|
|
3310
4393
|
// Find the content wrapper and observe item elements
|
|
3311
4394
|
const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
|
|
3312
4395
|
if (!wrapper)
|
|
3313
4396
|
return;
|
|
4397
|
+
// The DOM element of each item, by its data-draggable-id: one query for all rows, where a
|
|
4398
|
+
// query per row scanned the wrapper once for each (quadratic in the rendered rows)
|
|
4399
|
+
const elementsById = mapByAttribute(wrapper, 'data-draggable-id');
|
|
3314
4400
|
const nextObservedByKey = new Map();
|
|
3315
4401
|
for (const entry of rendered) {
|
|
3316
4402
|
if (entry.type !== 'item' || !entry.data)
|
|
3317
4403
|
continue;
|
|
3318
4404
|
const key = idFn(entry.data);
|
|
3319
|
-
|
|
3320
|
-
const el = queryByAttribute(wrapper, 'data-draggable-id', key);
|
|
4405
|
+
const el = elementsById.get(key);
|
|
3321
4406
|
if (el) {
|
|
3322
4407
|
nextObservedByKey.set(key, el);
|
|
3323
4408
|
}
|
|
@@ -3356,206 +4441,56 @@ class VirtualScrollContainerComponent {
|
|
|
3356
4441
|
*/
|
|
3357
4442
|
scrollToIndex(index) {
|
|
3358
4443
|
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
|
-
};
|
|
4444
|
+
const position = strategy.getOffsetForIndex(index);
|
|
4445
|
+
this.scrollTo(position);
|
|
3515
4446
|
}
|
|
3516
|
-
/**
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
this.#
|
|
3521
|
-
this.#hideAnimation = null;
|
|
3522
|
-
ghostAnimation?.cancel();
|
|
3523
|
-
hideAnimation?.cancel();
|
|
4447
|
+
/**
|
|
4448
|
+
* Get the current scroll position.
|
|
4449
|
+
*/
|
|
4450
|
+
getScrollTop() {
|
|
4451
|
+
return this.#scrollTop();
|
|
3524
4452
|
}
|
|
3525
|
-
|
|
3526
|
-
|
|
4453
|
+
/**
|
|
4454
|
+
* Get the total scrollable height.
|
|
4455
|
+
*/
|
|
4456
|
+
getScrollHeight() {
|
|
4457
|
+
return this.totalHeight();
|
|
3527
4458
|
}
|
|
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;
|
|
4459
|
+
/**
|
|
4460
|
+
* Scroll by a delta amount.
|
|
4461
|
+
*/
|
|
4462
|
+
scrollBy(delta) {
|
|
4463
|
+
const newPosition = Math.max(0, Math.min(this.getScrollTop() + delta, this.getScrollHeight() - this.effectiveHeight()));
|
|
4464
|
+
this.scrollTo(newPosition);
|
|
3556
4465
|
}
|
|
3557
|
-
|
|
4466
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
4467
|
+
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: `
|
|
4468
|
+
<!-- Single spacer maintains scroll height -->
|
|
4469
|
+
<div #spacer class="vdnd-virtual-scroll-spacer"></div>
|
|
4470
|
+
|
|
4471
|
+
<!-- Content wrapper positioned via GPU-accelerated transform -->
|
|
4472
|
+
<div #contentWrapper class="vdnd-virtual-scroll-content-wrapper">
|
|
4473
|
+
<ng-container #rows />
|
|
4474
|
+
</div>
|
|
4475
|
+
`, 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
4476
|
}
|
|
4477
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, decorators: [{
|
|
4478
|
+
type: Component,
|
|
4479
|
+
args: [{ selector: 'vdnd-virtual-scroll', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
4480
|
+
class: 'vdnd-virtual-scroll',
|
|
4481
|
+
'[style.height.px]': 'containerHeight() ?? null',
|
|
4482
|
+
'[attr.data-item-height]': 'itemHeight()',
|
|
4483
|
+
'[attr.data-total-items]': 'items().length',
|
|
4484
|
+
}, template: `
|
|
4485
|
+
<!-- Single spacer maintains scroll height -->
|
|
4486
|
+
<div #spacer class="vdnd-virtual-scroll-spacer"></div>
|
|
4487
|
+
|
|
4488
|
+
<!-- Content wrapper positioned via GPU-accelerated transform -->
|
|
4489
|
+
<div #contentWrapper class="vdnd-virtual-scroll-content-wrapper">
|
|
4490
|
+
<ng-container #rows />
|
|
4491
|
+
</div>
|
|
4492
|
+
`, 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"] }]
|
|
4493
|
+
}], 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
4494
|
|
|
3560
4495
|
/**
|
|
3561
4496
|
* Renders a preview of the dragged item that follows the cursor.
|
|
@@ -3581,6 +4516,8 @@ class DragPreviewComponent {
|
|
|
3581
4516
|
#overlayContainer = inject(OverlayContainerService);
|
|
3582
4517
|
#elementRef = inject((ElementRef));
|
|
3583
4518
|
#injector = inject(Injector);
|
|
4519
|
+
#droppableRegistry = inject(DroppableRegistryService);
|
|
4520
|
+
#scheduler = inject(DragSchedulerService);
|
|
3584
4521
|
#dropAnimator = this.#createDropAnimator();
|
|
3585
4522
|
/** Optional custom template for the preview */
|
|
3586
4523
|
previewTemplate = input(...(ngDevMode ? [undefined, { debugName: "previewTemplate" }] : []));
|
|
@@ -3598,6 +4535,39 @@ class DragPreviewComponent {
|
|
|
3598
4535
|
isSettling = computed(() => this.#settling() !== null, ...(ngDevMode ? [{ debugName: "isSettling" }] : []));
|
|
3599
4536
|
/** The item shown: the live dragged item, or the dropped one while it settles */
|
|
3600
4537
|
displayedItem = computed(() => this.dragState.draggedItem() ?? this.#settling()?.item ?? null, ...(ngDevMode ? [{ debugName: "displayedItem" }] : []));
|
|
4538
|
+
/**
|
|
4539
|
+
* Where the preview goes, from everything but the cursor. The template and its effects never
|
|
4540
|
+
* read the cursor: a signal read there would schedule a full application render on every
|
|
4541
|
+
* pointer frame, and that render lands after the frame is painted (one frame late).
|
|
4542
|
+
*/
|
|
4543
|
+
#placement = computed(() => {
|
|
4544
|
+
const settling = this.#settling();
|
|
4545
|
+
if (settling && !this.dragState.isDragging()) {
|
|
4546
|
+
return {
|
|
4547
|
+
settled: settling.position,
|
|
4548
|
+
offset: { x: 0, y: 0 },
|
|
4549
|
+
initialPosition: null,
|
|
4550
|
+
lockAxis: null,
|
|
4551
|
+
};
|
|
4552
|
+
}
|
|
4553
|
+
return this.#placementFor({
|
|
4554
|
+
grabOffset: this.dragState.grabOffset(),
|
|
4555
|
+
initialPosition: this.dragState.initialPosition(),
|
|
4556
|
+
lockAxis: this.dragState.lockAxis(),
|
|
4557
|
+
});
|
|
4558
|
+
}, ...(ngDevMode ? [{ debugName: "#placement" }] : []));
|
|
4559
|
+
/**
|
|
4560
|
+
* Moves the preview inside the drag scheduler's frame, right after the hit-test wrote the
|
|
4561
|
+
* cursor (bound for stable add/remove). The render-time write in the constructor covers the
|
|
4562
|
+
* rest: the preview appearing, keyboard drags and the settle.
|
|
4563
|
+
*/
|
|
4564
|
+
#writeFramePosition = () => {
|
|
4565
|
+
const preview = this.previewElement()?.nativeElement;
|
|
4566
|
+
if (!preview || this.#settling() || !this.dragState.isDragging()) {
|
|
4567
|
+
return;
|
|
4568
|
+
}
|
|
4569
|
+
this.#place(preview, this.#placement(), this.dragState.cursorPosition());
|
|
4570
|
+
};
|
|
3601
4571
|
/** Whether this preview's template registration is currently counted (kept balanced). */
|
|
3602
4572
|
#templateRegistered = false;
|
|
3603
4573
|
/** The cloned element from drag state (used when no custom template is provided) */
|
|
@@ -3608,6 +4578,7 @@ class DragPreviewComponent {
|
|
|
3608
4578
|
return this.displayedItem()?.clonedElement ?? null;
|
|
3609
4579
|
}, ...(ngDevMode ? [{ debugName: "clonedElement" }] : []));
|
|
3610
4580
|
constructor() {
|
|
4581
|
+
this.#scheduler.addFrameWriter(this.#writeFramePosition);
|
|
3611
4582
|
// Teleport host element into the body-level overlay container after first render.
|
|
3612
4583
|
// This escapes any ancestor CSS transforms that would break position: fixed.
|
|
3613
4584
|
afterNextRender(() => {
|
|
@@ -3616,6 +4587,18 @@ class DragPreviewComponent {
|
|
|
3616
4587
|
container.appendChild(this.#elementRef.nativeElement);
|
|
3617
4588
|
}
|
|
3618
4589
|
});
|
|
4590
|
+
// Place the preview when it appears or anything but the cursor changes (drag start,
|
|
4591
|
+
// keyboard drags, the settle). The cursor is read untracked; pointer moves are written by
|
|
4592
|
+
// #writeFramePosition. Runs before the settle's afterNextRender reads the transform.
|
|
4593
|
+
afterRenderEffect({
|
|
4594
|
+
write: () => {
|
|
4595
|
+
const preview = this.previewElement()?.nativeElement;
|
|
4596
|
+
const placement = this.#placement();
|
|
4597
|
+
if (preview) {
|
|
4598
|
+
this.#place(preview, placement, untracked(this.dragState.cursorPosition));
|
|
4599
|
+
}
|
|
4600
|
+
},
|
|
4601
|
+
});
|
|
3619
4602
|
// Publish whether this preview renders via a custom template so the drag
|
|
3620
4603
|
// directives can skip the drag-start element clone when it won't be shown.
|
|
3621
4604
|
effect(() => {
|
|
@@ -3660,6 +4643,7 @@ class DragPreviewComponent {
|
|
|
3660
4643
|
});
|
|
3661
4644
|
}
|
|
3662
4645
|
ngOnDestroy() {
|
|
4646
|
+
this.#scheduler.removeFrameWriter(this.#writeFramePosition);
|
|
3663
4647
|
this.#dropAnimator?.cancel();
|
|
3664
4648
|
if (this.#templateRegistered) {
|
|
3665
4649
|
this.#overlayContainer.setTemplatePreviewActive(false);
|
|
@@ -3667,32 +4651,31 @@ class DragPreviewComponent {
|
|
|
3667
4651
|
}
|
|
3668
4652
|
this.#elementRef.nativeElement.remove();
|
|
3669
4653
|
}
|
|
3670
|
-
/**
|
|
4654
|
+
/**
|
|
4655
|
+
* Whether the preview is visible. A drag shows it once it has a position: `initialPosition`
|
|
4656
|
+
* is set from the drag-start cursor and, unlike `cursorPosition`, does not change per frame.
|
|
4657
|
+
*/
|
|
3671
4658
|
isVisible = computed(() => {
|
|
3672
|
-
return ((this.dragState.isDragging() && this.dragState.
|
|
4659
|
+
return ((this.dragState.isDragging() && this.dragState.initialPosition() !== null) ||
|
|
4660
|
+
this.isSettling());
|
|
3673
4661
|
}, ...(ngDevMode ? [{ debugName: "isVisible" }] : []));
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
4662
|
+
#placementFor(state) {
|
|
4663
|
+
return {
|
|
4664
|
+
settled: null,
|
|
4665
|
+
// Use grab offset if available (preserves grab position), otherwise fall back to cursorOffset input
|
|
4666
|
+
offset: state.grabOffset ?? this.cursorOffset(),
|
|
4667
|
+
initialPosition: state.initialPosition,
|
|
4668
|
+
lockAxis: state.lockAxis,
|
|
4669
|
+
};
|
|
4670
|
+
}
|
|
4671
|
+
#positionAt(placement, cursor) {
|
|
4672
|
+
if (placement.settled) {
|
|
4673
|
+
return placement.settled;
|
|
3679
4674
|
}
|
|
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
4675
|
if (!cursor) {
|
|
3692
4676
|
return { x: 0, y: 0 };
|
|
3693
4677
|
}
|
|
3694
|
-
|
|
3695
|
-
const offset = state.grabOffset ?? this.cursorOffset();
|
|
4678
|
+
const { offset, initialPosition, lockAxis } = placement;
|
|
3696
4679
|
let x = cursor.x - offset.x;
|
|
3697
4680
|
let y = cursor.y - offset.y;
|
|
3698
4681
|
// Apply axis locking if configured
|
|
@@ -3709,10 +4692,10 @@ class DragPreviewComponent {
|
|
|
3709
4692
|
return { x, y };
|
|
3710
4693
|
}
|
|
3711
4694
|
/** Transform-based positioning for better performance (avoid layout from left/top). */
|
|
3712
|
-
|
|
3713
|
-
const { x, y } = this
|
|
3714
|
-
|
|
3715
|
-
}
|
|
4695
|
+
#place(preview, placement, cursor) {
|
|
4696
|
+
const { x, y } = this.#positionAt(placement, cursor);
|
|
4697
|
+
preview.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
|
4698
|
+
}
|
|
3716
4699
|
/** Dimensions of the preview */
|
|
3717
4700
|
dimensions = computed(() => {
|
|
3718
4701
|
const item = this.displayedItem();
|
|
@@ -3748,7 +4731,7 @@ class DragPreviewComponent {
|
|
|
3748
4731
|
}
|
|
3749
4732
|
const settling = {
|
|
3750
4733
|
item: ended.draggedItem,
|
|
3751
|
-
position: this.#
|
|
4734
|
+
position: this.#positionAt(this.#placementFor(ended), ended.cursorPosition),
|
|
3752
4735
|
};
|
|
3753
4736
|
this.#settling.set(settling);
|
|
3754
4737
|
// A normal drop lands in the target list; a cancel, a rejected drop or one the consumer
|
|
@@ -3765,7 +4748,7 @@ class DragPreviewComponent {
|
|
|
3765
4748
|
this.#settling.set(null);
|
|
3766
4749
|
return;
|
|
3767
4750
|
}
|
|
3768
|
-
const target = findDropTarget(settling.item.draggableId, droppableIds);
|
|
4751
|
+
const target = findDropTarget(settling.item.draggableId, droppableIds, (id) => this.#droppableRegistry.getById(id));
|
|
3769
4752
|
this.#dropAnimator.play(ghost, target, () => {
|
|
3770
4753
|
if (this.#settling() === settling) {
|
|
3771
4754
|
this.#settling.set(null);
|
|
@@ -3785,7 +4768,6 @@ class DragPreviewComponent {
|
|
|
3785
4768
|
class="vdnd-drag-preview"
|
|
3786
4769
|
[class.vdnd-drag-preview-dropping]="isSettling()"
|
|
3787
4770
|
[attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
|
|
3788
|
-
[style.transform]="transform()"
|
|
3789
4771
|
[style.width.px]="dimensions().width"
|
|
3790
4772
|
[style.height.px]="dimensions().height"
|
|
3791
4773
|
>
|
|
@@ -3812,7 +4794,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
3812
4794
|
class="vdnd-drag-preview"
|
|
3813
4795
|
[class.vdnd-drag-preview-dropping]="isSettling()"
|
|
3814
4796
|
[attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
|
|
3815
|
-
[style.transform]="transform()"
|
|
3816
4797
|
[style.width.px]="dimensions().width"
|
|
3817
4798
|
[style.height.px]="dimensions().height"
|
|
3818
4799
|
>
|
|
@@ -4013,7 +4994,8 @@ class DroppableDirective {
|
|
|
4013
4994
|
#elementRef = inject((ElementRef));
|
|
4014
4995
|
#dragState = inject(DragStateService);
|
|
4015
4996
|
#autoScroll = inject(AutoScrollService);
|
|
4016
|
-
#
|
|
4997
|
+
#registry = inject(DroppableRegistryService);
|
|
4998
|
+
#ngZone = inject(NgZone);
|
|
4017
4999
|
#parentGroup = inject(VDND_GROUP_TOKEN, { optional: true });
|
|
4018
5000
|
/** Unique identifier for this droppable */
|
|
4019
5001
|
vdndDroppable = input.required(...(ngDevMode ? [{ debugName: "vdndDroppable" }] : []));
|
|
@@ -4042,7 +5024,7 @@ class DroppableDirective {
|
|
|
4042
5024
|
autoScrollConfig = input({}, ...(ngDevMode ? [{ debugName: "autoScrollConfig" }] : []));
|
|
4043
5025
|
/** Constrain drag preview and placeholder to container boundaries */
|
|
4044
5026
|
constrainToContainer = input(false, ...(ngDevMode ? [{ debugName: "constrainToContainer" }] : []));
|
|
4045
|
-
/** Emits when an item is dropped on this droppable */
|
|
5027
|
+
/** Emits when an item is dropped on this droppable: at release, right after its `dragEnd` */
|
|
4046
5028
|
// eslint-disable-next-line @angular-eslint/no-output-native
|
|
4047
5029
|
drop = output();
|
|
4048
5030
|
/**
|
|
@@ -4087,14 +5069,6 @@ class DroppableDirective {
|
|
|
4087
5069
|
#lastPlaceholderIndex = null;
|
|
4088
5070
|
/** Drag whose initial placeholder position has already been seen by this droppable */
|
|
4089
5071
|
#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
5072
|
/** Whether this droppable has rendered, so its inputs have values */
|
|
4099
5073
|
#rendered = false;
|
|
4100
5074
|
constructor() {
|
|
@@ -4109,59 +5083,33 @@ class DroppableDirective {
|
|
|
4109
5083
|
// would never register. AutoScrollService filters by live scroll geometry per drag.
|
|
4110
5084
|
canRegister: () => Boolean(this.effectiveGroup()),
|
|
4111
5085
|
});
|
|
4112
|
-
|
|
4113
|
-
//
|
|
4114
|
-
//
|
|
4115
|
-
|
|
4116
|
-
|
|
5086
|
+
afterNextRender(() => (this.#rendered = true));
|
|
5087
|
+
// Register with the droppable registry, and again under the new key when the ID or group
|
|
5088
|
+
// changes. Drag code finds drop targets through the registry instead of querying the
|
|
5089
|
+
// document; a registration during an active drag makes this droppable a target from the
|
|
5090
|
+
// next hit-test. The registry also calls #handleDrop when a drag ends on this droppable.
|
|
5091
|
+
// The cleanup unregisters it, including on destroy.
|
|
5092
|
+
//
|
|
5093
|
+
// Not deferred to afterNextRender: this runs in the change detection that creates the
|
|
5094
|
+
// droppable (inputs are set by then), so afterNextRender hooks of that same render (drop
|
|
5095
|
+
// animation, keyboard focus restore) already find it, and so does a view refreshed only
|
|
5096
|
+
// by its own detectChanges(). Nothing reads the registry before change detection ends.
|
|
5097
|
+
effect((onCleanup) => {
|
|
4117
5098
|
const group = this.effectiveGroup();
|
|
4118
|
-
if (group) {
|
|
4119
|
-
|
|
5099
|
+
if (!group) {
|
|
5100
|
+
return;
|
|
4120
5101
|
}
|
|
5102
|
+
onCleanup(this.#registry.register(this.#elementRef.nativeElement, this.vdndDroppable(), group, (endedState) => this.#handleDrop(endedState)));
|
|
4121
5103
|
});
|
|
4122
5104
|
effect(() => {
|
|
4123
5105
|
const currentIndex = this.#placeholderInsertionIndex();
|
|
4124
5106
|
untracked(() => this.#handlePlaceholderMove(currentIndex));
|
|
4125
5107
|
});
|
|
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
5108
|
}
|
|
4161
5109
|
ngOnDestroy() {
|
|
4162
|
-
// Destroyed before it rendered: its inputs may have no values yet
|
|
4163
|
-
// drop target
|
|
4164
|
-
//
|
|
5110
|
+
// Destroyed before it rendered: its inputs may have no values yet and it never became the
|
|
5111
|
+
// active drop target. (Not an ngOnInit flag: a subclass with its own ngOnInit would skip
|
|
5112
|
+
// it.) A registry registration, if any, is removed by its effect's cleanup.
|
|
4165
5113
|
if (!this.#rendered) {
|
|
4166
5114
|
return;
|
|
4167
5115
|
}
|
|
@@ -4169,13 +5117,7 @@ class DroppableDirective {
|
|
|
4169
5117
|
if (this.isActive()) {
|
|
4170
5118
|
this.#dragState.setActiveDroppable(null);
|
|
4171
5119
|
}
|
|
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
|
-
}
|
|
5120
|
+
// Auto-scroll and the droppable registry unregister through their effects' cleanup.
|
|
4179
5121
|
}
|
|
4180
5122
|
/**
|
|
4181
5123
|
* Emit placeholderMove when the placeholder moved within (or entered) this droppable.
|
|
@@ -4209,14 +5151,14 @@ class DroppableDirective {
|
|
|
4209
5151
|
});
|
|
4210
5152
|
}
|
|
4211
5153
|
/**
|
|
4212
|
-
*
|
|
5154
|
+
* Emit `drop` for a drag that ended on this droppable. Called by the registry from
|
|
5155
|
+
* `DragStateService.endDrag()`, with the state captured just before the reset. Returns
|
|
5156
|
+
* whether it emitted.
|
|
4213
5157
|
*/
|
|
4214
|
-
#handleDrop() {
|
|
4215
|
-
//
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
if (!state?.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
|
|
4219
|
-
return;
|
|
5158
|
+
#handleDrop(state) {
|
|
5159
|
+
// A target disabled at release is not a valid drop (dragEnd reports no destination).
|
|
5160
|
+
if (this.disabled() || !state.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
|
|
5161
|
+
return false;
|
|
4220
5162
|
}
|
|
4221
5163
|
const sourceDroppableId = state.sourceDroppableId ?? '';
|
|
4222
5164
|
const placeholderId = state.placeholderId ?? END_OF_LIST;
|
|
@@ -4236,7 +5178,7 @@ class DroppableDirective {
|
|
|
4236
5178
|
sourceDroppableId,
|
|
4237
5179
|
activeDroppableId: this.vdndDroppable(),
|
|
4238
5180
|
});
|
|
4239
|
-
|
|
5181
|
+
const event = {
|
|
4240
5182
|
source: {
|
|
4241
5183
|
draggableId: state.draggedItem.draggableId,
|
|
4242
5184
|
droppableId: sourceDroppableId,
|
|
@@ -4249,7 +5191,13 @@ class DroppableDirective {
|
|
|
4249
5191
|
index: destinationIndex,
|
|
4250
5192
|
data: this.vdndDroppableData(),
|
|
4251
5193
|
},
|
|
4252
|
-
}
|
|
5194
|
+
};
|
|
5195
|
+
// Delivered from the pointer/keyboard listener, which runs outside Angular's zone. With
|
|
5196
|
+
// zone.js a template listener marks its view dirty without scheduling a render, so emit
|
|
5197
|
+
// inside the zone for the consumer's handler (and async work it starts) to render. A
|
|
5198
|
+
// no-op when zoneless.
|
|
5199
|
+
this.#ngZone.run(() => this.drop.emit(event));
|
|
5200
|
+
return true;
|
|
4253
5201
|
}
|
|
4254
5202
|
/**
|
|
4255
5203
|
* Get the index of an item in a droppable.
|
|
@@ -4257,7 +5205,7 @@ class DroppableDirective {
|
|
|
4257
5205
|
*/
|
|
4258
5206
|
#getItemIndex(draggableId, droppableId) {
|
|
4259
5207
|
// Find all draggables in the source droppable
|
|
4260
|
-
const droppable =
|
|
5208
|
+
const droppable = this.#registry.getById(droppableId);
|
|
4261
5209
|
if (!droppable) {
|
|
4262
5210
|
return 0;
|
|
4263
5211
|
}
|
|
@@ -4330,6 +5278,30 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4330
5278
|
}]
|
|
4331
5279
|
}], 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
5280
|
|
|
5281
|
+
/**
|
|
5282
|
+
* Forwards the inner droppable's `drop` and `placeholderMove` to the sortable list's outputs.
|
|
5283
|
+
*
|
|
5284
|
+
* Not template listeners: Angular marks the listening view and every ancestor up to the root
|
|
5285
|
+
* dirty before running one, so each placeholder move would re-render the consumer's whole
|
|
5286
|
+
* component chain even when nothing listens to `placeholderMove`. A subscription marks nothing;
|
|
5287
|
+
* a consumer's own listener on the sortable list marks only its own view. Subscribed in the
|
|
5288
|
+
* constructor, before the droppable's first change detection can emit; both outputs drop their
|
|
5289
|
+
* listeners when the element is destroyed.
|
|
5290
|
+
*/
|
|
5291
|
+
class SortableListOutputsDirective {
|
|
5292
|
+
constructor() {
|
|
5293
|
+
const droppable = inject(DroppableDirective);
|
|
5294
|
+
const list = inject(VirtualSortableListComponent);
|
|
5295
|
+
droppable.drop.subscribe((event) => list.drop.emit(event));
|
|
5296
|
+
droppable.placeholderMove.subscribe((event) => list.placeholderMove.emit(event));
|
|
5297
|
+
}
|
|
5298
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: SortableListOutputsDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
5299
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.6", type: SortableListOutputsDirective, isStandalone: true, selector: "[vdndSortableListOutputs]", ngImport: i0 });
|
|
5300
|
+
}
|
|
5301
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: SortableListOutputsDirective, decorators: [{
|
|
5302
|
+
type: Directive,
|
|
5303
|
+
args: [{ selector: '[vdndSortableListOutputs]' }]
|
|
5304
|
+
}], ctorParameters: () => [] });
|
|
4333
5305
|
/**
|
|
4334
5306
|
* A high-level component that combines droppable, virtual scroll, and placeholder
|
|
4335
5307
|
* functionality into a single, easy-to-use component.
|
|
@@ -4407,6 +5379,13 @@ class VirtualSortableListComponent {
|
|
|
4407
5379
|
containerHeight = input(...(ngDevMode ? [undefined, { debugName: "containerHeight" }] : []));
|
|
4408
5380
|
/** Number of items to render above/below the visible area */
|
|
4409
5381
|
overscan = input(3, ...(ngDevMode ? [{ debugName: "overscan" }] : []));
|
|
5382
|
+
/**
|
|
5383
|
+
* Reuse the views of rows that scroll out to render the rows that scroll in, instead of
|
|
5384
|
+
* destroying them and creating new ones. Scrolling creates fewer components and elements, but
|
|
5385
|
+
* a row's components, element and DOM state (focus aside) then carry over to other items.
|
|
5386
|
+
* @default false
|
|
5387
|
+
*/
|
|
5388
|
+
recycleRows = input(false, ...(ngDevMode ? [{ debugName: "recycleRows" }] : []));
|
|
4410
5389
|
/** Enable auto-scroll when dragging near edges */
|
|
4411
5390
|
autoScrollEnabled = input(true, ...(ngDevMode ? [{ debugName: "autoScrollEnabled" }] : []));
|
|
4412
5391
|
/** Auto-scroll configuration */
|
|
@@ -4420,7 +5399,7 @@ class VirtualSortableListComponent {
|
|
|
4420
5399
|
/** Emits each time the placeholder moves within this list (every item displacement) */
|
|
4421
5400
|
placeholderMove = output();
|
|
4422
5401
|
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: `
|
|
5402
|
+
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
5403
|
<div
|
|
4425
5404
|
class="vdnd-sortable-list-droppable"
|
|
4426
5405
|
[vdndDroppable]="droppableId()"
|
|
@@ -4428,8 +5407,7 @@ class VirtualSortableListComponent {
|
|
|
4428
5407
|
[vdndDroppableData]="droppableData()"
|
|
4429
5408
|
[disabled]="disabled()"
|
|
4430
5409
|
[constrainToContainer]="constrainToContainer()"
|
|
4431
|
-
|
|
4432
|
-
(placeholderMove)="placeholderMove.emit($event)"
|
|
5410
|
+
vdndSortableListOutputs
|
|
4433
5411
|
>
|
|
4434
5412
|
<vdnd-virtual-scroll
|
|
4435
5413
|
[items]="items()"
|
|
@@ -4442,16 +5420,17 @@ class VirtualSortableListComponent {
|
|
|
4442
5420
|
[autoStickyDraggedItem]="true"
|
|
4443
5421
|
[containerHeight]="containerHeight()"
|
|
4444
5422
|
[overscan]="overscan()"
|
|
5423
|
+
[recycleRows]="recycleRows()"
|
|
4445
5424
|
[autoScrollEnabled]="autoScrollEnabled()"
|
|
4446
5425
|
[autoScrollConfig]="autoScrollConfig()"
|
|
4447
5426
|
>
|
|
4448
5427
|
</vdnd-virtual-scroll>
|
|
4449
5428
|
</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 });
|
|
5429
|
+
`, 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
5430
|
}
|
|
4452
5431
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, decorators: [{
|
|
4453
5432
|
type: Component,
|
|
4454
|
-
args: [{ selector: 'vdnd-sortable-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [VirtualScrollContainerComponent, DroppableDirective], host: {
|
|
5433
|
+
args: [{ selector: 'vdnd-sortable-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [VirtualScrollContainerComponent, DroppableDirective, SortableListOutputsDirective], host: {
|
|
4455
5434
|
class: 'vdnd-sortable-list',
|
|
4456
5435
|
}, template: `
|
|
4457
5436
|
<div
|
|
@@ -4461,8 +5440,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4461
5440
|
[vdndDroppableData]="droppableData()"
|
|
4462
5441
|
[disabled]="disabled()"
|
|
4463
5442
|
[constrainToContainer]="constrainToContainer()"
|
|
4464
|
-
|
|
4465
|
-
(placeholderMove)="placeholderMove.emit($event)"
|
|
5443
|
+
vdndSortableListOutputs
|
|
4466
5444
|
>
|
|
4467
5445
|
<vdnd-virtual-scroll
|
|
4468
5446
|
[items]="items()"
|
|
@@ -4475,13 +5453,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4475
5453
|
[autoStickyDraggedItem]="true"
|
|
4476
5454
|
[containerHeight]="containerHeight()"
|
|
4477
5455
|
[overscan]="overscan()"
|
|
5456
|
+
[recycleRows]="recycleRows()"
|
|
4478
5457
|
[autoScrollEnabled]="autoScrollEnabled()"
|
|
4479
5458
|
[autoScrollConfig]="autoScrollConfig()"
|
|
4480
5459
|
>
|
|
4481
5460
|
</vdnd-virtual-scroll>
|
|
4482
5461
|
</div>
|
|
4483
5462
|
`, 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"] }] } });
|
|
5463
|
+
}], 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
5464
|
|
|
4486
5465
|
/**
|
|
4487
5466
|
* A virtual viewport component that provides efficient wrapper-based positioning
|
|
@@ -5129,22 +6108,25 @@ class KeyboardDragHandler {
|
|
|
5129
6108
|
this.#cleanupDocumentListener();
|
|
5130
6109
|
// Clear droppable metadata cache from this drag session
|
|
5131
6110
|
this.#deps.dragIndexCalculator.clearCache();
|
|
5132
|
-
|
|
5133
|
-
this.#deps.callbacks.onDragEnd({
|
|
6111
|
+
const event = {
|
|
5134
6112
|
draggableId: ctx.draggableId,
|
|
5135
6113
|
droppableId: this.#deps.callbacks.getParentDroppableId() ?? '',
|
|
5136
6114
|
cancelled: false,
|
|
5137
6115
|
data: ctx.data,
|
|
5138
6116
|
sourceIndex,
|
|
5139
6117
|
destinationIndex,
|
|
5140
|
-
}
|
|
6118
|
+
};
|
|
5141
6119
|
// The item stays in its source list when there is no valid target
|
|
5142
6120
|
const fallbackDroppableId = hasValidTarget
|
|
5143
6121
|
? activeDroppableId
|
|
5144
6122
|
: this.#deps.dragState.sourceDroppableId();
|
|
5145
|
-
this.#
|
|
5146
|
-
|
|
5147
|
-
|
|
6123
|
+
this.#endInZone(() => {
|
|
6124
|
+
this.#deps.callbacks.onDragEnd(event);
|
|
6125
|
+
// Ends the drag state, which delivers the drop
|
|
6126
|
+
this.#deps.keyboardDrag.completeKeyboardDrag();
|
|
6127
|
+
// Restore focus to the moved element after state updates
|
|
6128
|
+
this.#restoreFocus(ctx.draggableId, fallbackDroppableId);
|
|
6129
|
+
});
|
|
5148
6130
|
}
|
|
5149
6131
|
/**
|
|
5150
6132
|
* Cancel a keyboard drag operation.
|
|
@@ -5158,18 +6140,30 @@ class KeyboardDragHandler {
|
|
|
5158
6140
|
this.#cleanupDocumentListener();
|
|
5159
6141
|
// Clear droppable metadata cache from this drag session
|
|
5160
6142
|
this.#deps.dragIndexCalculator.clearCache();
|
|
5161
|
-
|
|
5162
|
-
this.#deps.callbacks.onDragEnd({
|
|
6143
|
+
const event = {
|
|
5163
6144
|
draggableId: ctx.draggableId,
|
|
5164
6145
|
droppableId: this.#deps.callbacks.getParentDroppableId() ?? '',
|
|
5165
6146
|
cancelled: true,
|
|
5166
6147
|
data: ctx.data,
|
|
5167
6148
|
sourceIndex,
|
|
5168
6149
|
destinationIndex: null,
|
|
6150
|
+
};
|
|
6151
|
+
this.#endInZone(() => {
|
|
6152
|
+
this.#deps.callbacks.onDragEnd(event);
|
|
6153
|
+
this.#deps.keyboardDrag.cancelKeyboardDrag();
|
|
6154
|
+
// Restore focus to the original element after state updates
|
|
6155
|
+
this.#restoreFocus(ctx.draggableId, sourceDroppableId);
|
|
5169
6156
|
});
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
6157
|
+
}
|
|
6158
|
+
/**
|
|
6159
|
+
* Run a drag end (dragEnd, the state reset that delivers drop, the focus restore) in one
|
|
6160
|
+
* entry into Angular's zone. The document listener runs outside it; with zone.js a
|
|
6161
|
+
* template listener marks its view dirty but schedules no render, and each entry renders
|
|
6162
|
+
* when it is left, so this renders the end once, with focus restored in that render. A
|
|
6163
|
+
* no-op when zoneless.
|
|
6164
|
+
*/
|
|
6165
|
+
#endInZone(end) {
|
|
6166
|
+
this.#deps.ngZone.run(end);
|
|
5173
6167
|
}
|
|
5174
6168
|
/**
|
|
5175
6169
|
* Teardown — remove any active document listener.
|
|
@@ -5197,7 +6191,7 @@ class KeyboardDragHandler {
|
|
|
5197
6191
|
}
|
|
5198
6192
|
else if (fallbackDroppableId) {
|
|
5199
6193
|
// Fallback: focus the first draggable in the list the item ended up in
|
|
5200
|
-
const container =
|
|
6194
|
+
const container = this.#deps.positionCalculator.getDroppableById(fallbackDroppableId);
|
|
5201
6195
|
const firstDraggable = container?.querySelector('[data-draggable-id]');
|
|
5202
6196
|
firstDraggable?.focus();
|
|
5203
6197
|
}
|
|
@@ -5291,8 +6285,14 @@ class PointerDragHandler {
|
|
|
5291
6285
|
#startPosition = null;
|
|
5292
6286
|
/** Whether we're currently tracking a potential drag */
|
|
5293
6287
|
#isTracking = false;
|
|
6288
|
+
/** The item the tracked press landed on */
|
|
6289
|
+
#pressedId = null;
|
|
6290
|
+
/** Whether the document and window listeners of a press are added */
|
|
6291
|
+
#listening = false;
|
|
5294
6292
|
/** Identifier of the finger that pressed, for touch gestures (other fingers are ignored) */
|
|
5295
6293
|
#touchId = null;
|
|
6294
|
+
/** Whether the tracked press is a touch (which shows no cursor) */
|
|
6295
|
+
#isTouch = false;
|
|
5296
6296
|
/** Bound event handlers for cleanup */
|
|
5297
6297
|
#boundPointerMove = null;
|
|
5298
6298
|
#boundPointerUp = null;
|
|
@@ -5318,6 +6318,13 @@ class PointerDragHandler {
|
|
|
5318
6318
|
getStartPosition() {
|
|
5319
6319
|
return this.#startPosition;
|
|
5320
6320
|
}
|
|
6321
|
+
/**
|
|
6322
|
+
* Whether the tracked press is a touch. Read by the directive when the drag starts: a touch
|
|
6323
|
+
* drag shows no cursor, so it gets no grabbing cursor.
|
|
6324
|
+
*/
|
|
6325
|
+
isTouchPress() {
|
|
6326
|
+
return this.#isTouch;
|
|
6327
|
+
}
|
|
5321
6328
|
/**
|
|
5322
6329
|
* Handle pointer down (mouse or touch).
|
|
5323
6330
|
* Called from the directive's host binding.
|
|
@@ -5373,6 +6380,8 @@ class PointerDragHandler {
|
|
|
5373
6380
|
return;
|
|
5374
6381
|
}
|
|
5375
6382
|
this.#isTracking = true;
|
|
6383
|
+
this.#pressedId = ctx.draggableId;
|
|
6384
|
+
this.#isTouch = isTouch;
|
|
5376
6385
|
if (touch) {
|
|
5377
6386
|
this.#touchId = touch.identifier;
|
|
5378
6387
|
this.#startPosition = { x: touch.clientX, y: touch.clientY };
|
|
@@ -5387,9 +6396,10 @@ class PointerDragHandler {
|
|
|
5387
6396
|
if (delay > 0) {
|
|
5388
6397
|
this.#delayTimerId = setTimeout(() => {
|
|
5389
6398
|
this.#delayTimerId = null;
|
|
5390
|
-
// Another drag started while this press was held
|
|
5391
|
-
// as ready to drag
|
|
5392
|
-
if (this.#deps.callbacks.isOtherDragActive()
|
|
6399
|
+
// Another drag started while this press was held, or the press no longer applies (see
|
|
6400
|
+
// #isStalePress): drop it rather than show it as ready to drag.
|
|
6401
|
+
if (this.#deps.callbacks.isOtherDragActive() ||
|
|
6402
|
+
this.#isStalePress(this.#deps.getContext())) {
|
|
5393
6403
|
this.cleanup();
|
|
5394
6404
|
return;
|
|
5395
6405
|
}
|
|
@@ -5418,6 +6428,7 @@ class PointerDragHandler {
|
|
|
5418
6428
|
window.addEventListener('blur', this.#boundWindowBlur);
|
|
5419
6429
|
document.addEventListener('visibilitychange', this.#boundVisibilityChange);
|
|
5420
6430
|
});
|
|
6431
|
+
this.#listening = true;
|
|
5421
6432
|
}
|
|
5422
6433
|
/**
|
|
5423
6434
|
* Remove listeners + cancel timers.
|
|
@@ -5425,14 +6436,18 @@ class PointerDragHandler {
|
|
|
5425
6436
|
*/
|
|
5426
6437
|
cleanup() {
|
|
5427
6438
|
this.#isTracking = false;
|
|
6439
|
+
this.#pressedId = null;
|
|
5428
6440
|
this.#startPosition = null;
|
|
5429
6441
|
this.#touchId = null;
|
|
6442
|
+
this.#isTouch = false;
|
|
5430
6443
|
this.#deps.callbacks.onPendingChange(false); // Clear pending state on cleanup
|
|
5431
6444
|
this.#cancelDelayTimer();
|
|
5432
|
-
//
|
|
5433
|
-
|
|
6445
|
+
// Nothing to remove unless a press added listeners (most draggables are never pressed; a
|
|
6446
|
+
// virtual list destroys one each time a row scrolls out). Server rendering adds none.
|
|
6447
|
+
if (!this.#listening) {
|
|
5434
6448
|
return;
|
|
5435
6449
|
}
|
|
6450
|
+
this.#listening = false;
|
|
5436
6451
|
// Remove event listeners
|
|
5437
6452
|
if (this.#boundPointerMove) {
|
|
5438
6453
|
document.removeEventListener('mousemove', this.#boundPointerMove);
|
|
@@ -5483,9 +6498,14 @@ class PointerDragHandler {
|
|
|
5483
6498
|
else {
|
|
5484
6499
|
position = { x: event.clientX, y: event.clientY };
|
|
5485
6500
|
}
|
|
5486
|
-
const ctx = this.#deps.getContext();
|
|
5487
6501
|
// Check if we've moved past the threshold
|
|
5488
6502
|
if (!this.#deps.callbacks.isDragging() && this.#startPosition) {
|
|
6503
|
+
// Read only before the drag starts, not on every move of a drag: nothing in it matters then
|
|
6504
|
+
const ctx = this.#deps.getContext();
|
|
6505
|
+
if (this.#isStalePress(ctx)) {
|
|
6506
|
+
this.cleanup();
|
|
6507
|
+
return;
|
|
6508
|
+
}
|
|
5489
6509
|
const distance = Math.sqrt(Math.pow(position.x - this.#startPosition.x, 2) +
|
|
5490
6510
|
Math.pow(position.y - this.#startPosition.y, 2));
|
|
5491
6511
|
if (distance < ctx.dragThreshold) {
|
|
@@ -5537,13 +6557,25 @@ class PointerDragHandler {
|
|
|
5537
6557
|
}
|
|
5538
6558
|
// Only prevent default if we were actually dragging
|
|
5539
6559
|
// Otherwise, allow native touch behavior (like scroll momentum) to complete
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
6560
|
+
// The drag end runs consumer handlers (dragEnd, drop); stop tracking even if one throws
|
|
6561
|
+
try {
|
|
6562
|
+
if (this.#deps.callbacks.isDragging()) {
|
|
6563
|
+
event.preventDefault();
|
|
6564
|
+
// The system took the touch (an incoming call, an OS gesture, too many fingers): the
|
|
6565
|
+
// user never released, so this is a cancel, not a drop
|
|
6566
|
+
this.#deps.callbacks.onDragEnd(event.type === 'touchcancel');
|
|
6567
|
+
}
|
|
5545
6568
|
}
|
|
5546
|
-
|
|
6569
|
+
finally {
|
|
6570
|
+
this.cleanup();
|
|
6571
|
+
}
|
|
6572
|
+
}
|
|
6573
|
+
/**
|
|
6574
|
+
* Whether the tracked press no longer applies to the draggable: it renders another item now, or
|
|
6575
|
+
* it left the page (a virtual list recycled or pooled its row).
|
|
6576
|
+
*/
|
|
6577
|
+
#isStalePress(ctx) {
|
|
6578
|
+
return ctx.draggableId !== this.#pressedId || !ctx.element.isConnected;
|
|
5547
6579
|
}
|
|
5548
6580
|
/**
|
|
5549
6581
|
* Cancel the delay timer if active.
|
|
@@ -5560,9 +6592,12 @@ class PointerDragHandler {
|
|
|
5560
6592
|
*/
|
|
5561
6593
|
#onEscapeKeyDown(event) {
|
|
5562
6594
|
if (event.key === 'Escape' && this.#deps.callbacks.isDragging()) {
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
6595
|
+
try {
|
|
6596
|
+
this.#deps.callbacks.onDragEnd(true);
|
|
6597
|
+
}
|
|
6598
|
+
finally {
|
|
6599
|
+
this.cleanup();
|
|
6600
|
+
}
|
|
5566
6601
|
}
|
|
5567
6602
|
}
|
|
5568
6603
|
/**
|
|
@@ -5572,10 +6607,14 @@ class PointerDragHandler {
|
|
|
5572
6607
|
if (!this.#isTracking) {
|
|
5573
6608
|
return;
|
|
5574
6609
|
}
|
|
5575
|
-
|
|
5576
|
-
this.#deps.callbacks.
|
|
6610
|
+
try {
|
|
6611
|
+
if (this.#deps.callbacks.isDragging()) {
|
|
6612
|
+
this.#deps.callbacks.onDragEnd(true);
|
|
6613
|
+
}
|
|
6614
|
+
}
|
|
6615
|
+
finally {
|
|
6616
|
+
this.cleanup();
|
|
5577
6617
|
}
|
|
5578
|
-
this.cleanup();
|
|
5579
6618
|
}
|
|
5580
6619
|
/**
|
|
5581
6620
|
* Treat the page being hidden (tab switch, app switch on mobile) as focus loss.
|
|
@@ -5587,10 +6626,20 @@ class PointerDragHandler {
|
|
|
5587
6626
|
}
|
|
5588
6627
|
/**
|
|
5589
6628
|
* 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.
|
|
6629
|
+
* leave a list undefined, so a missing list counts as empty. Read by index, without copying the
|
|
6630
|
+
* list or creating an iterator: this runs for every touch move.
|
|
5591
6631
|
*/
|
|
5592
6632
|
#trackedTouch(touches) {
|
|
5593
|
-
|
|
6633
|
+
if (!touches) {
|
|
6634
|
+
return null;
|
|
6635
|
+
}
|
|
6636
|
+
// eslint-disable-next-line @typescript-eslint/prefer-for-of -- for...of creates an iterator
|
|
6637
|
+
for (let i = 0; i < touches.length; i++) {
|
|
6638
|
+
if (touches[i].identifier === this.#touchId) {
|
|
6639
|
+
return touches[i];
|
|
6640
|
+
}
|
|
6641
|
+
}
|
|
6642
|
+
return null;
|
|
5594
6643
|
}
|
|
5595
6644
|
/**
|
|
5596
6645
|
* The finger that pressed this draggable: of the touches that started with this event, the
|
|
@@ -5604,6 +6653,26 @@ class PointerDragHandler {
|
|
|
5604
6653
|
}
|
|
5605
6654
|
}
|
|
5606
6655
|
|
|
6656
|
+
/** Key names as Angular's `keydown.<key>` bindings spell them, for the `event.key` values that differ */
|
|
6657
|
+
const KEY_NAMES = {
|
|
6658
|
+
' ': 'space',
|
|
6659
|
+
Esc: 'escape',
|
|
6660
|
+
'\x1B': 'escape',
|
|
6661
|
+
Up: 'arrowup',
|
|
6662
|
+
Down: 'arrowdown',
|
|
6663
|
+
Left: 'arrowleft',
|
|
6664
|
+
Right: 'arrowright',
|
|
6665
|
+
};
|
|
6666
|
+
/** The keys an item handles */
|
|
6667
|
+
const HANDLED_KEYS = new Set([
|
|
6668
|
+
'space',
|
|
6669
|
+
'enter',
|
|
6670
|
+
'arrowup',
|
|
6671
|
+
'arrowdown',
|
|
6672
|
+
'arrowleft',
|
|
6673
|
+
'arrowright',
|
|
6674
|
+
'escape',
|
|
6675
|
+
]);
|
|
5607
6676
|
/**
|
|
5608
6677
|
* Makes an element draggable within the virtual scroll drag-and-drop system.
|
|
5609
6678
|
*
|
|
@@ -5680,9 +6749,16 @@ class DraggableDirective {
|
|
|
5680
6749
|
dragStart = output();
|
|
5681
6750
|
/** Emits when drag ends */
|
|
5682
6751
|
dragEnd = output();
|
|
5683
|
-
/**
|
|
5684
|
-
#
|
|
5685
|
-
|
|
6752
|
+
/** The item whose press is ready to drag (its delay has passed) */
|
|
6753
|
+
#pendingId = signal(null, ...(ngDevMode ? [{ debugName: "#pendingId" }] : []));
|
|
6754
|
+
/**
|
|
6755
|
+
* Whether this element is in the "ready to drag" state (delay has passed). Tied to the pressed
|
|
6756
|
+
* item: a recycled row that renders another item is not ready to drag.
|
|
6757
|
+
*/
|
|
6758
|
+
isPending = computed(() => {
|
|
6759
|
+
const pendingId = this.#pendingId();
|
|
6760
|
+
return pendingId !== null && pendingId === this.vdndDraggable();
|
|
6761
|
+
}, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
|
|
5686
6762
|
/** Whether this element is currently being dragged (based on global drag state) */
|
|
5687
6763
|
isDragging = computed(() => {
|
|
5688
6764
|
const draggedItem = this.#dragState.draggedItem();
|
|
@@ -5692,6 +6768,62 @@ class DraggableDirective {
|
|
|
5692
6768
|
#pointerHandler;
|
|
5693
6769
|
/** Set by ngOnInit, which creates the handlers */
|
|
5694
6770
|
#initialized = false;
|
|
6771
|
+
/**
|
|
6772
|
+
* Keys pressed on the item: one listener, added outside Angular's zone, instead of a
|
|
6773
|
+
* `(keydown.<key>)` host binding per key, as Angular adds a listener to every row for each.
|
|
6774
|
+
* Like those bindings, it ignores a key pressed with Shift, Ctrl, Alt or Meta, runs the handler
|
|
6775
|
+
* of a key it handles in the zone (so zone.js apps still render after it), prevents the key's
|
|
6776
|
+
* default action when the handler returns false, and reports what the handler throws to the
|
|
6777
|
+
* ErrorHandler. Added in the constructor, so it runs before the element's template listeners,
|
|
6778
|
+
* as host listeners do.
|
|
6779
|
+
*/
|
|
6780
|
+
#onKeydown = (event) => {
|
|
6781
|
+
if (!this.#initialized || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
|
|
6782
|
+
return;
|
|
6783
|
+
}
|
|
6784
|
+
const key = event.key ? (KEY_NAMES[event.key] ?? event.key.toLowerCase()) : '';
|
|
6785
|
+
if (!HANDLED_KEYS.has(key)) {
|
|
6786
|
+
return;
|
|
6787
|
+
}
|
|
6788
|
+
this.#ngZone.run(() => {
|
|
6789
|
+
try {
|
|
6790
|
+
if (this.#handleKey(key, event) === false) {
|
|
6791
|
+
event.preventDefault();
|
|
6792
|
+
}
|
|
6793
|
+
}
|
|
6794
|
+
catch (error) {
|
|
6795
|
+
this.#envInjector.get(ErrorHandler).handleError(error);
|
|
6796
|
+
}
|
|
6797
|
+
});
|
|
6798
|
+
};
|
|
6799
|
+
/**
|
|
6800
|
+
* Touch presses on the item. Added programmatically, not as a host binding (which Angular adds
|
|
6801
|
+
* without options, so non-passive): a swipe that starts on an element with a non-passive
|
|
6802
|
+
* touchstart listener can't scroll until the main thread has run it. With a drag delay the press
|
|
6803
|
+
* never cancels the scroll (the page must scroll when the user swipes before the delay passes),
|
|
6804
|
+
* so the listener is passive; without one it prevents the default action and is non-passive.
|
|
6805
|
+
* Like the keydown listener, it runs the handler in the zone (as the mousedown host binding
|
|
6806
|
+
* does) and reports what it throws to the ErrorHandler. Added once the inputs are set, so unlike
|
|
6807
|
+
* a host listener it runs after the element's template listeners.
|
|
6808
|
+
*/
|
|
6809
|
+
#onTouchStart = (event) => {
|
|
6810
|
+
if (!this.#initialized) {
|
|
6811
|
+
return;
|
|
6812
|
+
}
|
|
6813
|
+
this.#ngZone.run(() => {
|
|
6814
|
+
try {
|
|
6815
|
+
this.onPointerDown(event, true);
|
|
6816
|
+
}
|
|
6817
|
+
catch (error) {
|
|
6818
|
+
this.#envInjector.get(ErrorHandler).handleError(error);
|
|
6819
|
+
}
|
|
6820
|
+
});
|
|
6821
|
+
};
|
|
6822
|
+
/** Whether the touchstart listener is passive, or null while it isn't added */
|
|
6823
|
+
#touchStartPassive = null;
|
|
6824
|
+
constructor() {
|
|
6825
|
+
this.#ngZone.runOutsideAngular(() => this.#elementRef.nativeElement.addEventListener('keydown', this.#onKeydown));
|
|
6826
|
+
}
|
|
5695
6827
|
/** Cached constraint flag from source droppable */
|
|
5696
6828
|
#constrainToContainer = false;
|
|
5697
6829
|
/** Element whose rect is used for clamping (scroll container or droppable itself) */
|
|
@@ -5702,9 +6834,7 @@ class DraggableDirective {
|
|
|
5702
6834
|
* Update the pending state and emit the change event.
|
|
5703
6835
|
*/
|
|
5704
6836
|
#setPending(pending) {
|
|
5705
|
-
|
|
5706
|
-
this.#isPending.set(pending);
|
|
5707
|
-
}
|
|
6837
|
+
this.#pendingId.set(pending ? this.vdndDraggable() : null);
|
|
5708
6838
|
}
|
|
5709
6839
|
/**
|
|
5710
6840
|
* Find the element to use for container constraint clamping.
|
|
@@ -5729,7 +6859,8 @@ class DraggableDirective {
|
|
|
5729
6859
|
ngZone: this.#ngZone,
|
|
5730
6860
|
envInjector: this.#envInjector,
|
|
5731
6861
|
callbacks: {
|
|
5732
|
-
onDragStart: (event) => this.dragStart.emit(event),
|
|
6862
|
+
onDragStart: (event) => this.#ngZone.run(() => this.dragStart.emit(event)),
|
|
6863
|
+
// The keyboard handler ends the drag inside the zone (dragEnd, drop, focus restore)
|
|
5733
6864
|
onDragEnd: (event) => this.dragEnd.emit(event),
|
|
5734
6865
|
getParentDroppableId: () => this.#getParentDroppableId(),
|
|
5735
6866
|
calculateSourceIndex: (el, droppable) => this.#calculateSourceIndex(el, droppable),
|
|
@@ -5758,6 +6889,7 @@ class DraggableDirective {
|
|
|
5758
6889
|
},
|
|
5759
6890
|
getContext: () => ({
|
|
5760
6891
|
element: this.#elementRef.nativeElement,
|
|
6892
|
+
draggableId: this.vdndDraggable(),
|
|
5761
6893
|
groupName: this.#effectiveGroup(),
|
|
5762
6894
|
disabled: this.disabled(),
|
|
5763
6895
|
dragHandle: this.dragHandle(),
|
|
@@ -5766,8 +6898,14 @@ class DraggableDirective {
|
|
|
5766
6898
|
}),
|
|
5767
6899
|
});
|
|
5768
6900
|
this.#initialized = true;
|
|
6901
|
+
this.#listenForTouchStart();
|
|
6902
|
+
}
|
|
6903
|
+
ngOnChanges() {
|
|
6904
|
+
this.#listenForTouchStart();
|
|
5769
6905
|
}
|
|
5770
6906
|
ngOnDestroy() {
|
|
6907
|
+
this.#elementRef.nativeElement.removeEventListener('keydown', this.#onKeydown);
|
|
6908
|
+
this.#elementRef.nativeElement.removeEventListener('touchstart', this.#onTouchStart);
|
|
5771
6909
|
// Destroyed before its first change detection: there are no handlers yet, and its inputs
|
|
5772
6910
|
// have no values (reading a bound ID would throw).
|
|
5773
6911
|
if (!this.#initialized) {
|
|
@@ -5780,6 +6918,35 @@ class DraggableDirective {
|
|
|
5780
6918
|
this.#pointerHandler.destroy();
|
|
5781
6919
|
this.#keyboardHandler.destroy();
|
|
5782
6920
|
}
|
|
6921
|
+
/**
|
|
6922
|
+
* Add the touchstart listener, or re-add it when the drag delay changes whether it can be
|
|
6923
|
+
* passive. Lifecycle hooks, not an effect: creating a view effect schedules a change detection
|
|
6924
|
+
* that visits every ancestor, and a virtual list creates rows on each scroll step.
|
|
6925
|
+
*/
|
|
6926
|
+
#listenForTouchStart() {
|
|
6927
|
+
// The handler cancels the scroll of a touch press only when there is no delay
|
|
6928
|
+
const passive = this.dragDelay() !== 0;
|
|
6929
|
+
if (passive === this.#touchStartPassive) {
|
|
6930
|
+
return;
|
|
6931
|
+
}
|
|
6932
|
+
const element = this.#elementRef.nativeElement;
|
|
6933
|
+
element.removeEventListener('touchstart', this.#onTouchStart);
|
|
6934
|
+
this.#ngZone.runOutsideAngular(() => element.addEventListener('touchstart', this.#onTouchStart, { passive }));
|
|
6935
|
+
this.#touchStartPassive = passive;
|
|
6936
|
+
}
|
|
6937
|
+
/** Run the handler of a key the item handles (see HANDLED_KEYS), returning what it returns. */
|
|
6938
|
+
#handleKey(key, event) {
|
|
6939
|
+
switch (key) {
|
|
6940
|
+
case 'space':
|
|
6941
|
+
return this.onKeyboardActivate(event);
|
|
6942
|
+
case 'enter':
|
|
6943
|
+
return this.onEnterKey(event);
|
|
6944
|
+
case 'escape':
|
|
6945
|
+
return this.onEscape();
|
|
6946
|
+
default:
|
|
6947
|
+
return this.onArrowKey(event);
|
|
6948
|
+
}
|
|
6949
|
+
}
|
|
5783
6950
|
/**
|
|
5784
6951
|
* Handle pointer down (mouse or touch).
|
|
5785
6952
|
*/
|
|
@@ -5946,7 +7113,7 @@ class DraggableDirective {
|
|
|
5946
7113
|
height: rect.height,
|
|
5947
7114
|
width: rect.width,
|
|
5948
7115
|
data: this.vdndDraggableData(),
|
|
5949
|
-
}, position, grabOffset, lockAxis, activeDroppableId, initialPlaceholderId, initialPlaceholderIndex, sourceIndex, undefined, lockAxis ? startPos : undefined);
|
|
7116
|
+
}, position, grabOffset, lockAxis, activeDroppableId, initialPlaceholderId, initialPlaceholderIndex, sourceIndex, undefined, lockAxis ? startPos : undefined, this.#pointerHandler.isTouchPress());
|
|
5950
7117
|
// Start the scheduler RAF loop (drives pointer-move updates + autoscroll participant).
|
|
5951
7118
|
// onTick is called each frame: when cursor is dirty, run full #updateDrag.
|
|
5952
7119
|
this.#scheduler.start((cursor, cursorDirty) => {
|
|
@@ -5957,13 +7124,16 @@ class DraggableDirective {
|
|
|
5957
7124
|
// Start auto-scroll monitoring — registers as a scheduler participant.
|
|
5958
7125
|
this.#autoScroll.startMonitoring(() => this.#recalculatePlaceholder());
|
|
5959
7126
|
// Emit drag start event
|
|
5960
|
-
|
|
7127
|
+
// Pointer listeners run outside Angular's zone. With zone.js a template listener marks
|
|
7128
|
+
// its view dirty but schedules no render, so emit inside the zone (a no-op when zoneless).
|
|
7129
|
+
const event = {
|
|
5961
7130
|
draggableId: this.vdndDraggable(),
|
|
5962
7131
|
droppableId: parentDroppableId ?? '',
|
|
5963
7132
|
data: this.vdndDraggableData(),
|
|
5964
7133
|
position,
|
|
5965
7134
|
sourceIndex,
|
|
5966
|
-
}
|
|
7135
|
+
};
|
|
7136
|
+
this.#ngZone.run(() => this.dragStart.emit(event));
|
|
5967
7137
|
}
|
|
5968
7138
|
/**
|
|
5969
7139
|
* Calculate the source index of the dragged element BEFORE it's hidden.
|
|
@@ -6179,22 +7349,27 @@ class DraggableDirective {
|
|
|
6179
7349
|
sourceDroppableId: this.#dragState.sourceDroppableId(),
|
|
6180
7350
|
activeDroppableId: this.#dragState.activeDroppableId(),
|
|
6181
7351
|
});
|
|
6182
|
-
|
|
6183
|
-
this.dragEnd.emit({
|
|
7352
|
+
const event = {
|
|
6184
7353
|
draggableId: this.vdndDraggable(),
|
|
6185
7354
|
droppableId: this.#getParentDroppableId() ?? '',
|
|
6186
7355
|
cancelled,
|
|
6187
7356
|
data: this.vdndDraggableData(),
|
|
6188
7357
|
sourceIndex,
|
|
6189
7358
|
destinationIndex,
|
|
7359
|
+
};
|
|
7360
|
+
// dragEnd, then the state reset that delivers drop, in ONE zone entry: listeners run
|
|
7361
|
+
// outside Angular's zone, and with zone.js each entry renders when it is left, so
|
|
7362
|
+
// separate entries would render the drag end twice. A no-op when zoneless.
|
|
7363
|
+
this.#ngZone.run(() => {
|
|
7364
|
+
this.dragEnd.emit(event);
|
|
7365
|
+
// Clear drag state - this triggers isDragging computed to become false
|
|
7366
|
+
if (cancelled) {
|
|
7367
|
+
this.#dragState.cancelDrag();
|
|
7368
|
+
}
|
|
7369
|
+
else {
|
|
7370
|
+
this.#dragState.endDrag();
|
|
7371
|
+
}
|
|
6190
7372
|
});
|
|
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
7373
|
}
|
|
6199
7374
|
/**
|
|
6200
7375
|
* Get the parent droppable ID.
|
|
@@ -6208,7 +7383,7 @@ class DraggableDirective {
|
|
|
6208
7383
|
return droppable ? this.#positionCalculator.getDroppableId(droppable) : null;
|
|
6209
7384
|
}
|
|
6210
7385
|
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)"
|
|
7386
|
+
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
7387
|
}
|
|
6213
7388
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DraggableDirective, decorators: [{
|
|
6214
7389
|
type: Directive,
|
|
@@ -6224,17 +7399,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
6224
7399
|
'[attr.aria-grabbed]': 'isDragging() ? "true" : "false"',
|
|
6225
7400
|
'[tabindex]': 'disabled() ? -1 : 0',
|
|
6226
7401
|
'(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
7402
|
},
|
|
6236
7403
|
}]
|
|
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"] }] } });
|
|
7404
|
+
}], 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
7405
|
|
|
6239
7406
|
/**
|
|
6240
7407
|
* Directive that marks an element as a scrollable container for virtual scrolling.
|
|
@@ -6548,38 +7715,47 @@ class VirtualForDirective {
|
|
|
6548
7715
|
const end = Math.min(total - 1, first + visible + overscan);
|
|
6549
7716
|
return { start, end };
|
|
6550
7717
|
}, ...(ngDevMode ? [{ debugName: "#renderRange" }] : []));
|
|
6551
|
-
/**
|
|
6552
|
-
|
|
6553
|
-
|
|
7718
|
+
/**
|
|
7719
|
+
* Every item's track key, in order. Computed once per items change, and only when something
|
|
7720
|
+
* reads it: a dynamic-height strategy or a drag. A fixed-height strategy needs only the count.
|
|
7721
|
+
*/
|
|
7722
|
+
#itemKeys = computed(() => {
|
|
6554
7723
|
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) */
|
|
7724
|
+
return this.vdndVirtualForOf().map((item, i) => trackByFn(i, item));
|
|
7725
|
+
}, ...(ngDevMode ? [{ debugName: "#itemKeys" }] : []));
|
|
7726
|
+
/**
|
|
7727
|
+
* Index of the dragged item in this list (-1 if not present or not dragging). The index where
|
|
7728
|
+
* the drag found its item is checked first: it spares a search of every track key.
|
|
7729
|
+
*/
|
|
6562
7730
|
#draggedItemIndex = computed(() => {
|
|
6563
7731
|
const draggedItem = this.#dragState.draggedItem();
|
|
6564
7732
|
if (!draggedItem)
|
|
6565
7733
|
return -1;
|
|
6566
|
-
const
|
|
6567
|
-
|
|
7734
|
+
const items = this.vdndVirtualForOf();
|
|
7735
|
+
const sourceIndex = this.#dragState.sourceIndex();
|
|
7736
|
+
if (sourceIndex !== null && sourceIndex >= 0 && sourceIndex < items.length) {
|
|
7737
|
+
const item = items[sourceIndex];
|
|
7738
|
+
if (this.vdndVirtualForTrackBy()(sourceIndex, item) === draggedItem.draggableId ||
|
|
7739
|
+
(draggedItem.data !== null && draggedItem.data !== undefined && item === draggedItem.data)) {
|
|
7740
|
+
return sourceIndex;
|
|
7741
|
+
}
|
|
7742
|
+
}
|
|
7743
|
+
// The last item with the key, as an index map filled in order would give
|
|
7744
|
+
const byId = this.#itemKeys().lastIndexOf(draggedItem.draggableId);
|
|
7745
|
+
if (byId >= 0) {
|
|
6568
7746
|
return byId;
|
|
6569
7747
|
}
|
|
6570
7748
|
const data = draggedItem.data;
|
|
6571
7749
|
if (data !== null && data !== undefined) {
|
|
6572
|
-
return
|
|
7750
|
+
return items.indexOf(data);
|
|
6573
7751
|
}
|
|
6574
7752
|
return -1;
|
|
6575
7753
|
}, ...(ngDevMode ? [{ debugName: "#draggedItemIndex" }] : []));
|
|
6576
7754
|
constructor() {
|
|
6577
|
-
// Keep strategy
|
|
7755
|
+
// Keep the strategy's items in sync. A fixed-height strategy needs only their count, which
|
|
7756
|
+
// spares computing every item's track key on each items change (each drop).
|
|
6578
7757
|
effect(() => {
|
|
6579
|
-
|
|
6580
|
-
const trackByFn = this.vdndVirtualForTrackBy();
|
|
6581
|
-
const keys = items.map((item, i) => trackByFn(i, item));
|
|
6582
|
-
this.#strategy().setItemKeys(keys);
|
|
7758
|
+
setStrategyItems(this.#strategy(), this.vdndVirtualForOf().length, () => this.#itemKeys());
|
|
6583
7759
|
});
|
|
6584
7760
|
// Register strategy with drag index calculator for accurate position lookups
|
|
6585
7761
|
effect((onCleanup) => {
|
|
@@ -6595,12 +7771,13 @@ class VirtualForDirective {
|
|
|
6595
7771
|
// correctly skip the hidden item — not just findIndexAtOffset.
|
|
6596
7772
|
effect(() => {
|
|
6597
7773
|
const strategy = this.#strategy();
|
|
6598
|
-
const draggedIndex = this.#draggedItemIndex();
|
|
6599
7774
|
const droppableId = this.#effectiveDroppableId();
|
|
6600
7775
|
const sourceDroppableId = this.#dragState.sourceDroppableId();
|
|
6601
7776
|
const isDragging = this.#dragState.isDragging();
|
|
6602
7777
|
const isSourceList = isDragging && droppableId !== undefined && droppableId === sourceDroppableId;
|
|
6603
|
-
|
|
7778
|
+
// Only the source list looks for the dragged item (in all its keys)
|
|
7779
|
+
const draggedIndex = isSourceList ? this.#draggedItemIndex() : -1;
|
|
7780
|
+
if (draggedIndex >= 0) {
|
|
6604
7781
|
strategy.setExcludedIndex(draggedIndex);
|
|
6605
7782
|
}
|
|
6606
7783
|
else {
|
|
@@ -6799,14 +7976,12 @@ class VirtualForDirective {
|
|
|
6799
7976
|
strategy.version();
|
|
6800
7977
|
const placeholderIndex = this.#placeholderIndex();
|
|
6801
7978
|
const showPlaceholder = this.#shouldShowPlaceholder();
|
|
6802
|
-
const draggedIndex = this.#draggedItemIndex();
|
|
6803
7979
|
const droppableId = this.#effectiveDroppableId();
|
|
6804
7980
|
const sourceDroppableId = this.#dragState.sourceDroppableId();
|
|
6805
7981
|
const isSourceList = droppableId ? droppableId === sourceDroppableId : true;
|
|
6806
|
-
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
draggedIndex < items.length;
|
|
7982
|
+
// Only the source list keeps the dragged item rendered, so only it looks for the item
|
|
7983
|
+
const draggedIndex = this.#dragState.isDragging() && isSourceList ? this.#draggedItemIndex() : -1;
|
|
7984
|
+
const shouldKeepDragged = draggedIndex >= 0 && draggedIndex < items.length;
|
|
6810
7985
|
// Snapshot positions before the DOM changes so displaced items can slide
|
|
6811
7986
|
this.#shiftAnimator?.beforeUpdate(this.#dragState.isDragging(), placeholderIndex);
|
|
6812
7987
|
// Notify viewport of render start index for wrapper positioning
|
|
@@ -6916,14 +8091,8 @@ class VirtualForDirective {
|
|
|
6916
8091
|
// Move unused views to pool
|
|
6917
8092
|
for (const [key, view] of this.#activeViews) {
|
|
6918
8093
|
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
8094
|
this.#activeViews.delete(key);
|
|
8095
|
+
this.#poolView(view);
|
|
6927
8096
|
}
|
|
6928
8097
|
}
|
|
6929
8098
|
// Remove placeholder from DOM if not needed
|
|
@@ -6935,6 +8104,7 @@ class VirtualForDirective {
|
|
|
6935
8104
|
let viewContainerIndex = 0;
|
|
6936
8105
|
let placeholderDomPosition = -1;
|
|
6937
8106
|
const renderedKeys = new Set();
|
|
8107
|
+
const changedViews = [];
|
|
6938
8108
|
for (const entry of itemsToRender) {
|
|
6939
8109
|
if (entry.type === 'placeholder') {
|
|
6940
8110
|
placeholderDomPosition = viewContainerIndex;
|
|
@@ -6945,7 +8115,7 @@ class VirtualForDirective {
|
|
|
6945
8115
|
continue;
|
|
6946
8116
|
}
|
|
6947
8117
|
renderedKeys.add(entry.key);
|
|
6948
|
-
const view = this.#getOrCreateView(entry.key, entry.context);
|
|
8118
|
+
const view = this.#getOrCreateView(entry.key, entry.context, changedViews);
|
|
6949
8119
|
// Ensure view is at correct position in ViewContainerRef
|
|
6950
8120
|
const currentIndex = this.#viewContainer.indexOf(view);
|
|
6951
8121
|
if (currentIndex !== viewContainerIndex) {
|
|
@@ -6970,8 +8140,33 @@ class VirtualForDirective {
|
|
|
6970
8140
|
}
|
|
6971
8141
|
viewContainerIndex++;
|
|
6972
8142
|
}
|
|
8143
|
+
this.#renderChangedViews(changedViews);
|
|
6973
8144
|
return placeholderDomPosition;
|
|
6974
8145
|
}
|
|
8146
|
+
/**
|
|
8147
|
+
* Take a row's view out of the container and pool it. It keeps its element, so its shift
|
|
8148
|
+
* animation is cancelled (the item it renders next must not slide from this one's position),
|
|
8149
|
+
* and a drop animation that hides it shows it again.
|
|
8150
|
+
*/
|
|
8151
|
+
#poolView(view) {
|
|
8152
|
+
const index = this.#viewContainer.indexOf(view);
|
|
8153
|
+
if (index >= 0) {
|
|
8154
|
+
this.#viewContainer.detach(index);
|
|
8155
|
+
}
|
|
8156
|
+
this.#unobserveViewElements(view);
|
|
8157
|
+
// A leave animation (`animate.leave`) keeps the row in the DOM, and Angular removes it when
|
|
8158
|
+
// the animation ends: in the row of another item by then, if the view were reused. A row
|
|
8159
|
+
// whose element is dragged (its draggable ID is not its track key) is destroyed too, which
|
|
8160
|
+
// cancels the drag instead of handing it to another item.
|
|
8161
|
+
const draggedElement = this.#dragState.draggedItem()?.element;
|
|
8162
|
+
if (view.rootNodes.some((node) => node.parentNode !== null || (!!draggedElement && node.contains(draggedElement)))) {
|
|
8163
|
+
view.destroy();
|
|
8164
|
+
return;
|
|
8165
|
+
}
|
|
8166
|
+
this.#cancelShiftAnimation(view);
|
|
8167
|
+
revealDropTargetIn(view.rootNodes);
|
|
8168
|
+
this.#viewPool.push(view);
|
|
8169
|
+
}
|
|
6975
8170
|
/**
|
|
6976
8171
|
* Clamp target indices for move/insert to avoid out-of-range operations when
|
|
6977
8172
|
* reconciliation is given invalid duplicate keys or transiently inconsistent view state.
|
|
@@ -6997,28 +8192,59 @@ class VirtualForDirective {
|
|
|
6997
8192
|
}
|
|
6998
8193
|
/**
|
|
6999
8194
|
* Get an existing view or create/recycle one from the pool.
|
|
8195
|
+
* Adds the view to `changedViews` when it needs rendering: new, taken from the pool, or with a
|
|
8196
|
+
* changed context.
|
|
7000
8197
|
*/
|
|
7001
|
-
#getOrCreateView(key, context) {
|
|
7002
|
-
|
|
8198
|
+
#getOrCreateView(key, context, changedViews) {
|
|
8199
|
+
const activeView = this.#activeViews.get(key);
|
|
8200
|
+
let view = activeView ?? this.#viewPool.pop();
|
|
7003
8201
|
if (view) {
|
|
7004
|
-
//
|
|
7005
|
-
|
|
7006
|
-
view.
|
|
8202
|
+
// A pooled view was detached, so change detection skipped it: render it even when it
|
|
8203
|
+
// comes back with the same context, as what its template shows may have changed since
|
|
8204
|
+
if (this.#updateContext(view.context, context) || !activeView) {
|
|
8205
|
+
changedViews.push(view);
|
|
8206
|
+
}
|
|
7007
8207
|
}
|
|
7008
8208
|
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);
|
|
8209
|
+
view = this.#templateRef.createEmbeddedView(context);
|
|
8210
|
+
changedViews.push(view);
|
|
7019
8211
|
}
|
|
8212
|
+
this.#activeViews.set(key, view);
|
|
7020
8213
|
return view;
|
|
7021
8214
|
}
|
|
8215
|
+
/** Copy the fields of `next` into `context`. Returns whether any of them changed. */
|
|
8216
|
+
#updateContext(context, next) {
|
|
8217
|
+
if (Object.is(context.$implicit, next.$implicit) &&
|
|
8218
|
+
context.index === next.index &&
|
|
8219
|
+
context.first === next.first &&
|
|
8220
|
+
context.last === next.last &&
|
|
8221
|
+
context.count === next.count) {
|
|
8222
|
+
return false;
|
|
8223
|
+
}
|
|
8224
|
+
context.$implicit = next.$implicit;
|
|
8225
|
+
context.index = next.index;
|
|
8226
|
+
context.first = next.first;
|
|
8227
|
+
context.last = next.last;
|
|
8228
|
+
context.count = next.count;
|
|
8229
|
+
return true;
|
|
8230
|
+
}
|
|
8231
|
+
/**
|
|
8232
|
+
* Render the views whose context changed, and only those. `markForCheck()` would mark every
|
|
8233
|
+
* ancestor view up to the root as well, re-rendering all of them (and every row of the list)
|
|
8234
|
+
* on each scroll step. Untracked, so the rows' signal reads do not become this effect's
|
|
8235
|
+
* dependencies.
|
|
8236
|
+
*/
|
|
8237
|
+
#renderChangedViews(changedViews) {
|
|
8238
|
+
if (changedViews.length === 0)
|
|
8239
|
+
return;
|
|
8240
|
+
untracked(() => {
|
|
8241
|
+
for (const view of changedViews) {
|
|
8242
|
+
if (!view.destroyed) {
|
|
8243
|
+
view.detectChanges();
|
|
8244
|
+
}
|
|
8245
|
+
}
|
|
8246
|
+
});
|
|
8247
|
+
}
|
|
7022
8248
|
/**
|
|
7023
8249
|
* Apply absolute positioning styles to a view's root nodes.
|
|
7024
8250
|
*/
|