ngx-virtual-dnd 3.2.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/ngx-virtual-dnd.mjs +2214 -733
- package/fesm2022/ngx-virtual-dnd.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-virtual-dnd.d.ts +96 -34
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { signal, InjectionToken,
|
|
3
|
-
import { NgTemplateOutlet } from '@angular/common';
|
|
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
|
+
import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Initial state for the drag state service.
|
|
@@ -21,7 +21,10 @@ const INITIAL_DRAG_STATE = {
|
|
|
21
21
|
keyboardTargetIndex: null,
|
|
22
22
|
};
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
24
|
+
* The value of every `placeholderId` the library sets. Positions are reported as indexes
|
|
25
|
+
* (`placeholderIndex`, `DropDestination.index`).
|
|
26
|
+
* @deprecated Goes with the deprecated `placeholderId` fields. Use the indexes. Will be removed in
|
|
27
|
+
* the next major version.
|
|
25
28
|
*/
|
|
26
29
|
const END_OF_LIST = 'END_OF_LIST';
|
|
27
30
|
|
|
@@ -72,9 +75,16 @@ class FixedHeightStrategy {
|
|
|
72
75
|
// No-op for fixed height strategy
|
|
73
76
|
}
|
|
74
77
|
setItemKeys(keys) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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;
|
|
78
88
|
this.#version.update((v) => v + 1);
|
|
79
89
|
}
|
|
80
90
|
}
|
|
@@ -105,8 +115,15 @@ class FixedHeightStrategy {
|
|
|
105
115
|
* Cache for measured item heights in dynamic virtual scrolling.
|
|
106
116
|
*
|
|
107
117
|
* Tracks heights by trackBy key (survives reordering). Maintains a cumulative
|
|
108
|
-
* offset prefix-sum array
|
|
109
|
-
*
|
|
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.
|
|
110
127
|
*
|
|
111
128
|
* This is a plain class (not a service) — one instance per virtual scroll.
|
|
112
129
|
*/
|
|
@@ -115,12 +132,21 @@ class HeightCache {
|
|
|
115
132
|
#estimatedHeight;
|
|
116
133
|
/** Map from trackBy key to measured height */
|
|
117
134
|
#heightsByKey = new Map();
|
|
118
|
-
/**
|
|
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
|
+
*/
|
|
119
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;
|
|
120
144
|
/** Prefix sums: offsets[i] = cumulative height of items 0..i-1 */
|
|
121
145
|
#offsets = [];
|
|
122
|
-
/**
|
|
123
|
-
#
|
|
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;
|
|
124
150
|
/** Index excluded during same-list drag (-1 = none) */
|
|
125
151
|
#excludedIndex = -1;
|
|
126
152
|
constructor(estimatedHeight) {
|
|
@@ -139,21 +165,31 @@ class HeightCache {
|
|
|
139
165
|
* This must be called whenever items change order or the array changes.
|
|
140
166
|
*/
|
|
141
167
|
setKeys(keys) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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;
|
|
157
193
|
return true;
|
|
158
194
|
}
|
|
159
195
|
/**
|
|
@@ -165,7 +201,21 @@ class HeightCache {
|
|
|
165
201
|
if (existing === height)
|
|
166
202
|
return false;
|
|
167
203
|
this.#heightsByKey.set(key, height);
|
|
168
|
-
|
|
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
|
+
}
|
|
169
219
|
return true;
|
|
170
220
|
}
|
|
171
221
|
/**
|
|
@@ -183,18 +233,18 @@ class HeightCache {
|
|
|
183
233
|
* Accounts for the excluded index during same-list drag.
|
|
184
234
|
*/
|
|
185
235
|
getOffset(index) {
|
|
186
|
-
this.#
|
|
236
|
+
const count = this.#keys.length;
|
|
187
237
|
if (this.#excludedIndex < 0) {
|
|
188
238
|
// No exclusion — direct lookup
|
|
189
|
-
return index <
|
|
239
|
+
return index < count ? this.#offsetAt(index) : this.#getExactTotalHeight();
|
|
190
240
|
}
|
|
191
241
|
// With exclusion: subtract the excluded item's height from offsets
|
|
192
242
|
// at or after the excluded index
|
|
193
243
|
if (index <= this.#excludedIndex) {
|
|
194
|
-
return this.#
|
|
244
|
+
return this.#offsetAt(index) ?? 0;
|
|
195
245
|
}
|
|
196
246
|
// index > excludedIndex: offset = raw offset for (index) minus excluded item's height
|
|
197
|
-
const rawOffset = index <
|
|
247
|
+
const rawOffset = index < count ? this.#offsetAt(index) : this.#getExactTotalHeight();
|
|
198
248
|
const excludedHeight = this.getHeight(this.#excludedIndex);
|
|
199
249
|
return rawOffset - excludedHeight;
|
|
200
250
|
}
|
|
@@ -205,7 +255,6 @@ class HeightCache {
|
|
|
205
255
|
* Uses O(1) prefix-sum lookup when possible.
|
|
206
256
|
*/
|
|
207
257
|
getTotalHeight(itemCount) {
|
|
208
|
-
this.#rebuildIfDirty();
|
|
209
258
|
if (itemCount <= 0)
|
|
210
259
|
return 0;
|
|
211
260
|
const keyCount = this.#keys.length;
|
|
@@ -216,20 +265,24 @@ class HeightCache {
|
|
|
216
265
|
return this.#getTotalHeightRaw() + (itemCount - keyCount) * this.#estimatedHeight;
|
|
217
266
|
}
|
|
218
267
|
// Partial: prefix sum up to itemCount
|
|
219
|
-
return this.#
|
|
268
|
+
return this.#offsetAt(itemCount - 1) + this.getHeight(itemCount - 1);
|
|
220
269
|
}
|
|
221
270
|
/**
|
|
222
271
|
* Find the first visible index for a given scrollTop.
|
|
223
272
|
* Uses binary search on the prefix-sum offsets (excluding the excluded item).
|
|
224
273
|
*/
|
|
225
274
|
findFirstVisibleIndex(scrollTop) {
|
|
226
|
-
this.#rebuildIfDirty();
|
|
227
275
|
const count = this.#keys.length;
|
|
228
276
|
if (count === 0)
|
|
229
277
|
return 0;
|
|
230
|
-
//
|
|
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.
|
|
231
284
|
let lo = 0;
|
|
232
|
-
let hi = count - 1;
|
|
285
|
+
let hi = Math.min(count, this.#validCount) - 1;
|
|
233
286
|
let result = 0;
|
|
234
287
|
while (lo <= hi) {
|
|
235
288
|
const mid = (lo + hi) >>> 1;
|
|
@@ -275,7 +328,6 @@ class HeightCache {
|
|
|
275
328
|
* Used for drag index calculations with variable heights.
|
|
276
329
|
*/
|
|
277
330
|
findIndexAtOffset(offset) {
|
|
278
|
-
this.#rebuildIfDirty();
|
|
279
331
|
const count = this.#keys.length;
|
|
280
332
|
if (count === 0)
|
|
281
333
|
return 0;
|
|
@@ -292,7 +344,7 @@ class HeightCache {
|
|
|
292
344
|
if (excludedIndex < 0 || excludedIndex >= count) {
|
|
293
345
|
return this.#findFirstIndexWithBottomPastOffset(offset, 0, count - 1);
|
|
294
346
|
}
|
|
295
|
-
const excludedOffset = this.#
|
|
347
|
+
const excludedOffset = this.#offsetAt(excludedIndex);
|
|
296
348
|
if (offset < excludedOffset && excludedIndex > 0) {
|
|
297
349
|
return this.#findFirstIndexWithBottomPastOffset(offset, 0, excludedIndex - 1);
|
|
298
350
|
}
|
|
@@ -313,6 +365,17 @@ class HeightCache {
|
|
|
313
365
|
this.#excludedIndex = nextIndex;
|
|
314
366
|
return true;
|
|
315
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
|
+
}
|
|
316
379
|
/**
|
|
317
380
|
* Find first logical index in [start, end] whose bottom edge is past offset.
|
|
318
381
|
* Returns one-past-end when no match is found.
|
|
@@ -321,8 +384,15 @@ class HeightCache {
|
|
|
321
384
|
if (start > end) {
|
|
322
385
|
return start;
|
|
323
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
|
+
}
|
|
324
394
|
let lo = start;
|
|
325
|
-
let hi = end;
|
|
395
|
+
let hi = Math.min(end, lastValid);
|
|
326
396
|
let result = end + 1;
|
|
327
397
|
while (lo <= hi) {
|
|
328
398
|
const mid = (lo + hi) >>> 1;
|
|
@@ -337,27 +407,64 @@ class HeightCache {
|
|
|
337
407
|
}
|
|
338
408
|
return result;
|
|
339
409
|
}
|
|
340
|
-
/**
|
|
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
|
+
*/
|
|
341
414
|
#getTotalHeightRaw() {
|
|
342
|
-
this.#
|
|
343
|
-
const len = this.#offsets.length;
|
|
344
|
-
if (len === 0)
|
|
415
|
+
if (this.#keys.length === 0)
|
|
345
416
|
return 0;
|
|
346
|
-
return this.#
|
|
417
|
+
return this.#totalHeight ?? this.#getExactTotalHeight();
|
|
347
418
|
}
|
|
348
|
-
/**
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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() {
|
|
353
424
|
const count = this.#keys.length;
|
|
354
|
-
if (
|
|
355
|
-
|
|
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;
|
|
356
454
|
}
|
|
357
|
-
let cumulative = 0;
|
|
358
|
-
|
|
455
|
+
let cumulative = i === 0 ? 0 : this.#offsets[i - 1] + this.getHeight(i - 1);
|
|
456
|
+
while (i < end) {
|
|
359
457
|
this.#offsets[i] = cumulative;
|
|
360
|
-
|
|
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);
|
|
361
468
|
}
|
|
362
469
|
}
|
|
363
470
|
}
|
|
@@ -451,6 +558,14 @@ const VDND_SCROLL_CONTAINER = new InjectionToken('VDND_SCROLL_CONTAINER');
|
|
|
451
558
|
* a viewport component that handles wrapper-based positioning.
|
|
452
559
|
*/
|
|
453
560
|
const VDND_VIRTUAL_VIEWPORT = new InjectionToken('VDND_VIRTUAL_VIEWPORT');
|
|
561
|
+
/**
|
|
562
|
+
* Internal, not exported from the package. Provided by vdnd-virtual-viewport next to
|
|
563
|
+
* VDND_VIRTUAL_VIEWPORT: its rows start contentOffset px down its scroll area while its
|
|
564
|
+
* scrollTop() is the raw position, so VirtualForDirective subtracts the offset when the viewport
|
|
565
|
+
* it injects is this one. A token instead of an instanceof check keeps the viewport component out
|
|
566
|
+
* of apps that don't use it.
|
|
567
|
+
*/
|
|
568
|
+
const VDND_OFFSET_ROWS_VIEWPORT = new InjectionToken('VDND_OFFSET_ROWS_VIEWPORT');
|
|
454
569
|
|
|
455
570
|
/** Default shift animation duration (ms) used when the config omits `shiftDuration`. */
|
|
456
571
|
const DEFAULT_SHIFT_DURATION = 200;
|
|
@@ -476,6 +591,168 @@ const DEFAULT_DROP_EASING = 'cubic-bezier(0.2, 0, 0, 1)';
|
|
|
476
591
|
*/
|
|
477
592
|
const VDND_ANIMATION_CONFIG = new InjectionToken('VDND_ANIMATION_CONFIG');
|
|
478
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
|
+
|
|
479
756
|
const INITIAL_CORE_STATE = {
|
|
480
757
|
isDragging: false,
|
|
481
758
|
draggedItem: null,
|
|
@@ -498,6 +775,7 @@ const INITIAL_CORE_STATE = {
|
|
|
498
775
|
* draggedItem, etc.).
|
|
499
776
|
*/
|
|
500
777
|
class DragStateService {
|
|
778
|
+
#droppableRegistry = inject(DroppableRegistryService);
|
|
501
779
|
/** Core state signal — contains all rarely-changing properties */
|
|
502
780
|
#state = signal(INITIAL_CORE_STATE, ...(ngDevMode ? [{ debugName: "#state" }] : []));
|
|
503
781
|
/** High-frequency cursor position — updated at 60fps during pointer drag */
|
|
@@ -506,7 +784,7 @@ class DragStateService {
|
|
|
506
784
|
#keyboardTargetIndex = signal(null, ...(ngDevMode ? [{ debugName: "#keyboardTargetIndex" }] : []));
|
|
507
785
|
/** ID of the droppable currently being hovered over — changes on droppable crossings */
|
|
508
786
|
#activeDroppableId = signal(null, ...(ngDevMode ? [{ debugName: "#activeDroppableId" }] : []));
|
|
509
|
-
/**
|
|
787
|
+
/** `END_OF_LIST` while a droppable is targeted, otherwise null; see placeholderIndex */
|
|
510
788
|
#placeholderId = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderId" }] : []));
|
|
511
789
|
/** Index where the placeholder should be inserted — updated on each placeholder move */
|
|
512
790
|
#placeholderIndex = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderIndex" }] : []));
|
|
@@ -514,7 +792,7 @@ class DragStateService {
|
|
|
514
792
|
#endedDragState = signal(null, ...(ngDevMode ? [{ debugName: "#endedDragState" }] : []));
|
|
515
793
|
/** Flag indicating if the last drag was cancelled (not dropped) */
|
|
516
794
|
#wasCancelled = signal(false, ...(ngDevMode ? [{ debugName: "#wasCancelled" }] : []));
|
|
517
|
-
/** Whether the last drag was cancelled (
|
|
795
|
+
/** Whether the last drag was cancelled (a cancelled drag delivers no drop) */
|
|
518
796
|
wasCancelled = this.#wasCancelled.asReadonly();
|
|
519
797
|
/** Snapshot captured synchronously before the last drag reset. */
|
|
520
798
|
endedDragState = this.#endedDragState.asReadonly();
|
|
@@ -530,7 +808,11 @@ class DragStateService {
|
|
|
530
808
|
sourceIndex = computed(() => this.#state().sourceIndex, ...(ngDevMode ? [{ debugName: "sourceIndex" }] : []));
|
|
531
809
|
/** ID of the droppable currently being hovered over */
|
|
532
810
|
activeDroppableId = this.#activeDroppableId.asReadonly();
|
|
533
|
-
/**
|
|
811
|
+
/**
|
|
812
|
+
* `END_OF_LIST` while a droppable is targeted, otherwise null.
|
|
813
|
+
* @deprecated It never identifies an item. Use `placeholderIndex`. Will be removed in the next
|
|
814
|
+
* major version.
|
|
815
|
+
*/
|
|
534
816
|
placeholderId = this.#placeholderId.asReadonly();
|
|
535
817
|
/** Index where the placeholder should be inserted */
|
|
536
818
|
placeholderIndex = this.#placeholderIndex.asReadonly();
|
|
@@ -572,6 +854,7 @@ class DragStateService {
|
|
|
572
854
|
}
|
|
573
855
|
/**
|
|
574
856
|
* Start a drag operation.
|
|
857
|
+
* `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
|
|
575
858
|
*/
|
|
576
859
|
startDrag(item, cursorPosition, grabOffset, lockAxis, activeDroppableId, placeholderId, placeholderIndex, sourceIndex, isKeyboardDrag, axisLockPosition) {
|
|
577
860
|
// Reset terminal drag metadata at start of new drag
|
|
@@ -595,6 +878,7 @@ class DragStateService {
|
|
|
595
878
|
}
|
|
596
879
|
/**
|
|
597
880
|
* Update the drag position and targets.
|
|
881
|
+
* `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
|
|
598
882
|
*/
|
|
599
883
|
updateDragPosition(update) {
|
|
600
884
|
if (!this.#state().isDragging) {
|
|
@@ -619,7 +903,9 @@ class DragStateService {
|
|
|
619
903
|
this.#activeDroppableId.set(droppableId);
|
|
620
904
|
}
|
|
621
905
|
/**
|
|
622
|
-
* Update just the placeholder
|
|
906
|
+
* Update just the placeholder ID.
|
|
907
|
+
* @deprecated Sets only the deprecated `placeholderId`. Will be removed in the next major
|
|
908
|
+
* version.
|
|
623
909
|
*/
|
|
624
910
|
setPlaceholder(placeholderId) {
|
|
625
911
|
if (!this.#state().isDragging) {
|
|
@@ -633,6 +919,7 @@ class DragStateService {
|
|
|
633
919
|
* Used by the autoscroll scroll-only fast path: when a scroll fires, the cursor has not
|
|
634
920
|
* moved, so only the placeholder index needs recalculation. Skipping the cursor and
|
|
635
921
|
* active-droppable writes avoids spurious re-evaluation of computeds that read those signals.
|
|
922
|
+
* `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
|
|
636
923
|
*/
|
|
637
924
|
updateScrollOnlyPlaceholder(placeholderId, placeholderIndex) {
|
|
638
925
|
if (!this.#state().isDragging) {
|
|
@@ -642,18 +929,34 @@ class DragStateService {
|
|
|
642
929
|
this.#placeholderIndex.set(placeholderIndex);
|
|
643
930
|
}
|
|
644
931
|
/**
|
|
645
|
-
* End the drag operation and reset state (normal drop)
|
|
932
|
+
* End the drag operation and reset state (normal drop), then deliver the drop: the target
|
|
933
|
+
* droppable (`activeDroppableId`) emits `drop` before this returns.
|
|
646
934
|
*/
|
|
647
935
|
endDrag() {
|
|
648
|
-
|
|
936
|
+
// Only a drag in progress ends. Again (e.g. from a drop handler) it would overwrite the
|
|
937
|
+
// ended drag, which the drop animation reads, with an empty one.
|
|
938
|
+
if (!this.#state().isDragging) {
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const endedState = this.getStateSnapshot();
|
|
942
|
+
this.#endedDragState.set(endedState);
|
|
649
943
|
this.#wasCancelled.set(false);
|
|
650
944
|
this.#resetHighFrequencySignals();
|
|
651
945
|
this.#state.set(INITIAL_CORE_STATE);
|
|
946
|
+
// After the reset, so the drop handler sees the drag as over. Delivered here rather than
|
|
947
|
+
// from a droppable effect on the next change detection: by then the target may already
|
|
948
|
+
// be destroyed or re-created (a drop zone rendered only while dragging, a list replaced
|
|
949
|
+
// by a (dragEnd) handler), and every other droppable would have to tell a new drop from
|
|
950
|
+
// an old one.
|
|
951
|
+
this.#droppableRegistry.deliverDrop(endedState);
|
|
652
952
|
}
|
|
653
953
|
/**
|
|
654
954
|
* Cancel the drag operation (escape key, disabled, etc.).
|
|
655
955
|
*/
|
|
656
956
|
cancelDrag() {
|
|
957
|
+
if (!this.#state().isDragging) {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
657
960
|
this.#endedDragState.set(this.getStateSnapshot());
|
|
658
961
|
this.#wasCancelled.set(true);
|
|
659
962
|
this.#resetHighFrequencySignals();
|
|
@@ -740,32 +1043,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
740
1043
|
}]
|
|
741
1044
|
}], ctorParameters: () => [] });
|
|
742
1045
|
|
|
743
|
-
/**
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
* CSS attribute selectors need string-specific escaping that differs from `CSS.escape()`.
|
|
747
|
-
* Filtering candidates by attribute presence avoids selector syntax errors for IDs containing
|
|
748
|
-
* quotes, brackets, backslashes, or other selector-sensitive characters.
|
|
749
|
-
*/
|
|
750
|
-
function queryByAttribute(root, attributeName, attributeValue) {
|
|
751
|
-
const candidates = root.querySelectorAll(`[${attributeName}]`);
|
|
752
|
-
for (const candidate of candidates) {
|
|
753
|
-
if (candidate.getAttribute(attributeName) === attributeValue) {
|
|
754
|
-
return candidate;
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
return null;
|
|
1046
|
+
/** The parent lookups stop below `<body>`: it and `<html>` never count as list or item. */
|
|
1047
|
+
function isPageRoot(element) {
|
|
1048
|
+
return element.tagName === 'BODY' || element.tagName === 'HTML';
|
|
758
1049
|
}
|
|
759
|
-
function queryAllByAttribute(root, attributeName, attributeValue) {
|
|
760
|
-
return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
|
|
761
|
-
}
|
|
762
|
-
|
|
763
1050
|
/**
|
|
764
1051
|
* Service for calculating drop positions and finding elements at cursor positions.
|
|
765
1052
|
* This is the core algorithm that makes virtual scroll + drag-and-drop work together.
|
|
766
1053
|
*/
|
|
767
1054
|
class PositionCalculatorService {
|
|
768
1055
|
#ngZone = inject(NgZone);
|
|
1056
|
+
#registry = inject(DroppableRegistryService);
|
|
769
1057
|
/** Data attribute used to identify droppable elements */
|
|
770
1058
|
#DROPPABLE_ID_ATTR = 'data-droppable-id';
|
|
771
1059
|
/** Data attribute used to identify droppable groups */
|
|
@@ -774,12 +1062,19 @@ class PositionCalculatorService {
|
|
|
774
1062
|
#DROPPABLE_DISABLED_ATTR = 'data-droppable-disabled';
|
|
775
1063
|
/** Data attribute used to identify draggable elements */
|
|
776
1064
|
#DRAGGABLE_ID_ATTR = 'data-draggable-id';
|
|
777
|
-
/** Maximum DOM levels to traverse when looking for parent elements */
|
|
778
|
-
#MAX_PARENT_TRAVERSAL = 15;
|
|
779
1065
|
/** Reusable result object for getNearEdge (avoids per-frame allocation) */
|
|
780
1066
|
#nearEdgeResult = { top: false, bottom: false, left: false, right: false };
|
|
781
1067
|
/** Active drag session rect snapshot, or null when no drag is in progress. */
|
|
782
1068
|
#session = null;
|
|
1069
|
+
constructor() {
|
|
1070
|
+
// A droppable (un)registering mid-drag makes the next hit-test re-read the candidates.
|
|
1071
|
+
// Removed on destroy in case the service is provided below root.
|
|
1072
|
+
const removeListener = this.#registry.onChange((group) => this.notifyCandidatesChanged(group));
|
|
1073
|
+
inject(DestroyRef).onDestroy(() => {
|
|
1074
|
+
removeListener();
|
|
1075
|
+
this.endDragSession();
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
783
1078
|
/**
|
|
784
1079
|
* Begin a drag session: snapshot the candidate droppables for `groupName` and
|
|
785
1080
|
* their rects once, then watch for viewport changes (scroll/resize) that would
|
|
@@ -799,7 +1094,7 @@ class PositionCalculatorService {
|
|
|
799
1094
|
this.#session = {
|
|
800
1095
|
groupName,
|
|
801
1096
|
candidates,
|
|
802
|
-
rects:
|
|
1097
|
+
rects: this.#measureRects(candidates, []),
|
|
803
1098
|
dirty: false,
|
|
804
1099
|
candidatesStale: false,
|
|
805
1100
|
onViewportChange,
|
|
@@ -845,10 +1140,11 @@ class PositionCalculatorService {
|
|
|
845
1140
|
}
|
|
846
1141
|
}
|
|
847
1142
|
/**
|
|
848
|
-
* Re-
|
|
849
|
-
* rects. Unlike {@link invalidateDroppableRects} (which only refreshes
|
|
850
|
-
* picks up droppables added or removed since the snapshot was captured at
|
|
851
|
-
*
|
|
1143
|
+
* Re-read the candidate droppable *list* for the active session from the registry and
|
|
1144
|
+
* re-read their rects. Unlike {@link invalidateDroppableRects} (which only refreshes
|
|
1145
|
+
* rects), this picks up droppables added or removed since the snapshot was captured at
|
|
1146
|
+
* drag start. Registration changes already trigger it on the next hit-test; this is the
|
|
1147
|
+
* manual escape hatch.
|
|
852
1148
|
*
|
|
853
1149
|
* Safe to call when no session is active (no-op).
|
|
854
1150
|
*/
|
|
@@ -858,19 +1154,18 @@ class PositionCalculatorService {
|
|
|
858
1154
|
return;
|
|
859
1155
|
}
|
|
860
1156
|
session.candidates = this.#queryDroppables(session.groupName);
|
|
861
|
-
session.rects = session.candidates
|
|
1157
|
+
session.rects = this.#measureRects(session.candidates, []);
|
|
862
1158
|
session.dirty = false;
|
|
863
1159
|
session.candidatesStale = false;
|
|
864
1160
|
this.#observeCandidates(session);
|
|
865
1161
|
}
|
|
866
1162
|
/**
|
|
867
1163
|
* Signal that the set of droppables for `groupName` may have changed (a droppable
|
|
868
|
-
* registered or unregistered mid-drag). Defers the
|
|
869
|
-
*
|
|
870
|
-
* one has its data attributes applied — keeping this notification path cheap.
|
|
1164
|
+
* registered or unregistered mid-drag). Defers the re-read to the next hit-test, keeping
|
|
1165
|
+
* this notification path cheap.
|
|
871
1166
|
*
|
|
872
|
-
* Called
|
|
873
|
-
*
|
|
1167
|
+
* Called for every droppable registry change. No-op when the active session belongs to
|
|
1168
|
+
* a different group or no session is active.
|
|
874
1169
|
*/
|
|
875
1170
|
notifyCandidatesChanged(groupName) {
|
|
876
1171
|
if (this.#session && this.#session.groupName === groupName) {
|
|
@@ -900,23 +1195,20 @@ class PositionCalculatorService {
|
|
|
900
1195
|
this.refreshCandidates();
|
|
901
1196
|
}
|
|
902
1197
|
else if (session.dirty) {
|
|
903
|
-
|
|
904
|
-
session.rects[i] = this.#measureRect(session.candidates[i]);
|
|
905
|
-
}
|
|
1198
|
+
this.#measureRects(session.candidates, session.rects);
|
|
906
1199
|
session.dirty = false;
|
|
907
1200
|
}
|
|
908
1201
|
return this.#hitTest(x, y, session.candidates, session.rects);
|
|
909
1202
|
}
|
|
910
1203
|
// No active session: one-shot geometric query (still avoids elementFromPoint).
|
|
911
1204
|
const candidates = this.#queryDroppables(groupName);
|
|
912
|
-
|
|
913
|
-
return this.#hitTest(x, y, candidates, rects);
|
|
1205
|
+
return this.#hitTest(x, y, candidates, this.#measureRects(candidates, []));
|
|
914
1206
|
}
|
|
915
1207
|
/**
|
|
916
1208
|
* Look up a droppable element by its ID.
|
|
917
1209
|
*
|
|
918
|
-
* When a drag session is active, searches the cached candidate list
|
|
919
|
-
*
|
|
1210
|
+
* When a drag session is active, searches the cached candidate list. Otherwise looks the
|
|
1211
|
+
* ID up in the droppable registry. Neither queries the document.
|
|
920
1212
|
*
|
|
921
1213
|
* Intended for the autoscroll scroll-only fast path, where the active droppable is already
|
|
922
1214
|
* known and only the placeholder index needs recalculation.
|
|
@@ -930,13 +1222,10 @@ class PositionCalculatorService {
|
|
|
930
1222
|
}
|
|
931
1223
|
return null;
|
|
932
1224
|
}
|
|
933
|
-
|
|
934
|
-
return null;
|
|
935
|
-
}
|
|
936
|
-
return queryByAttribute(document, this.#DROPPABLE_ID_ATTR, id);
|
|
1225
|
+
return this.#registry.getById(id);
|
|
937
1226
|
}
|
|
938
1227
|
/**
|
|
939
|
-
*
|
|
1228
|
+
* All rendered droppables belonging to a group, in document order, from the registry.
|
|
940
1229
|
*
|
|
941
1230
|
* All group members are included — the disabled check happens per-frame in
|
|
942
1231
|
* {@link #hitTest} rather than here, because a droppable can be disabled or
|
|
@@ -944,30 +1233,46 @@ class PositionCalculatorService {
|
|
|
944
1233
|
* `(dragStart)` handler, which fires AFTER the candidate snapshot is captured).
|
|
945
1234
|
*/
|
|
946
1235
|
#queryDroppables(groupName) {
|
|
947
|
-
|
|
948
|
-
return [];
|
|
949
|
-
}
|
|
950
|
-
return queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
|
|
1236
|
+
return this.#registry.getGroup(groupName);
|
|
951
1237
|
}
|
|
952
1238
|
/**
|
|
953
|
-
*
|
|
954
|
-
* ancestor
|
|
955
|
-
* still hit-tests over its full unclipped rect (issue #23
|
|
956
|
-
*
|
|
957
|
-
* `#hitTest` bounds check can never match it.
|
|
1239
|
+
* The ancestor that clips a candidate's hit-test rect: its nearest `.vdnd-scrollable`
|
|
1240
|
+
* ancestor, or null when it has none or is one itself. Without clipping a droppable scrolled
|
|
1241
|
+
* mostly out of a clipping container still hit-tests over its full unclipped rect (issue #23
|
|
1242
|
+
* case 3).
|
|
958
1243
|
*/
|
|
959
|
-
#
|
|
960
|
-
const rect = el.getBoundingClientRect();
|
|
1244
|
+
#clipOf(el) {
|
|
961
1245
|
const scrollable = el.closest('.vdnd-scrollable');
|
|
962
|
-
|
|
963
|
-
|
|
1246
|
+
return scrollable === el ? null : scrollable;
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Measure the candidates' hit-test rects into `rects`, each clipped to its clip ancestor (see
|
|
1250
|
+
* `#clipOf`). Consecutive candidates in one scroller (lists side by side in it) read its rect
|
|
1251
|
+
* once. The ancestor is looked up on each pass: a list moved to another scroller mid-drag keeps
|
|
1252
|
+
* its registration. The intersection is built as a plain DOMRect; an empty intersection yields
|
|
1253
|
+
* a negative width/height so the `#hitTest` bounds check can never match it.
|
|
1254
|
+
*/
|
|
1255
|
+
#measureRects(candidates, rects) {
|
|
1256
|
+
let lastClip = null;
|
|
1257
|
+
let lastClipRect = null;
|
|
1258
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
1259
|
+
const rect = candidates[i].getBoundingClientRect();
|
|
1260
|
+
const clip = this.#clipOf(candidates[i]);
|
|
1261
|
+
if (!clip) {
|
|
1262
|
+
rects[i] = rect;
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
if (clip !== lastClip || !lastClipRect) {
|
|
1266
|
+
lastClip = clip;
|
|
1267
|
+
lastClipRect = clip.getBoundingClientRect();
|
|
1268
|
+
}
|
|
1269
|
+
const top = Math.max(rect.top, lastClipRect.top);
|
|
1270
|
+
const left = Math.max(rect.left, lastClipRect.left);
|
|
1271
|
+
const right = Math.min(rect.right, lastClipRect.right);
|
|
1272
|
+
const bottom = Math.min(rect.bottom, lastClipRect.bottom);
|
|
1273
|
+
rects[i] = new DOMRect(left, top, right - left, bottom - top);
|
|
964
1274
|
}
|
|
965
|
-
|
|
966
|
-
const top = Math.max(rect.top, clip.top);
|
|
967
|
-
const left = Math.max(rect.left, clip.left);
|
|
968
|
-
const right = Math.min(rect.right, clip.right);
|
|
969
|
-
const bottom = Math.min(rect.bottom, clip.bottom);
|
|
970
|
-
return new DOMRect(left, top, right - left, bottom - top);
|
|
1275
|
+
return rects;
|
|
971
1276
|
}
|
|
972
1277
|
/**
|
|
973
1278
|
* Create a ResizeObserver (run outside Angular per CLAUDE.md) that marks the session's
|
|
@@ -1071,15 +1376,19 @@ class PositionCalculatorService {
|
|
|
1071
1376
|
* @returns The droppable parent element, or null if none found
|
|
1072
1377
|
*/
|
|
1073
1378
|
getDroppableParent(element, groupName) {
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1379
|
+
// An empty group name never matches (an empty attribute value is no group)
|
|
1380
|
+
if (!groupName) {
|
|
1381
|
+
return null;
|
|
1382
|
+
}
|
|
1383
|
+
// Match on the attribute's presence and compare the value here, so the group name needs no
|
|
1384
|
+
// selector escaping. Nearer droppables of other groups are skipped.
|
|
1385
|
+
const selector = `[${this.#DROPPABLE_GROUP_ATTR}]`;
|
|
1386
|
+
let current = element.closest(selector);
|
|
1387
|
+
while (current && !isPageRoot(current)) {
|
|
1388
|
+
if (current.getAttribute(this.#DROPPABLE_GROUP_ATTR) === groupName) {
|
|
1079
1389
|
return current;
|
|
1080
1390
|
}
|
|
1081
|
-
current = current.parentElement;
|
|
1082
|
-
count++;
|
|
1391
|
+
current = current.parentElement?.closest(selector) ?? null;
|
|
1083
1392
|
}
|
|
1084
1393
|
return null;
|
|
1085
1394
|
}
|
|
@@ -1090,17 +1399,9 @@ class PositionCalculatorService {
|
|
|
1090
1399
|
* @returns The draggable parent element, or null if none found
|
|
1091
1400
|
*/
|
|
1092
1401
|
getDraggableParent(element) {
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
const draggableId = current.getAttribute(this.#DRAGGABLE_ID_ATTR);
|
|
1097
|
-
if (draggableId) {
|
|
1098
|
-
return current;
|
|
1099
|
-
}
|
|
1100
|
-
current = current.parentElement;
|
|
1101
|
-
count++;
|
|
1102
|
-
}
|
|
1103
|
-
return null;
|
|
1402
|
+
const selector = `[${this.#DRAGGABLE_ID_ATTR}]:not([${this.#DRAGGABLE_ID_ATTR}=""])`;
|
|
1403
|
+
const draggable = element.closest(selector);
|
|
1404
|
+
return draggable && !isPageRoot(draggable) ? draggable : null;
|
|
1104
1405
|
}
|
|
1105
1406
|
/**
|
|
1106
1407
|
* Get the draggable ID from an element.
|
|
@@ -1173,21 +1474,20 @@ class PositionCalculatorService {
|
|
|
1173
1474
|
// current container — otherwise a container disabled mid-drag (its own index becomes
|
|
1174
1475
|
// -1) would trap the drag with no reachable neighbour. Disabled droppables are skipped
|
|
1175
1476
|
// as *targets* during the outward scan below instead.
|
|
1176
|
-
const allDroppables =
|
|
1477
|
+
const allDroppables = this.#registry.getGroup(groupName);
|
|
1177
1478
|
if (allDroppables.length <= 1) {
|
|
1178
1479
|
return null;
|
|
1179
1480
|
}
|
|
1180
1481
|
// Get bounding rects, IDs, and disabled state, sorted by X position
|
|
1181
1482
|
const droppableInfos = [];
|
|
1182
1483
|
allDroppables.forEach((el) => {
|
|
1183
|
-
const
|
|
1184
|
-
const id = htmlEl.getAttribute(this.#DROPPABLE_ID_ATTR);
|
|
1484
|
+
const id = el.getAttribute(this.#DROPPABLE_ID_ATTR);
|
|
1185
1485
|
if (id) {
|
|
1186
1486
|
droppableInfos.push({
|
|
1187
|
-
element:
|
|
1487
|
+
element: el,
|
|
1188
1488
|
id,
|
|
1189
|
-
rect:
|
|
1190
|
-
disabled: this.#isDroppableDisabled(
|
|
1489
|
+
rect: el.getBoundingClientRect(),
|
|
1490
|
+
disabled: this.#isDroppableDisabled(el),
|
|
1191
1491
|
});
|
|
1192
1492
|
}
|
|
1193
1493
|
});
|
|
@@ -1231,7 +1531,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
1231
1531
|
args: [{
|
|
1232
1532
|
providedIn: 'root',
|
|
1233
1533
|
}]
|
|
1234
|
-
}] });
|
|
1534
|
+
}], ctorParameters: () => [] });
|
|
1235
1535
|
|
|
1236
1536
|
/**
|
|
1237
1537
|
* Single RAF loop that coordinates all drag-frame work in ordered phases:
|
|
@@ -1244,6 +1544,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
1244
1544
|
* latest cursor and a `cursorDirty` flag. Callers skip their hit-test when
|
|
1245
1545
|
* the flag is false, so autoscroll-only frames (cursor stationary) incur
|
|
1246
1546
|
* zero hit-test work.
|
|
1547
|
+
* 3. Frame writer phase (WRITE): registered writers apply the state computed this
|
|
1548
|
+
* frame to the DOM. DragPreviewComponent writes the preview transform here, so
|
|
1549
|
+
* the preview moves in the frame that processed the pointer instead of waiting
|
|
1550
|
+
* for a change detection tick, which Angular schedules after this frame.
|
|
1247
1551
|
*
|
|
1248
1552
|
* PointerDragHandler calls `queueCursorUpdate()` instead of managing its own
|
|
1249
1553
|
* RAF per pointermove. This coalesces all moves within a frame into one update
|
|
@@ -1259,6 +1563,8 @@ class DragSchedulerService {
|
|
|
1259
1563
|
#cursorDirty = false;
|
|
1260
1564
|
/** Tick participants called before the main compute phase each frame. */
|
|
1261
1565
|
#participants = [];
|
|
1566
|
+
/** Frame writers called after the main compute phase each frame. */
|
|
1567
|
+
#frameWriters = [];
|
|
1262
1568
|
/**
|
|
1263
1569
|
* Start the scheduler RAF loop.
|
|
1264
1570
|
* No-op if already running.
|
|
@@ -1315,6 +1621,26 @@ class DragSchedulerService {
|
|
|
1315
1621
|
this.#participants.splice(idx, 1);
|
|
1316
1622
|
}
|
|
1317
1623
|
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Register a function to be called at the end of each frame, after the main tick.
|
|
1626
|
+
* DragPreviewComponent registers here to move the preview within the same frame.
|
|
1627
|
+
* No-op if the same function reference is already registered.
|
|
1628
|
+
*/
|
|
1629
|
+
addFrameWriter(fn) {
|
|
1630
|
+
if (!this.#frameWriters.includes(fn)) {
|
|
1631
|
+
this.#frameWriters.push(fn);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Unregister a previously added frame writer.
|
|
1636
|
+
* No-op if the function was not registered.
|
|
1637
|
+
*/
|
|
1638
|
+
removeFrameWriter(fn) {
|
|
1639
|
+
const idx = this.#frameWriters.indexOf(fn);
|
|
1640
|
+
if (idx !== -1) {
|
|
1641
|
+
this.#frameWriters.splice(idx, 1);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1318
1644
|
#scheduleNextTick() {
|
|
1319
1645
|
this.#rafId = requestAnimationFrame(() => this.#tick());
|
|
1320
1646
|
}
|
|
@@ -1334,9 +1660,23 @@ class DragSchedulerService {
|
|
|
1334
1660
|
for (const participant of this.#participants) {
|
|
1335
1661
|
participant();
|
|
1336
1662
|
}
|
|
1663
|
+
// A participant can end the drag (the autoscroll callback may cancel it), which stops
|
|
1664
|
+
// the scheduler and clears onTick.
|
|
1665
|
+
const onTick = this.#onTick;
|
|
1666
|
+
if (!onTick) {
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1337
1669
|
// Phase 2 — main compute (cursor-based hit-test + signal write)
|
|
1338
|
-
|
|
1339
|
-
//
|
|
1670
|
+
onTick(this.#pendingCursor, cursorDirty);
|
|
1671
|
+
// stop() may have been called by onTick (the drag ended): nothing left to write.
|
|
1672
|
+
if (this.#onTick === null) {
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
// Phase 3 — frame writers (apply this frame's state to the DOM)
|
|
1676
|
+
for (const writer of this.#frameWriters) {
|
|
1677
|
+
writer();
|
|
1678
|
+
}
|
|
1679
|
+
// Reschedule only if still active (a writer is not expected to stop it, but may).
|
|
1340
1680
|
if (this.#onTick !== null) {
|
|
1341
1681
|
this.#scheduleNextTick();
|
|
1342
1682
|
}
|
|
@@ -1387,6 +1727,14 @@ class AutoScrollService {
|
|
|
1387
1727
|
/** Last tick cursor position for stationary detection */
|
|
1388
1728
|
#lastTickCursorX = NaN;
|
|
1389
1729
|
#lastTickCursorY = NaN;
|
|
1730
|
+
/**
|
|
1731
|
+
* The containers the last tick that ran asked to scroll but had no room left: the first
|
|
1732
|
+
* #exhaustedCount entries (the objects are reused from tick to tick). A resting cursor skips the
|
|
1733
|
+
* tick only while they stay as they were on the axes it asked for: content that grows (rows
|
|
1734
|
+
* measured taller, items added), a resize or a scroll can give one room, and the scroll resumes.
|
|
1735
|
+
*/
|
|
1736
|
+
#exhausted = [];
|
|
1737
|
+
#exhaustedCount = 0;
|
|
1390
1738
|
/**
|
|
1391
1739
|
* Last timestamp used to scale per-frame autoscroll distance by elapsed frame time.
|
|
1392
1740
|
* Scale is intentionally floored at 1 to preserve the existing feel on 60fps+ displays while
|
|
@@ -1490,6 +1838,8 @@ class AutoScrollService {
|
|
|
1490
1838
|
this.#cursorOverride = null;
|
|
1491
1839
|
this.#lastTickCursorX = NaN;
|
|
1492
1840
|
this.#lastTickCursorY = NaN;
|
|
1841
|
+
this.#exhausted.length = 0;
|
|
1842
|
+
this.#exhaustedCount = 0;
|
|
1493
1843
|
this.#lastScrollTimestamp = 0;
|
|
1494
1844
|
this.#scrollState.containerId = null;
|
|
1495
1845
|
this.#scrollState.direction.x = 0;
|
|
@@ -1518,14 +1868,17 @@ class AutoScrollService {
|
|
|
1518
1868
|
if (!cursor) {
|
|
1519
1869
|
return;
|
|
1520
1870
|
}
|
|
1521
|
-
// Skip the container iteration when cursor hasn't moved and scroll is idle
|
|
1871
|
+
// Skip the container iteration when the cursor hasn't moved and scroll is idle, unless a
|
|
1872
|
+
// container it asked to scroll without room has changed since (see #exhausted).
|
|
1522
1873
|
if (cursor.x === this.#lastTickCursorX &&
|
|
1523
1874
|
cursor.y === this.#lastTickCursorY &&
|
|
1524
|
-
!this.isScrolling()
|
|
1875
|
+
!this.isScrolling() &&
|
|
1876
|
+
!this.#exhaustedChanged()) {
|
|
1525
1877
|
return;
|
|
1526
1878
|
}
|
|
1527
1879
|
this.#lastTickCursorX = cursor.x;
|
|
1528
1880
|
this.#lastTickCursorY = cursor.y;
|
|
1881
|
+
this.#exhaustedCount = 0;
|
|
1529
1882
|
let scrollPerformed = false;
|
|
1530
1883
|
// Frame scale is shared across candidates within a tick; computed lazily on
|
|
1531
1884
|
// the first scroll attempt so stationary/idle frames don't advance the clock.
|
|
@@ -1585,8 +1938,19 @@ class AutoScrollService {
|
|
|
1585
1938
|
this.#scrollState.direction.y = direction.y;
|
|
1586
1939
|
this.#scrollState.speed = speed;
|
|
1587
1940
|
scrollPerformed = true;
|
|
1941
|
+
// Notify callback IMMEDIATELY in the same frame (no RAF delay)
|
|
1942
|
+
// Delaying via RAF causes cumulative drift during continuous autoscroll
|
|
1943
|
+
// because multiple scrolls happen before each delayed callback runs.
|
|
1944
|
+
// Note: No ngZone.run() needed here - the callback (DraggableDirective.#recalculatePlaceholder)
|
|
1945
|
+
// updates signals and plain fields, and enters the zone itself to emit dragEnd if it ends
|
|
1946
|
+
// the drag.
|
|
1947
|
+
// It runs after the scroll state is recorded: it may end the drag, and the reset of
|
|
1948
|
+
// stopMonitoring() must not be overwritten.
|
|
1949
|
+
this.#onScrollCallback?.();
|
|
1588
1950
|
break;
|
|
1589
1951
|
}
|
|
1952
|
+
// No room in any requested direction: a resting cursor retries it once it changes
|
|
1953
|
+
this.#recordExhausted(element, direction);
|
|
1590
1954
|
}
|
|
1591
1955
|
}
|
|
1592
1956
|
if (!scrollPerformed) {
|
|
@@ -1646,16 +2010,46 @@ class AutoScrollService {
|
|
|
1646
2010
|
}
|
|
1647
2011
|
}
|
|
1648
2012
|
}
|
|
1649
|
-
|
|
1650
|
-
|
|
2013
|
+
return scrolled;
|
|
2014
|
+
}
|
|
2015
|
+
/** Record a container that had no room in `direction` (see #exhausted). */
|
|
2016
|
+
#recordExhausted(element, direction) {
|
|
2017
|
+
const exhausted = (this.#exhausted[this.#exhaustedCount] ??= {
|
|
2018
|
+
element,
|
|
2019
|
+
vertical: false,
|
|
2020
|
+
horizontal: false,
|
|
2021
|
+
scrollTop: 0,
|
|
2022
|
+
scrollLeft: 0,
|
|
2023
|
+
maxScrollTop: 0,
|
|
2024
|
+
maxScrollLeft: 0,
|
|
2025
|
+
});
|
|
2026
|
+
exhausted.element = element;
|
|
2027
|
+
exhausted.vertical = direction.y !== 0;
|
|
2028
|
+
exhausted.horizontal = direction.x !== 0;
|
|
2029
|
+
exhausted.scrollTop = element.scrollTop;
|
|
2030
|
+
exhausted.scrollLeft = element.scrollLeft;
|
|
2031
|
+
exhausted.maxScrollTop = element.scrollHeight - element.clientHeight;
|
|
2032
|
+
exhausted.maxScrollLeft = element.scrollWidth - element.clientWidth;
|
|
2033
|
+
this.#exhaustedCount++;
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Whether a container in #exhausted has scrolled or changed size on an axis it was asked to
|
|
2037
|
+
* scroll, so it may have room now.
|
|
2038
|
+
*/
|
|
2039
|
+
#exhaustedChanged() {
|
|
2040
|
+
for (let i = 0; i < this.#exhaustedCount; i++) {
|
|
2041
|
+
const exhausted = this.#exhausted[i];
|
|
2042
|
+
const { element } = exhausted;
|
|
2043
|
+
if ((exhausted.vertical &&
|
|
2044
|
+
(element.scrollTop !== exhausted.scrollTop ||
|
|
2045
|
+
element.scrollHeight - element.clientHeight !== exhausted.maxScrollTop)) ||
|
|
2046
|
+
(exhausted.horizontal &&
|
|
2047
|
+
(element.scrollLeft !== exhausted.scrollLeft ||
|
|
2048
|
+
element.scrollWidth - element.clientWidth !== exhausted.maxScrollLeft))) {
|
|
2049
|
+
return true;
|
|
2050
|
+
}
|
|
1651
2051
|
}
|
|
1652
|
-
|
|
1653
|
-
// Delaying via RAF causes cumulative drift during continuous autoscroll
|
|
1654
|
-
// because multiple scrolls happen before each delayed callback runs.
|
|
1655
|
-
// Note: No ngZone.run() needed here - the callback (DraggableDirective.#recalculatePlaceholder)
|
|
1656
|
-
// already enters the zone when updating drag state.
|
|
1657
|
-
this.#onScrollCallback?.();
|
|
1658
|
-
return true;
|
|
2052
|
+
return false;
|
|
1659
2053
|
}
|
|
1660
2054
|
#getFrameScale() {
|
|
1661
2055
|
const now = performance.now();
|
|
@@ -1696,20 +2090,19 @@ class ElementCloneService {
|
|
|
1696
2090
|
* These are the visual properties that affect appearance. Keeping them
|
|
1697
2091
|
* kebab-case avoids a camelCase→kebab conversion on every property of every
|
|
1698
2092
|
* node during the recursive clone walk (a drag-start hot path).
|
|
2093
|
+
*
|
|
2094
|
+
* A shorthand carries more than the longhands listed with it (`background-size`,
|
|
2095
|
+
* `font-style`, …), so it is copied first, and the longhands only when the engine can't
|
|
2096
|
+
* serialize the shorthand (it gives '' for `font` with `font-variant-ligatures: none`, for
|
|
2097
|
+
* example). Copying both would read and write the same values twice.
|
|
1699
2098
|
*/
|
|
1700
|
-
#
|
|
1701
|
-
'background',
|
|
1702
|
-
'background-color',
|
|
1703
|
-
'background-image',
|
|
2099
|
+
#stylesToCopy = [
|
|
2100
|
+
['background', ['background-color', 'background-image']],
|
|
1704
2101
|
'border',
|
|
1705
2102
|
'border-radius',
|
|
1706
2103
|
'box-shadow',
|
|
1707
2104
|
'color',
|
|
1708
|
-
'font',
|
|
1709
|
-
'font-family',
|
|
1710
|
-
'font-size',
|
|
1711
|
-
'font-weight',
|
|
1712
|
-
'line-height',
|
|
2105
|
+
['font', ['font-family', 'font-size', 'font-weight', 'line-height']],
|
|
1713
2106
|
'padding',
|
|
1714
2107
|
'margin',
|
|
1715
2108
|
'display',
|
|
@@ -1751,10 +2144,22 @@ class ElementCloneService {
|
|
|
1751
2144
|
#applyComputedStyles(source, target) {
|
|
1752
2145
|
const computed = window.getComputedStyle(source);
|
|
1753
2146
|
// Copy essential visual properties (keys are already kebab-case)
|
|
1754
|
-
for (const
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
2147
|
+
for (const style of this.#stylesToCopy) {
|
|
2148
|
+
if (typeof style === 'string') {
|
|
2149
|
+
this.#copyStyle(computed, target, style);
|
|
2150
|
+
continue;
|
|
2151
|
+
}
|
|
2152
|
+
// The longhands only when the shorthand was not copied: the engine gave no value, or the
|
|
2153
|
+
// clone rejected it. A shorthand sets all its longhands, so reading one back tells (the
|
|
2154
|
+
// shorthand itself would be serialized again to be read). The clone may carry that
|
|
2155
|
+
// longhand inline from the source, so it goes first.
|
|
2156
|
+
const [shorthand, longhands] = style;
|
|
2157
|
+
target.style.removeProperty(longhands[0]);
|
|
2158
|
+
if (!this.#copyStyle(computed, target, shorthand) ||
|
|
2159
|
+
!target.style.getPropertyValue(longhands[0])) {
|
|
2160
|
+
for (const longhand of longhands) {
|
|
2161
|
+
this.#copyStyle(computed, target, longhand);
|
|
2162
|
+
}
|
|
1758
2163
|
}
|
|
1759
2164
|
}
|
|
1760
2165
|
// Disable animations and transitions on clone
|
|
@@ -1771,6 +2176,18 @@ class ElementCloneService {
|
|
|
1771
2176
|
}
|
|
1772
2177
|
}
|
|
1773
2178
|
}
|
|
2179
|
+
/**
|
|
2180
|
+
* Copy one computed property to the target's inline style. Returns whether the engine gave a
|
|
2181
|
+
* value for it.
|
|
2182
|
+
*/
|
|
2183
|
+
#copyStyle(computed, target, property) {
|
|
2184
|
+
const value = computed.getPropertyValue(property);
|
|
2185
|
+
if (!value) {
|
|
2186
|
+
return false;
|
|
2187
|
+
}
|
|
2188
|
+
target.style.setProperty(property, value);
|
|
2189
|
+
return true;
|
|
2190
|
+
}
|
|
1774
2191
|
/**
|
|
1775
2192
|
* Handle special elements that require extra processing.
|
|
1776
2193
|
*/
|
|
@@ -1868,6 +2285,11 @@ class ElementCloneService {
|
|
|
1868
2285
|
el.setAttribute('disabled', 'true');
|
|
1869
2286
|
}
|
|
1870
2287
|
});
|
|
2288
|
+
// A checked radio joins its group as soon as the clone is connected, which unchecks the
|
|
2289
|
+
// radio in the source item. Without a name the cloned radios belong to no group.
|
|
2290
|
+
clone.querySelectorAll('input[type="radio"][name]').forEach((radio) => {
|
|
2291
|
+
radio.removeAttribute('name');
|
|
2292
|
+
});
|
|
1871
2293
|
// Remove focus styling classes that might interfere
|
|
1872
2294
|
clone.classList.remove('vdnd-draggable-dragging');
|
|
1873
2295
|
clone.classList.remove('vdnd-draggable-disabled');
|
|
@@ -2118,14 +2540,19 @@ class DragIndexCalculatorService {
|
|
|
2118
2540
|
const cached = this.#droppableCache.get(droppableElement);
|
|
2119
2541
|
if (cached)
|
|
2120
2542
|
return cached;
|
|
2543
|
+
const isViewport = droppableElement.hasAttribute('data-virtual-viewport');
|
|
2121
2544
|
const virtualScrollElement = droppableElement.querySelector('vdnd-virtual-scroll');
|
|
2122
|
-
const virtualContentElement = (droppableElement
|
|
2123
|
-
? droppableElement
|
|
2124
|
-
: droppableElement.closest('vdnd-virtual-content'));
|
|
2545
|
+
const virtualContentElement = this.#findVirtualContent(droppableElement);
|
|
2125
2546
|
let containerType;
|
|
2126
2547
|
let scrollContainer;
|
|
2127
2548
|
let scrollableParent = null;
|
|
2128
|
-
if (
|
|
2549
|
+
if (isViewport) {
|
|
2550
|
+
// The viewport scrolls its own rows. Checked first so a list nested in a row is not
|
|
2551
|
+
// mistaken for this one.
|
|
2552
|
+
containerType = 'viewport';
|
|
2553
|
+
scrollContainer = droppableElement;
|
|
2554
|
+
}
|
|
2555
|
+
else if (virtualScrollElement) {
|
|
2129
2556
|
containerType = 'virtualScroll';
|
|
2130
2557
|
scrollContainer = virtualScrollElement;
|
|
2131
2558
|
}
|
|
@@ -2162,31 +2589,21 @@ class DragIndexCalculatorService {
|
|
|
2162
2589
|
return entry;
|
|
2163
2590
|
}
|
|
2164
2591
|
getTotalItemCount(args) {
|
|
2165
|
-
return this.#getTotalItemCount(args.droppableElement, args.
|
|
2592
|
+
return this.#getTotalItemCount(args.droppableElement, args.draggedItemHeight);
|
|
2593
|
+
}
|
|
2594
|
+
/**
|
|
2595
|
+
* Live scroll geometry of a droppable's rows (see {@link DroppableScrollGeometry}). The same
|
|
2596
|
+
* measurement places the placeholder and finds the dragged item's source index.
|
|
2597
|
+
*/
|
|
2598
|
+
getScrollGeometry(droppableElement, draggedItemHeight) {
|
|
2599
|
+
return this.#getScrollGeometry(this.#resolveDroppable(droppableElement, draggedItemHeight));
|
|
2166
2600
|
}
|
|
2167
2601
|
calculatePlaceholderIndex(args) {
|
|
2168
2602
|
const { droppableElement, position, grabOffset, draggedItemHeight, sourceDroppableId, sourceIndex, } = args;
|
|
2169
2603
|
// Resolve cached metadata (DOM queries run once per droppable per drag session)
|
|
2170
2604
|
const cache = this.#resolveDroppable(droppableElement, draggedItemHeight);
|
|
2171
2605
|
// Live reads: getBoundingClientRect() and scrollTop change during scroll
|
|
2172
|
-
|
|
2173
|
-
let rect;
|
|
2174
|
-
if (cache.containerType === 'virtualScroll') {
|
|
2175
|
-
rect = cache.scrollContainer.getBoundingClientRect();
|
|
2176
|
-
currentScrollTop = cache.scrollContainer.scrollTop;
|
|
2177
|
-
}
|
|
2178
|
-
else if (cache.containerType === 'virtualContent' && cache.scrollableParent) {
|
|
2179
|
-
rect = cache.scrollContainer.getBoundingClientRect();
|
|
2180
|
-
const contentOffsetAttr = cache.virtualContentElement.getAttribute('data-content-offset');
|
|
2181
|
-
const contentOffset = contentOffsetAttr ? parseFloat(contentOffsetAttr) : 0;
|
|
2182
|
-
const offsetValue = Number.isFinite(contentOffset) ? contentOffset : 0;
|
|
2183
|
-
currentScrollTop = cache.scrollContainer.scrollTop - offsetValue;
|
|
2184
|
-
}
|
|
2185
|
-
else {
|
|
2186
|
-
rect = cache.scrollContainer.getBoundingClientRect();
|
|
2187
|
-
currentScrollTop =
|
|
2188
|
-
cache.containerType === 'virtualContent' ? 0 : cache.scrollContainer.scrollTop;
|
|
2189
|
-
}
|
|
2606
|
+
const { rect, scrollTop: currentScrollTop } = this.#getScrollGeometry(cache);
|
|
2190
2607
|
const { strategy, itemHeight, droppableId: currentDroppableId, isConstrainedToContainer, } = cache;
|
|
2191
2608
|
// Calculate preview center position mathematically
|
|
2192
2609
|
// The preview is positioned at: cursorPosition - grabOffset (see drag-preview.component.ts)
|
|
@@ -2244,7 +2661,7 @@ class DragIndexCalculatorService {
|
|
|
2244
2661
|
totalItems = Math.max(0, cachedItemCount);
|
|
2245
2662
|
}
|
|
2246
2663
|
else {
|
|
2247
|
-
totalItems = this.#getTotalItemCount(droppableElement,
|
|
2664
|
+
totalItems = this.#getTotalItemCount(droppableElement, draggedItemHeight, cache);
|
|
2248
2665
|
}
|
|
2249
2666
|
// Edge detection: allow dropping at the END of the list when cursor is near bottom edge.
|
|
2250
2667
|
// Due to max scroll limits, the math alone can't reach totalItems when the list is longer
|
|
@@ -2274,7 +2691,41 @@ class DragIndexCalculatorService {
|
|
|
2274
2691
|
placeholderIndex = Math.max(0, Math.min(placeholderIndex, totalItems));
|
|
2275
2692
|
return { index: placeholderIndex, placeholderId: END_OF_LIST };
|
|
2276
2693
|
}
|
|
2277
|
-
#
|
|
2694
|
+
#getScrollGeometry(cache) {
|
|
2695
|
+
const { containerType, scrollContainer } = cache;
|
|
2696
|
+
const rect = scrollContainer.getBoundingClientRect();
|
|
2697
|
+
const isVirtual = containerType === 'virtualScroll' || containerType === 'virtualContent';
|
|
2698
|
+
switch (containerType) {
|
|
2699
|
+
case 'viewport':
|
|
2700
|
+
return {
|
|
2701
|
+
rect,
|
|
2702
|
+
scrollTop: scrollContainer.scrollTop - this.#readContentOffset(scrollContainer),
|
|
2703
|
+
isVirtual,
|
|
2704
|
+
};
|
|
2705
|
+
case 'virtualContent':
|
|
2706
|
+
// Without a vdndScrollable parent the content element itself is measured, unscrolled
|
|
2707
|
+
return cache.scrollableParent
|
|
2708
|
+
? {
|
|
2709
|
+
rect,
|
|
2710
|
+
scrollTop: scrollContainer.scrollTop - this.#readContentOffset(cache.virtualContentElement),
|
|
2711
|
+
isVirtual,
|
|
2712
|
+
}
|
|
2713
|
+
: { rect, scrollTop: 0, isVirtual };
|
|
2714
|
+
default:
|
|
2715
|
+
return { rect, scrollTop: scrollContainer.scrollTop, isVirtual };
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
/** The `data-content-offset` (px reserved above the rows) of a virtual container, or 0. */
|
|
2719
|
+
#readContentOffset(element) {
|
|
2720
|
+
const offset = parseFloat(element?.getAttribute('data-content-offset') ?? '');
|
|
2721
|
+
return Number.isFinite(offset) ? offset : 0;
|
|
2722
|
+
}
|
|
2723
|
+
/**
|
|
2724
|
+
* The number of items in a droppable. With `cache`, the drag's resolved metadata, the virtual
|
|
2725
|
+
* containers it knows are not looked up in the DOM again: this runs on every frame of a drag
|
|
2726
|
+
* over a list without a registered strategy.
|
|
2727
|
+
*/
|
|
2728
|
+
#getTotalItemCount(droppableElement, draggedItemHeight, cache) {
|
|
2278
2729
|
// Check if a registered strategy exists
|
|
2279
2730
|
const droppableId = this.#positionCalculator.getDroppableId(droppableElement);
|
|
2280
2731
|
const strategy = droppableId ? this.#strategies.get(droppableId) : null;
|
|
@@ -2283,7 +2734,9 @@ class DragIndexCalculatorService {
|
|
|
2283
2734
|
return Math.max(0, strategyItemCount);
|
|
2284
2735
|
}
|
|
2285
2736
|
// Check for embedded virtual scroll component
|
|
2286
|
-
const virtualScroll =
|
|
2737
|
+
const virtualScroll = cache
|
|
2738
|
+
? cache.virtualScrollElement
|
|
2739
|
+
: droppableElement.querySelector('vdnd-virtual-scroll');
|
|
2287
2740
|
if (virtualScroll) {
|
|
2288
2741
|
// Use data-total-items attribute if available (always the true N)
|
|
2289
2742
|
const totalItemsAttr = virtualScroll.getAttribute('data-total-items');
|
|
@@ -2320,9 +2773,9 @@ class DragIndexCalculatorService {
|
|
|
2320
2773
|
return count;
|
|
2321
2774
|
}
|
|
2322
2775
|
// Check for page-level scroll (vdnd-virtual-content)
|
|
2323
|
-
const virtualContent =
|
|
2324
|
-
?
|
|
2325
|
-
: droppableElement
|
|
2776
|
+
const virtualContent = cache
|
|
2777
|
+
? cache.virtualContentElement
|
|
2778
|
+
: this.#findVirtualContent(droppableElement);
|
|
2326
2779
|
if (virtualContent) {
|
|
2327
2780
|
// Prefer explicit total items attribute (works for both fixed and dynamic heights)
|
|
2328
2781
|
const totalItemsAttr = virtualContent.getAttribute('data-total-items');
|
|
@@ -2349,6 +2802,12 @@ class DragIndexCalculatorService {
|
|
|
2349
2802
|
const items = droppableElement.querySelectorAll('[data-draggable-id]');
|
|
2350
2803
|
return items.length;
|
|
2351
2804
|
}
|
|
2805
|
+
/** The `vdnd-virtual-content` the droppable is or is inside (page-level scroll), if any. */
|
|
2806
|
+
#findVirtualContent(droppableElement) {
|
|
2807
|
+
return droppableElement.matches('vdnd-virtual-content')
|
|
2808
|
+
? droppableElement
|
|
2809
|
+
: droppableElement.closest('vdnd-virtual-content');
|
|
2810
|
+
}
|
|
2352
2811
|
#getDraggedItemHeightFallback(height, fallback) {
|
|
2353
2812
|
return Number.isFinite(height) && height > 0 ? height : fallback;
|
|
2354
2813
|
}
|
|
@@ -2401,10 +2860,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
2401
2860
|
}, template: ``, styles: [":host{display:block;pointer-events:none}\n"] }]
|
|
2402
2861
|
}], propDecorators: { itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }] } });
|
|
2403
2862
|
|
|
2863
|
+
/** A layout value, or 0 where there is no layout (server rendering leaves them undefined). */
|
|
2864
|
+
function layoutValue(value) {
|
|
2865
|
+
return Number.isFinite(value) ? value : 0;
|
|
2866
|
+
}
|
|
2404
2867
|
function bindRafThrottledScrollTopSignal(options) {
|
|
2405
2868
|
const { element, ngZone, scrollTop, thresholdPx = 5, onCommit } = options;
|
|
2406
2869
|
let pendingRaf = null;
|
|
2407
|
-
let lastCommittedScrollTop = element.scrollTop;
|
|
2870
|
+
let lastCommittedScrollTop = layoutValue(element.scrollTop);
|
|
2408
2871
|
const onScroll = () => {
|
|
2409
2872
|
if (pendingRaf !== null) {
|
|
2410
2873
|
return;
|
|
@@ -2438,7 +2901,7 @@ function bindRafThrottledScrollTopSignal(options) {
|
|
|
2438
2901
|
function bindResizeObserverHeightSignal(options) {
|
|
2439
2902
|
const { element, ngZone, height, minDeltaPx = 1 } = options;
|
|
2440
2903
|
if (typeof ResizeObserver === 'undefined') {
|
|
2441
|
-
height.set(element.clientHeight);
|
|
2904
|
+
height.set(layoutValue(element.clientHeight));
|
|
2442
2905
|
return () => undefined;
|
|
2443
2906
|
}
|
|
2444
2907
|
let observer = null;
|
|
@@ -2453,7 +2916,7 @@ function bindResizeObserverHeightSignal(options) {
|
|
|
2453
2916
|
});
|
|
2454
2917
|
observer.observe(element);
|
|
2455
2918
|
});
|
|
2456
|
-
height.set(element.clientHeight);
|
|
2919
|
+
height.set(layoutValue(element.clientHeight));
|
|
2457
2920
|
return () => {
|
|
2458
2921
|
observer?.disconnect();
|
|
2459
2922
|
observer = null;
|
|
@@ -2488,6 +2951,66 @@ function createAutoScrollRegistration(options) {
|
|
|
2488
2951
|
destroyRef.onDestroy(unregisterCurrent);
|
|
2489
2952
|
}
|
|
2490
2953
|
|
|
2954
|
+
/**
|
|
2955
|
+
* Give a list's strategy its items: `count` of them, with the track keys `keys` returns (the
|
|
2956
|
+
* list's own array, which it goes on reading).
|
|
2957
|
+
*
|
|
2958
|
+
* - FixedHeightStrategy's `setItemKeys` only counts the keys: it gets the count, and the keys are
|
|
2959
|
+
* never computed.
|
|
2960
|
+
* - DynamicHeightStrategy's copies the keys: it gets the list's array.
|
|
2961
|
+
* - Any other `setItemKeys` (a strategy of the consumer's own, or a subclass that overrides it)
|
|
2962
|
+
* gets a copy, which it may keep and change.
|
|
2963
|
+
* @internal
|
|
2964
|
+
*/
|
|
2965
|
+
function setStrategyItems(strategy, count, keys) {
|
|
2966
|
+
if (strategy instanceof FixedHeightStrategy &&
|
|
2967
|
+
strategy.setItemKeys === FixedHeightStrategy.prototype.setItemKeys) {
|
|
2968
|
+
strategy.setItemCount(count);
|
|
2969
|
+
}
|
|
2970
|
+
else if (strategy instanceof DynamicHeightStrategy &&
|
|
2971
|
+
strategy.setItemKeys === DynamicHeightStrategy.prototype.setItemKeys) {
|
|
2972
|
+
strategy.setItemKeys(keys());
|
|
2973
|
+
}
|
|
2974
|
+
else {
|
|
2975
|
+
strategy.setItemKeys(keys().slice());
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
/**
|
|
2980
|
+
* Query by an exact attribute value without interpolating consumer data into a CSS selector.
|
|
2981
|
+
*
|
|
2982
|
+
* CSS attribute selectors need string-specific escaping that differs from `CSS.escape()`.
|
|
2983
|
+
* Filtering candidates by attribute presence avoids selector syntax errors for IDs containing
|
|
2984
|
+
* quotes, brackets, backslashes, or other selector-sensitive characters.
|
|
2985
|
+
*/
|
|
2986
|
+
function queryByAttribute(root, attributeName, attributeValue) {
|
|
2987
|
+
const candidates = root.querySelectorAll(`[${attributeName}]`);
|
|
2988
|
+
for (const candidate of candidates) {
|
|
2989
|
+
if (candidate.getAttribute(attributeName) === attributeValue) {
|
|
2990
|
+
return candidate;
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
return null;
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* The elements carrying an attribute, by its value (the first in document order when several
|
|
2997
|
+
* share one, as `queryByAttribute` finds). One query for many lookups, where `queryByAttribute`
|
|
2998
|
+
* scans every candidate on each call.
|
|
2999
|
+
*/
|
|
3000
|
+
function mapByAttribute(root, attributeName) {
|
|
3001
|
+
const elements = new Map();
|
|
3002
|
+
for (const candidate of root.querySelectorAll(`[${attributeName}]`)) {
|
|
3003
|
+
const value = candidate.getAttribute(attributeName);
|
|
3004
|
+
if (!elements.has(value)) {
|
|
3005
|
+
elements.set(value, candidate);
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
return elements;
|
|
3009
|
+
}
|
|
3010
|
+
function queryAllByAttribute(root, attributeName, attributeValue) {
|
|
3011
|
+
return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
|
|
3012
|
+
}
|
|
3013
|
+
|
|
2491
3014
|
/** Layout changes smaller than this are ignored (sub-pixel rounding noise). */
|
|
2492
3015
|
const MIN_SHIFT_PX = 0.5;
|
|
2493
3016
|
/**
|
|
@@ -2671,12 +3194,214 @@ class ShiftAnimator {
|
|
|
2671
3194
|
}
|
|
2672
3195
|
}
|
|
2673
3196
|
|
|
3197
|
+
/** The elements drop animations hide, with the animation hiding each */
|
|
3198
|
+
const hiddenTargets = new Map();
|
|
2674
3199
|
/**
|
|
2675
|
-
*
|
|
2676
|
-
*
|
|
2677
|
-
*
|
|
2678
|
-
|
|
2679
|
-
|
|
3200
|
+
* Show again an element a drop animation hides, if one of these nodes holds it. A virtual list
|
|
3201
|
+
* calls it for the row it pools: that row's element renders another item next.
|
|
3202
|
+
* @internal
|
|
3203
|
+
*/
|
|
3204
|
+
function revealDropTargetIn(nodes) {
|
|
3205
|
+
for (const [element, animation] of hiddenTargets) {
|
|
3206
|
+
if (nodes.some((node) => node.contains(element))) {
|
|
3207
|
+
hiddenTargets.delete(element);
|
|
3208
|
+
animation.cancel();
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
/**
|
|
3213
|
+
* Plays the drop ("settle") animation: the drag preview glides from where it was released
|
|
3214
|
+
* into the dropped item's final position, while that item stays invisible underneath.
|
|
3215
|
+
*
|
|
3216
|
+
* Purely visual. The drag state and the drop events are already final when it starts, so it
|
|
3217
|
+
* never delays or changes a drop. Without a target (the item is not rendered, or scrolled out
|
|
3218
|
+
* of view) the ghost fades out where it was released.
|
|
3219
|
+
* @internal
|
|
3220
|
+
*/
|
|
3221
|
+
class DropAnimator {
|
|
3222
|
+
#config;
|
|
3223
|
+
#ghostAnimation = null;
|
|
3224
|
+
#hidden = null;
|
|
3225
|
+
/** The reduced-motion media query, created on first use (null without `matchMedia`) */
|
|
3226
|
+
#reducedMotionQuery;
|
|
3227
|
+
constructor(config) {
|
|
3228
|
+
this.#config = config;
|
|
3229
|
+
}
|
|
3230
|
+
/** Whether a drop animation would play right now (duration, WAAPI, reduced motion). */
|
|
3231
|
+
isEnabled() {
|
|
3232
|
+
if (this.#duration() <= 0)
|
|
3233
|
+
return false;
|
|
3234
|
+
if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
|
|
3235
|
+
return false;
|
|
3236
|
+
}
|
|
3237
|
+
return !this.#prefersReducedMotion();
|
|
3238
|
+
}
|
|
3239
|
+
/**
|
|
3240
|
+
* Animate `ghost` onto `target` (or fade it out without one), then call `onDone`.
|
|
3241
|
+
* `onDone` is not called when the animation is cancelled.
|
|
3242
|
+
*/
|
|
3243
|
+
play(ghost, target, onDone) {
|
|
3244
|
+
this.cancel();
|
|
3245
|
+
const timing = {
|
|
3246
|
+
duration: this.#duration(),
|
|
3247
|
+
easing: this.#config.dropEasing ?? DEFAULT_DROP_EASING,
|
|
3248
|
+
fill: 'forwards',
|
|
3249
|
+
};
|
|
3250
|
+
let ghostAnimation;
|
|
3251
|
+
if (target) {
|
|
3252
|
+
const from = ghost.getBoundingClientRect();
|
|
3253
|
+
const dx = target.rect.left - from.left;
|
|
3254
|
+
const dy = target.rect.top - from.top;
|
|
3255
|
+
// Travel on top of the preview's own position transform (width/height must not be
|
|
3256
|
+
// composited, so the base transform is repeated instead of using composite: 'add').
|
|
3257
|
+
const base = ghost.style.transform;
|
|
3258
|
+
ghostAnimation = ghost.animate([
|
|
3259
|
+
{
|
|
3260
|
+
transform: `${base} translate(0px, 0px)`,
|
|
3261
|
+
width: `${from.width}px`,
|
|
3262
|
+
height: `${from.height}px`,
|
|
3263
|
+
},
|
|
3264
|
+
{
|
|
3265
|
+
transform: `${base} translate(${dx}px, ${dy}px)`,
|
|
3266
|
+
width: `${target.rect.width}px`,
|
|
3267
|
+
height: `${target.rect.height}px`,
|
|
3268
|
+
},
|
|
3269
|
+
], timing);
|
|
3270
|
+
// Keep the real item invisible until the ghost lands on it, so it is never seen twice.
|
|
3271
|
+
const hideAnimation = target.element.animate([{ opacity: 0 }, { opacity: 0 }], {
|
|
3272
|
+
duration: timing.duration,
|
|
3273
|
+
});
|
|
3274
|
+
this.#hidden = { element: target.element, animation: hideAnimation };
|
|
3275
|
+
hiddenTargets.set(target.element, hideAnimation);
|
|
3276
|
+
}
|
|
3277
|
+
else {
|
|
3278
|
+
ghostAnimation = ghost.animate([{ opacity: 1 }, { opacity: 0 }], timing);
|
|
3279
|
+
}
|
|
3280
|
+
this.#ghostAnimation = ghostAnimation;
|
|
3281
|
+
ghostAnimation.onfinish = () => {
|
|
3282
|
+
if (this.#ghostAnimation !== ghostAnimation)
|
|
3283
|
+
return;
|
|
3284
|
+
this.cancel();
|
|
3285
|
+
onDone();
|
|
3286
|
+
};
|
|
3287
|
+
}
|
|
3288
|
+
/** Stop the running animation immediately and reveal the real item. */
|
|
3289
|
+
cancel() {
|
|
3290
|
+
const ghostAnimation = this.#ghostAnimation;
|
|
3291
|
+
const hidden = this.#hidden;
|
|
3292
|
+
this.#ghostAnimation = null;
|
|
3293
|
+
this.#hidden = null;
|
|
3294
|
+
ghostAnimation?.cancel();
|
|
3295
|
+
if (hidden) {
|
|
3296
|
+
if (hiddenTargets.get(hidden.element) === hidden.animation) {
|
|
3297
|
+
hiddenTargets.delete(hidden.element);
|
|
3298
|
+
}
|
|
3299
|
+
hidden.animation.cancel();
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
#duration() {
|
|
3303
|
+
return this.#config.dropDuration ?? DEFAULT_DROP_DURATION;
|
|
3304
|
+
}
|
|
3305
|
+
/** One media query for every drop: it is live, so it follows a change of the preference. */
|
|
3306
|
+
#prefersReducedMotion() {
|
|
3307
|
+
if (this.#reducedMotionQuery === undefined) {
|
|
3308
|
+
this.#reducedMotionQuery =
|
|
3309
|
+
typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
|
|
3310
|
+
}
|
|
3311
|
+
return this.#reducedMotionQuery?.matches ?? false;
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
/**
|
|
3315
|
+
* Find the rendered element of a dropped item: first in the droppable it was dropped on, then
|
|
3316
|
+
* in the one it came from (a drop the consumer rejected or has not committed yet leaves it
|
|
3317
|
+
* there). Returns null when it is not rendered, or rendered but scrolled out of its list's view.
|
|
3318
|
+
*
|
|
3319
|
+
* `getDroppable` resolves a droppable ID to its element (the droppable registry lookup).
|
|
3320
|
+
*/
|
|
3321
|
+
function findDropTarget(draggableId, droppableIds, getDroppable) {
|
|
3322
|
+
for (const droppableId of droppableIds) {
|
|
3323
|
+
if (!droppableId)
|
|
3324
|
+
continue;
|
|
3325
|
+
const droppable = getDroppable(droppableId);
|
|
3326
|
+
const element = droppable
|
|
3327
|
+
? queryByAttribute(droppable, 'data-draggable-id', draggableId)
|
|
3328
|
+
: null;
|
|
3329
|
+
if (!droppable || !element)
|
|
3330
|
+
continue;
|
|
3331
|
+
const rect = element.getBoundingClientRect();
|
|
3332
|
+
// Hidden, or virtualized/scrolled out of the list's visible box: nowhere to land.
|
|
3333
|
+
if (rect.width === 0 && rect.height === 0)
|
|
3334
|
+
return null;
|
|
3335
|
+
const bounds = droppable.getBoundingClientRect();
|
|
3336
|
+
const visible = rect.bottom > bounds.top &&
|
|
3337
|
+
rect.top < bounds.bottom &&
|
|
3338
|
+
rect.right > bounds.left &&
|
|
3339
|
+
rect.left < bounds.right;
|
|
3340
|
+
return visible ? { element, rect } : null;
|
|
3341
|
+
}
|
|
3342
|
+
return null;
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
/**
|
|
3346
|
+
* How many views of rows that left a list stay pooled for rows that come in (recycleRows). A
|
|
3347
|
+
* scroll step reuses the views it pools right away; the pool keeps the rest, for example when
|
|
3348
|
+
* fewer rows render after the list shrinks.
|
|
3349
|
+
*/
|
|
3350
|
+
const MAX_POOLED_ROWS = 10;
|
|
3351
|
+
/** `Node.DOCUMENT_POSITION_FOLLOWING`, without the `Node` global (servers have none) */
|
|
3352
|
+
const DOCUMENT_POSITION_FOLLOWING = 4;
|
|
3353
|
+
/**
|
|
3354
|
+
* The node that comes first in the DOM. A view's `rootNodes` list a control flow block's anchor
|
|
3355
|
+
* before the nodes rendered in it, which the DOM puts before the anchor.
|
|
3356
|
+
*/
|
|
3357
|
+
function firstInDocumentOrder(nodes) {
|
|
3358
|
+
let first = null;
|
|
3359
|
+
for (const node of nodes) {
|
|
3360
|
+
if (first === null || node.compareDocumentPosition(first) & DOCUMENT_POSITION_FOLLOWING) {
|
|
3361
|
+
first = node;
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
return first;
|
|
3365
|
+
}
|
|
3366
|
+
/**
|
|
3367
|
+
* Which values belong to a longest increasing subsequence of `values` (negative values never
|
|
3368
|
+
* do). For rows: the views that can stay where they are while the others move around them, as
|
|
3369
|
+
* few as possible. A moved row loses focus and restarts its CSS transitions.
|
|
3370
|
+
*/
|
|
3371
|
+
function longestIncreasingRun(values) {
|
|
3372
|
+
// tails[k]: the index of the smallest value that ends an increasing run of length k + 1
|
|
3373
|
+
const tails = [];
|
|
3374
|
+
const previous = new Array(values.length).fill(-1);
|
|
3375
|
+
for (let i = 0; i < values.length; i++) {
|
|
3376
|
+
const value = values[i];
|
|
3377
|
+
if (value < 0)
|
|
3378
|
+
continue;
|
|
3379
|
+
let low = 0;
|
|
3380
|
+
let high = tails.length;
|
|
3381
|
+
while (low < high) {
|
|
3382
|
+
const middle = (low + high) >> 1;
|
|
3383
|
+
if (values[tails[middle]] < value) {
|
|
3384
|
+
low = middle + 1;
|
|
3385
|
+
}
|
|
3386
|
+
else {
|
|
3387
|
+
high = middle;
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
previous[i] = low > 0 ? tails[low - 1] : -1;
|
|
3391
|
+
tails[low] = i;
|
|
3392
|
+
}
|
|
3393
|
+
const inRun = new Array(values.length).fill(false);
|
|
3394
|
+
for (let i = tails.length > 0 ? tails[tails.length - 1] : -1; i >= 0; i = previous[i]) {
|
|
3395
|
+
inRun[i] = true;
|
|
3396
|
+
}
|
|
3397
|
+
return inRun;
|
|
3398
|
+
}
|
|
3399
|
+
/**
|
|
3400
|
+
* A virtual scroll container that only renders visible items.
|
|
3401
|
+
*
|
|
3402
|
+
* Key features:
|
|
3403
|
+
* - Only renders items within the visible viewport plus an overscan buffer
|
|
3404
|
+
* - Supports "sticky" items that are always rendered (used for dragged items)
|
|
2680
3405
|
* - Uses spacer divs to maintain correct scroll height
|
|
2681
3406
|
* - Integrates with the drag-and-drop system
|
|
2682
3407
|
* - Automatic height detection via ResizeObserver when containerHeight is not provided
|
|
@@ -2716,11 +3441,13 @@ class VirtualScrollContainerComponent {
|
|
|
2716
3441
|
#keyboardDrag = inject(KeyboardDragService);
|
|
2717
3442
|
#ngZone = inject(NgZone);
|
|
2718
3443
|
#injector = inject(Injector);
|
|
3444
|
+
#environmentInjector = inject(EnvironmentInjector);
|
|
3445
|
+
#isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
2719
3446
|
/** Cleanup function for scroll listener */
|
|
2720
3447
|
#scrollCleanup = null;
|
|
2721
3448
|
#resizeCleanup = null;
|
|
2722
|
-
/** ResizeObserver for dynamic height measurement */
|
|
2723
|
-
#itemResizeObserver = null;
|
|
3449
|
+
/** ResizeObserver for dynamic height measurement (only in dynamic height mode) */
|
|
3450
|
+
#itemResizeObserver = signal(null, ...(ngDevMode ? [{ debugName: "#itemResizeObserver" }] : []));
|
|
2724
3451
|
/** Map from observed HTMLElement to its trackBy key */
|
|
2725
3452
|
#observedElements = new WeakMap();
|
|
2726
3453
|
/** Stable mapping from rendered key to observed element for unobserve cleanup */
|
|
@@ -2729,6 +3456,38 @@ class VirtualScrollContainerComponent {
|
|
|
2729
3456
|
#measuredHeight = signal(0, ...(ngDevMode ? [{ debugName: "#measuredHeight" }] : []));
|
|
2730
3457
|
/** Slides items displaced by the placeholder (only when VDND_ANIMATION_CONFIG is provided) */
|
|
2731
3458
|
#shiftAnimator = this.#createShiftAnimator();
|
|
3459
|
+
/** The template's spacer (cannot use ES private with viewChild) */
|
|
3460
|
+
spacer = viewChild('spacer', ...(ngDevMode ? [{ debugName: "spacer" }] : []));
|
|
3461
|
+
/** The template's content wrapper (cannot use ES private with viewChild) */
|
|
3462
|
+
contentWrapper = viewChild('contentWrapper', ...(ngDevMode ? [{ debugName: "contentWrapper" }] : []));
|
|
3463
|
+
/** Where #renderRows inserts the rows (cannot use ES private with viewChild) */
|
|
3464
|
+
rowOutlet = viewChild('rows', { ...(ngDevMode ? { debugName: "rowOutlet" } : {}), read: ViewContainerRef });
|
|
3465
|
+
/** Rendered rows, in DOM order */
|
|
3466
|
+
#rows = [];
|
|
3467
|
+
/** The entries the rendered rows show */
|
|
3468
|
+
#rowEntries = null;
|
|
3469
|
+
/** The template the rendered rows were created from */
|
|
3470
|
+
#rowTemplate = null;
|
|
3471
|
+
/** The track function that gave the rendered rows their keys */
|
|
3472
|
+
#rowTrackBy = null;
|
|
3473
|
+
/** Views of rows that left, detached, to render rows that come in (recycleRows) */
|
|
3474
|
+
#rowPool = [];
|
|
3475
|
+
/**
|
|
3476
|
+
* Set while this component's view is about to be checked in the current change detection pass:
|
|
3477
|
+
* from an input change (ngOnChanges, which runs before its effects; the first render included)
|
|
3478
|
+
* until the check (ngAfterViewChecked). That check renders every row.
|
|
3479
|
+
*/
|
|
3480
|
+
#viewCheckPending = false;
|
|
3481
|
+
/** The placeholder, created on its first render and reused */
|
|
3482
|
+
#placeholder = null;
|
|
3483
|
+
/** Height last set on the placeholder */
|
|
3484
|
+
#placeholderHeightSet = null;
|
|
3485
|
+
/** Content offset last written to the content wrapper */
|
|
3486
|
+
#contentTransformSet = null;
|
|
3487
|
+
/** Whether duplicate track keys were reported (once per list) */
|
|
3488
|
+
#warnedDuplicateKeys = false;
|
|
3489
|
+
/** The row keys last checked for duplicates (checked again when the rows change) */
|
|
3490
|
+
#rowKeysChecked = null;
|
|
2732
3491
|
/** Template for rendering each item - passed as input instead of content child for reliability */
|
|
2733
3492
|
itemTemplate = input.required(...(ngDevMode ? [{ debugName: "itemTemplate" }] : []));
|
|
2734
3493
|
/** Unique ID for this scroll container (used for auto-scroll registration) */
|
|
@@ -2775,6 +3534,13 @@ class VirtualScrollContainerComponent {
|
|
|
2775
3534
|
* Required for placeholder positioning.
|
|
2776
3535
|
*/
|
|
2777
3536
|
droppableId = input(...(ngDevMode ? [undefined, { debugName: "droppableId" }] : []));
|
|
3537
|
+
/**
|
|
3538
|
+
* Reuse the views of rows that scroll out to render the rows that scroll in, instead of
|
|
3539
|
+
* destroying them and creating new ones. Scrolling creates fewer components and elements, but
|
|
3540
|
+
* a row's components, element and DOM state (focus aside) then carry over to other items.
|
|
3541
|
+
* @default false
|
|
3542
|
+
*/
|
|
3543
|
+
recycleRows = input(false, ...(ngDevMode ? [{ debugName: "recycleRows" }] : []));
|
|
2778
3544
|
/**
|
|
2779
3545
|
* Whether to automatically add the dragged item to the sticky list.
|
|
2780
3546
|
* This ensures the dragged item remains visible during virtual scrolling.
|
|
@@ -2871,22 +3637,50 @@ class VirtualScrollContainerComponent {
|
|
|
2871
3637
|
draggedItemId = computed(() => {
|
|
2872
3638
|
return this.#dragState.draggedItem()?.draggableId ?? null;
|
|
2873
3639
|
}, ...(ngDevMode ? [{ debugName: "draggedItemId" }] : []));
|
|
2874
|
-
/**
|
|
2875
|
-
|
|
2876
|
-
|
|
3640
|
+
/**
|
|
3641
|
+
* Every item's ID, in order. Computed once per items change, and only when something reads it:
|
|
3642
|
+
* a dynamic-height strategy, a drag, or a sticky item outside the rendered rows. A fixed-height
|
|
3643
|
+
* strategy needs only the item count.
|
|
3644
|
+
*/
|
|
3645
|
+
#itemIds = computed(() => {
|
|
2877
3646
|
const idFn = this.itemIdFn();
|
|
3647
|
+
return this.items().map((item) => idFn(item));
|
|
3648
|
+
}, ...(ngDevMode ? [{ debugName: "#itemIds" }] : []));
|
|
3649
|
+
/**
|
|
3650
|
+
* Index of each item ID (the last, when several share one), for the sticky items outside the
|
|
3651
|
+
* rendered rows. Built on first use, so an items change (a drop) builds none.
|
|
3652
|
+
*/
|
|
3653
|
+
#itemIndexMap = computed(() => {
|
|
3654
|
+
const ids = this.#itemIds();
|
|
2878
3655
|
const map = new Map();
|
|
2879
|
-
for (let i = 0; i <
|
|
2880
|
-
map.set(
|
|
3656
|
+
for (let i = 0; i < ids.length; i++) {
|
|
3657
|
+
map.set(ids[i], i);
|
|
2881
3658
|
}
|
|
2882
3659
|
return map;
|
|
2883
3660
|
}, ...(ngDevMode ? [{ debugName: "#itemIndexMap" }] : []));
|
|
2884
|
-
/**
|
|
3661
|
+
/**
|
|
3662
|
+
* The index of the currently dragged item in the items array (-1 if not found or not dragging).
|
|
3663
|
+
* Found without the index map, which only this lookup would need on a drag: a drag that started
|
|
3664
|
+
* in another list has no item here (IDs are unique across lists), the index where the drag found
|
|
3665
|
+
* its item is checked next, and only then every item ID is searched.
|
|
3666
|
+
*/
|
|
2885
3667
|
#draggedItemIndex = computed(() => {
|
|
2886
3668
|
const draggedId = this.draggedItemId();
|
|
2887
3669
|
if (!draggedId)
|
|
2888
3670
|
return -1;
|
|
2889
|
-
|
|
3671
|
+
const droppableId = this.droppableId();
|
|
3672
|
+
const sourceDroppableId = this.#dragState.sourceDroppableId();
|
|
3673
|
+
if (droppableId && sourceDroppableId && droppableId !== sourceDroppableId)
|
|
3674
|
+
return -1;
|
|
3675
|
+
const sourceIndex = this.#dragState.sourceIndex();
|
|
3676
|
+
const items = this.items();
|
|
3677
|
+
if (sourceIndex !== null &&
|
|
3678
|
+
sourceIndex >= 0 &&
|
|
3679
|
+
sourceIndex < items.length &&
|
|
3680
|
+
this.itemIdFn()(items[sourceIndex]) === draggedId) {
|
|
3681
|
+
return sourceIndex;
|
|
3682
|
+
}
|
|
3683
|
+
return this.#itemIds().lastIndexOf(draggedId);
|
|
2890
3684
|
}, ...(ngDevMode ? [{ debugName: "#draggedItemIndex" }] : []));
|
|
2891
3685
|
/** Memoized Set of sticky IDs - rebuilt only when effectiveStickyIds() changes */
|
|
2892
3686
|
#stickyIdsSet = computed(() => new Set(this.effectiveStickyIds()), ...(ngDevMode ? [{ debugName: "#stickyIdsSet" }] : []));
|
|
@@ -2911,31 +3705,20 @@ class VirtualScrollContainerComponent {
|
|
|
2911
3705
|
}
|
|
2912
3706
|
return this.itemHeight();
|
|
2913
3707
|
}, ...(ngDevMode ? [{ debugName: "placeholderHeight" }] : []));
|
|
2914
|
-
/**
|
|
2915
|
-
|
|
3708
|
+
/**
|
|
3709
|
+
* Rows to render: the items in range, then the sticky items outside it. Independent of the
|
|
3710
|
+
* placeholder, so a placeholder move leaves the rows as they are.
|
|
3711
|
+
*/
|
|
3712
|
+
#renderedRows = computed(() => {
|
|
2916
3713
|
const items = this.items();
|
|
2917
3714
|
const { start, end } = this.#renderRange();
|
|
2918
3715
|
const stickyIds = this.#stickyIdsSet();
|
|
2919
3716
|
const idFn = this.itemIdFn();
|
|
2920
|
-
const itemIndexMap = this.#itemIndexMap();
|
|
2921
3717
|
const draggedId = this.draggedItemId();
|
|
2922
|
-
const placeholderIdx = this.placeholderIndex();
|
|
2923
3718
|
const result = [];
|
|
2924
3719
|
const renderedIds = new Set();
|
|
2925
|
-
|
|
2926
|
-
// Add all items in the visible range, inserting placeholder at correct position
|
|
3720
|
+
// Add all items in the visible range
|
|
2927
3721
|
for (let i = start; i <= end && i < items.length; i++) {
|
|
2928
|
-
// Insert placeholder before item at placeholderIndex
|
|
2929
|
-
if (placeholderIdx === i && !hasPlaceholder) {
|
|
2930
|
-
result.push({
|
|
2931
|
-
type: 'placeholder',
|
|
2932
|
-
data: null,
|
|
2933
|
-
index: placeholderIdx,
|
|
2934
|
-
isSticky: false,
|
|
2935
|
-
isDragging: false,
|
|
2936
|
-
});
|
|
2937
|
-
hasPlaceholder = true;
|
|
2938
|
-
}
|
|
2939
3722
|
const item = items[i];
|
|
2940
3723
|
const id = idFn(item);
|
|
2941
3724
|
result.push({
|
|
@@ -2947,24 +3730,14 @@ class VirtualScrollContainerComponent {
|
|
|
2947
3730
|
});
|
|
2948
3731
|
renderedIds.add(id);
|
|
2949
3732
|
}
|
|
2950
|
-
// If placeholder is at the end (after all items), add it
|
|
2951
|
-
if (placeholderIdx >= items.length && placeholderIdx >= 0 && !hasPlaceholder) {
|
|
2952
|
-
result.push({
|
|
2953
|
-
type: 'placeholder',
|
|
2954
|
-
data: null,
|
|
2955
|
-
index: placeholderIdx,
|
|
2956
|
-
isSticky: false,
|
|
2957
|
-
isDragging: false,
|
|
2958
|
-
});
|
|
2959
|
-
hasPlaceholder = true;
|
|
2960
|
-
}
|
|
2961
3733
|
// Add any sticky items that aren't already rendered
|
|
2962
3734
|
const missingStickyIndices = [];
|
|
2963
3735
|
for (const id of stickyIds) {
|
|
2964
3736
|
if (renderedIds.has(id))
|
|
2965
3737
|
continue;
|
|
2966
|
-
|
|
2967
|
-
|
|
3738
|
+
// The drag knows its item's index; the index map serves the other sticky items
|
|
3739
|
+
const index = id === draggedId ? this.#draggedItemIndex() : (this.#itemIndexMap().get(id) ?? -1);
|
|
3740
|
+
if (index < 0)
|
|
2968
3741
|
continue;
|
|
2969
3742
|
missingStickyIndices.push({ id, index });
|
|
2970
3743
|
}
|
|
@@ -2984,6 +3757,45 @@ class VirtualScrollContainerComponent {
|
|
|
2984
3757
|
});
|
|
2985
3758
|
}
|
|
2986
3759
|
return result;
|
|
3760
|
+
}, ...(ngDevMode ? [{ debugName: "#renderedRows" }] : []));
|
|
3761
|
+
/** Track keys of #renderedRows, in order */
|
|
3762
|
+
#rowKeys = computed(() => this.#renderedRows().map((entry, i) => this.trackEntry(i, entry)), ...(ngDevMode ? [{ debugName: "#rowKeys" }] : []));
|
|
3763
|
+
/**
|
|
3764
|
+
* Where the placeholder renders among the rows: before the row at this position (after the
|
|
3765
|
+
* rows in range when it equals their count), or -1 when it is not rendered. It goes before the
|
|
3766
|
+
* item at the placeholder index when that item is in range, and after the rows in range when
|
|
3767
|
+
* the index is past the last item.
|
|
3768
|
+
*/
|
|
3769
|
+
#placeholderSlot = computed(() => {
|
|
3770
|
+
const placeholderIndex = this.placeholderIndex();
|
|
3771
|
+
if (placeholderIndex < 0)
|
|
3772
|
+
return -1;
|
|
3773
|
+
const itemCount = this.items().length;
|
|
3774
|
+
const { start, end } = this.#renderRange();
|
|
3775
|
+
const lastInRange = Math.min(end, itemCount - 1);
|
|
3776
|
+
if (placeholderIndex >= start && placeholderIndex <= lastInRange) {
|
|
3777
|
+
return placeholderIndex - start;
|
|
3778
|
+
}
|
|
3779
|
+
if (placeholderIndex >= itemCount) {
|
|
3780
|
+
return Math.max(0, lastInRange - start + 1);
|
|
3781
|
+
}
|
|
3782
|
+
return -1;
|
|
3783
|
+
}, ...(ngDevMode ? [{ debugName: "#placeholderSlot" }] : []));
|
|
3784
|
+
/** Items to render, including sticky items and placeholder */
|
|
3785
|
+
renderedItems = computed(() => {
|
|
3786
|
+
const rows = this.#renderedRows();
|
|
3787
|
+
const slot = this.#placeholderSlot();
|
|
3788
|
+
const result = [...rows];
|
|
3789
|
+
if (slot >= 0) {
|
|
3790
|
+
result.splice(slot, 0, {
|
|
3791
|
+
type: 'placeholder',
|
|
3792
|
+
data: null,
|
|
3793
|
+
index: this.placeholderIndex(),
|
|
3794
|
+
isSticky: false,
|
|
3795
|
+
isDragging: false,
|
|
3796
|
+
});
|
|
3797
|
+
}
|
|
3798
|
+
return result;
|
|
2987
3799
|
}, ...(ngDevMode ? [{ debugName: "renderedItems" }] : []));
|
|
2988
3800
|
/** Generated ID for auto-scroll registration */
|
|
2989
3801
|
#generatedScrollId = `vdnd-scroll-${Math.random().toString(36).slice(2, 9)}`;
|
|
@@ -2999,12 +3811,10 @@ class VirtualScrollContainerComponent {
|
|
|
2999
3811
|
enabled: () => this.autoScrollEnabled(),
|
|
3000
3812
|
config: () => this.autoScrollConfig(),
|
|
3001
3813
|
});
|
|
3002
|
-
// Keep strategy
|
|
3814
|
+
// Keep the strategy's items in sync. A fixed-height strategy needs only their count, which
|
|
3815
|
+
// spares computing every item's ID on each items change (each drop).
|
|
3003
3816
|
effect(() => {
|
|
3004
|
-
|
|
3005
|
-
const idFn = this.itemIdFn();
|
|
3006
|
-
const keys = items.map((item) => idFn(item));
|
|
3007
|
-
this.#strategy().setItemKeys(keys);
|
|
3817
|
+
setStrategyItems(this.#strategy(), this.items().length, () => this.#itemIds());
|
|
3008
3818
|
});
|
|
3009
3819
|
// Register strategy with drag index calculator for accurate drag calculations
|
|
3010
3820
|
effect((onCleanup) => {
|
|
@@ -3021,16 +3831,6 @@ class VirtualScrollContainerComponent {
|
|
|
3021
3831
|
const draggedIndex = this.#draggedItemIndex();
|
|
3022
3832
|
strategy.setExcludedIndex(draggedIndex >= 0 ? draggedIndex : null);
|
|
3023
3833
|
});
|
|
3024
|
-
// Snapshot item positions before the template re-renders the placeholder move.
|
|
3025
|
-
// Component effects run before the component's own template is refreshed.
|
|
3026
|
-
if (this.#shiftAnimator) {
|
|
3027
|
-
const animator = this.#shiftAnimator;
|
|
3028
|
-
effect(() => {
|
|
3029
|
-
const isDragging = this.#dragState.isDragging();
|
|
3030
|
-
const placeholderIndex = this.placeholderIndex();
|
|
3031
|
-
untracked(() => animator.beforeUpdate(isDragging, placeholderIndex));
|
|
3032
|
-
});
|
|
3033
|
-
}
|
|
3034
3834
|
// Keyboard drag autoscroll: keep the placeholder visible. Arrow keys call
|
|
3035
3835
|
// #revealPlaceholder synchronously (through KeyboardDragService) so a drop right after a
|
|
3036
3836
|
// move never lands outside the rendered range; this effect re-runs it when item
|
|
@@ -3096,12 +3896,37 @@ class VirtualScrollContainerComponent {
|
|
|
3096
3896
|
}
|
|
3097
3897
|
}
|
|
3098
3898
|
});
|
|
3899
|
+
effect(() => {
|
|
3900
|
+
const spacer = this.spacer()?.nativeElement;
|
|
3901
|
+
if (spacer) {
|
|
3902
|
+
spacer.style.height = `${this.totalHeight()}px`;
|
|
3903
|
+
}
|
|
3904
|
+
});
|
|
3905
|
+
// Last, so it renders what the effects above scrolled to. Component effects run before the
|
|
3906
|
+
// component's own view is checked, which has nothing to update: the template has no bindings.
|
|
3907
|
+
effect(() => this.#render());
|
|
3908
|
+
}
|
|
3909
|
+
ngOnChanges() {
|
|
3910
|
+
this.#viewCheckPending = true;
|
|
3099
3911
|
}
|
|
3100
3912
|
ngOnInit() {
|
|
3101
|
-
//
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3913
|
+
// Measure rows in dynamic height mode. dynamicItemHeight can change at runtime, and the
|
|
3914
|
+
// strategy is replaced when it (or itemHeight) does, so this creates, replaces or removes the
|
|
3915
|
+
// observer. #observeRenderedItems observes every rendered row with each new observer, so a
|
|
3916
|
+
// new strategy gets their heights.
|
|
3917
|
+
effect((onCleanup) => {
|
|
3918
|
+
// No ResizeObserver during server rendering
|
|
3919
|
+
if (!this.dynamicItemHeight() || !this.#isBrowser)
|
|
3920
|
+
return;
|
|
3921
|
+
this.#strategy();
|
|
3922
|
+
const observer = untracked(() => this.#createItemResizeObserver());
|
|
3923
|
+
this.#itemResizeObserver.set(observer);
|
|
3924
|
+
onCleanup(() => {
|
|
3925
|
+
observer.disconnect();
|
|
3926
|
+
this.#observedElementByKey.clear();
|
|
3927
|
+
this.#itemResizeObserver.set(null);
|
|
3928
|
+
});
|
|
3929
|
+
}, { injector: this.#injector });
|
|
3105
3930
|
}
|
|
3106
3931
|
ngAfterViewInit() {
|
|
3107
3932
|
const element = this.#elementRef.nativeElement;
|
|
@@ -3120,9 +3945,10 @@ class VirtualScrollContainerComponent {
|
|
|
3120
3945
|
thresholdPx: 5,
|
|
3121
3946
|
});
|
|
3122
3947
|
// Observe rendered items for dynamic height measurement
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3948
|
+
this.#observeRenderedItems();
|
|
3949
|
+
}
|
|
3950
|
+
ngAfterViewChecked() {
|
|
3951
|
+
this.#viewCheckPending = false;
|
|
3126
3952
|
}
|
|
3127
3953
|
/**
|
|
3128
3954
|
* Scroll so the placeholder is fully visible during a keyboard drag into this list.
|
|
@@ -3168,15 +3994,295 @@ class VirtualScrollContainerComponent {
|
|
|
3168
3994
|
this.#scrollTop.set(newScrollTop);
|
|
3169
3995
|
}
|
|
3170
3996
|
}
|
|
3997
|
+
/**
|
|
3998
|
+
* Render the content offset, the rows and the placeholder. From an effect, not the template:
|
|
3999
|
+
* Angular checks every row view in this component's view each time a signal its template reads
|
|
4000
|
+
* changes, so reading the placeholder there re-rendered every row on each placeholder move.
|
|
4001
|
+
* Here a placeholder move only moves the placeholder, and a row renders when its context does.
|
|
4002
|
+
*/
|
|
4003
|
+
#render() {
|
|
4004
|
+
// Snapshot the rendered positions before anything moves, so displaced rows can slide. First,
|
|
4005
|
+
// as a subclass that renders its own template relies on it too.
|
|
4006
|
+
const isDragging = this.#dragState.isDragging();
|
|
4007
|
+
const placeholderIndex = this.placeholderIndex();
|
|
4008
|
+
untracked(() => this.#shiftAnimator?.beforeUpdate(isDragging, placeholderIndex));
|
|
4009
|
+
const outlet = this.rowOutlet();
|
|
4010
|
+
const wrapper = this.contentWrapper()?.nativeElement;
|
|
4011
|
+
// Not this component's template (a subclass's own): it renders renderedItems() itself
|
|
4012
|
+
if (!outlet || !wrapper)
|
|
4013
|
+
return;
|
|
4014
|
+
const template = this.itemTemplate() ?? null;
|
|
4015
|
+
const entries = this.#renderedRows();
|
|
4016
|
+
const keys = this.#rowKeys();
|
|
4017
|
+
const trackBy = this.effectiveTrackByFn();
|
|
4018
|
+
const recycle = this.recycleRows();
|
|
4019
|
+
const transform = this.contentTransform();
|
|
4020
|
+
const slot = this.#placeholderSlot();
|
|
4021
|
+
const placeholderHeight = slot >= 0 ? this.placeholderHeight() : null;
|
|
4022
|
+
// Untracked: the signals the rows' templates read must not become this effect's
|
|
4023
|
+
untracked(() => {
|
|
4024
|
+
if (transform !== this.#contentTransformSet) {
|
|
4025
|
+
wrapper.style.transform = transform;
|
|
4026
|
+
this.#contentTransformSet = transform;
|
|
4027
|
+
}
|
|
4028
|
+
this.#renderRows(outlet, template, entries, keys, trackBy, recycle);
|
|
4029
|
+
this.#renderPlaceholder(outlet, slot, placeholderHeight);
|
|
4030
|
+
});
|
|
4031
|
+
}
|
|
4032
|
+
/**
|
|
4033
|
+
* Give each entry a view of the item template, in order. As with `@for`, a row keeps its view
|
|
4034
|
+
* (and the components in it) while its track key stays rendered, and only the rows out of order
|
|
4035
|
+
* move. The view of a row that leaves is destroyed, or with `recycle` pooled and given to a row
|
|
4036
|
+
* that comes in. Only new, pooled and changed views are checked, with `detectChanges()`:
|
|
4037
|
+
* `markForCheck()` would mark every ancestor view up to the root too.
|
|
4038
|
+
*/
|
|
4039
|
+
#renderRows(outlet, template, entries, keys, trackBy, recycle) {
|
|
4040
|
+
if (!recycle) {
|
|
4041
|
+
this.#destroyRowPool();
|
|
4042
|
+
}
|
|
4043
|
+
// Nothing to do, as on a placeholder move
|
|
4044
|
+
if (entries === this.#rowEntries &&
|
|
4045
|
+
template === this.#rowTemplate &&
|
|
4046
|
+
trackBy === this.#rowTrackBy) {
|
|
4047
|
+
return;
|
|
4048
|
+
}
|
|
4049
|
+
// A new template renders every row anew; without one there is no row (as ngTemplateOutlet)
|
|
4050
|
+
if (template !== this.#rowTemplate) {
|
|
4051
|
+
for (const row of this.#rows) {
|
|
4052
|
+
row.view.destroy();
|
|
4053
|
+
}
|
|
4054
|
+
this.#rows = [];
|
|
4055
|
+
this.#destroyRowPool();
|
|
4056
|
+
this.#rowTemplate = template;
|
|
4057
|
+
}
|
|
4058
|
+
if (!template)
|
|
4059
|
+
return;
|
|
4060
|
+
// A new track function gives the rendered rows new keys; they keep their views (as with @for)
|
|
4061
|
+
if (trackBy !== this.#rowTrackBy) {
|
|
4062
|
+
for (const row of this.#rows) {
|
|
4063
|
+
const { $implicit, index } = row.view.context;
|
|
4064
|
+
row.key = this.trackEntry(index, { type: 'item', data: $implicit, index });
|
|
4065
|
+
}
|
|
4066
|
+
this.#rowTrackBy = trackBy;
|
|
4067
|
+
}
|
|
4068
|
+
// The last render's views by key, in order: an entry takes the first view left for its key
|
|
4069
|
+
const previous = new Map();
|
|
4070
|
+
for (const row of this.#rows) {
|
|
4071
|
+
if (row.view.destroyed)
|
|
4072
|
+
continue;
|
|
4073
|
+
const views = previous.get(row.key);
|
|
4074
|
+
if (views) {
|
|
4075
|
+
views.push(row);
|
|
4076
|
+
}
|
|
4077
|
+
else {
|
|
4078
|
+
previous.set(row.key, [row]);
|
|
4079
|
+
}
|
|
4080
|
+
}
|
|
4081
|
+
const kept = keys.map((key) => previous.get(key)?.shift());
|
|
4082
|
+
this.#warnDuplicateKeys(keys);
|
|
4083
|
+
// Remove the rows that left, then keep in place the longest run of kept views that is
|
|
4084
|
+
// already in order (their positions in the outlet, which now holds only kept views)
|
|
4085
|
+
const dragged = this.#dragState.draggedItem();
|
|
4086
|
+
for (const views of previous.values()) {
|
|
4087
|
+
for (const row of views) {
|
|
4088
|
+
// The dragged item's view is destroyed, as without recycling: that cancels the drag
|
|
4089
|
+
// instead of handing it to another item
|
|
4090
|
+
if (recycle && !this.#rendersDraggedItem(row.view, dragged)) {
|
|
4091
|
+
this.#poolRow(outlet, row.view);
|
|
4092
|
+
}
|
|
4093
|
+
else {
|
|
4094
|
+
row.view.destroy();
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
const positions = new Map();
|
|
4099
|
+
for (let i = 0; i < outlet.length; i++) {
|
|
4100
|
+
positions.set(outlet.get(i), i);
|
|
4101
|
+
}
|
|
4102
|
+
const inPlace = longestIncreasingRun(kept.map((row) => (row ? positions.get(row.view) : -1)));
|
|
4103
|
+
// From the last row up, so each row goes right before the next one, already in place
|
|
4104
|
+
const rows = new Array(entries.length);
|
|
4105
|
+
const toCheck = [];
|
|
4106
|
+
let next = null;
|
|
4107
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
4108
|
+
const entry = entries[i];
|
|
4109
|
+
let row = kept[i];
|
|
4110
|
+
if (row) {
|
|
4111
|
+
if (!inPlace[i]) {
|
|
4112
|
+
this.#moveBefore(outlet, row.view, next);
|
|
4113
|
+
}
|
|
4114
|
+
if (this.#updateContext(row.view.context, entry)) {
|
|
4115
|
+
toCheck.push(row.view);
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
else {
|
|
4119
|
+
const index = next ? outlet.indexOf(next) : outlet.length;
|
|
4120
|
+
// A pooled view is checked whatever its context: nothing checked it while detached
|
|
4121
|
+
let view = this.#rowPool.pop();
|
|
4122
|
+
if (view) {
|
|
4123
|
+
this.#updateContext(view.context, entry);
|
|
4124
|
+
outlet.insert(view, index);
|
|
4125
|
+
}
|
|
4126
|
+
else {
|
|
4127
|
+
const context = {
|
|
4128
|
+
$implicit: entry.data,
|
|
4129
|
+
index: entry.index,
|
|
4130
|
+
isSticky: entry.isSticky,
|
|
4131
|
+
};
|
|
4132
|
+
view = outlet.createEmbeddedView(template, context, index);
|
|
4133
|
+
}
|
|
4134
|
+
row = { key: keys[i], view };
|
|
4135
|
+
toCheck.push(view);
|
|
4136
|
+
}
|
|
4137
|
+
rows[i] = row;
|
|
4138
|
+
next = row.view;
|
|
4139
|
+
}
|
|
4140
|
+
this.#rows = rows;
|
|
4141
|
+
while (this.#rowPool.length > MAX_POOLED_ROWS) {
|
|
4142
|
+
this.#rowPool.pop().destroy();
|
|
4143
|
+
}
|
|
4144
|
+
// When this component's own view is checked later in this pass, that check renders every row
|
|
4145
|
+
if (!this.#viewCheckPending) {
|
|
4146
|
+
// Top to bottom (toCheck lists them bottom up)
|
|
4147
|
+
for (let i = toCheck.length - 1; i >= 0; i--) {
|
|
4148
|
+
toCheck[i].detectChanges();
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
4151
|
+
this.#rowEntries = entries;
|
|
4152
|
+
}
|
|
4153
|
+
/**
|
|
4154
|
+
* Whether a row's view renders the dragged item: its item has the dragged ID, or the view holds
|
|
4155
|
+
* the dragged element (its draggable ID need not be the item's ID).
|
|
4156
|
+
*/
|
|
4157
|
+
#rendersDraggedItem(view, dragged) {
|
|
4158
|
+
if (!dragged)
|
|
4159
|
+
return false;
|
|
4160
|
+
return (this.itemIdFn()(view.context.$implicit) === dragged.draggableId ||
|
|
4161
|
+
view.rootNodes.some((node) => node.contains(dragged.element)));
|
|
4162
|
+
}
|
|
4163
|
+
/**
|
|
4164
|
+
* Take a row's view out of the outlet and pool it. It keeps its element, so its shift
|
|
4165
|
+
* animation is cancelled (the item it renders next must not slide from this one's position),
|
|
4166
|
+
* and a drop animation that hides it shows it again.
|
|
4167
|
+
*/
|
|
4168
|
+
#poolRow(outlet, view) {
|
|
4169
|
+
outlet.detach(outlet.indexOf(view));
|
|
4170
|
+
const nodes = view.rootNodes;
|
|
4171
|
+
// A leave animation (`animate.leave`) keeps the row in the DOM, and Angular removes it when
|
|
4172
|
+
// the animation ends: in the row of another item by then, if the view were reused
|
|
4173
|
+
if (nodes.some((node) => node.parentNode !== null)) {
|
|
4174
|
+
view.destroy();
|
|
4175
|
+
return;
|
|
4176
|
+
}
|
|
4177
|
+
for (const node of nodes) {
|
|
4178
|
+
if (node instanceof HTMLElement) {
|
|
4179
|
+
this.#shiftAnimator?.cancel(node);
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
revealDropTargetIn(nodes);
|
|
4183
|
+
this.#rowPool.push(view);
|
|
4184
|
+
}
|
|
4185
|
+
#destroyRowPool() {
|
|
4186
|
+
for (const view of this.#rowPool) {
|
|
4187
|
+
view.destroy();
|
|
4188
|
+
}
|
|
4189
|
+
this.#rowPool = [];
|
|
4190
|
+
}
|
|
4191
|
+
/** Move a row's view right before `next` (last without one), unless it is there already. */
|
|
4192
|
+
#moveBefore(outlet, view, next) {
|
|
4193
|
+
const target = next ? outlet.indexOf(next) : outlet.length;
|
|
4194
|
+
const current = outlet.indexOf(view);
|
|
4195
|
+
if (current === target - 1)
|
|
4196
|
+
return;
|
|
4197
|
+
// move() takes the view out before inserting it, which shifts the views after it
|
|
4198
|
+
outlet.move(view, current < target ? target - 1 : target);
|
|
4199
|
+
}
|
|
4200
|
+
/** Copy an entry into a row's context. Returns whether the context changed. */
|
|
4201
|
+
#updateContext(context, entry) {
|
|
4202
|
+
if (Object.is(context.$implicit, entry.data) &&
|
|
4203
|
+
context.index === entry.index &&
|
|
4204
|
+
context.isSticky === entry.isSticky) {
|
|
4205
|
+
return false;
|
|
4206
|
+
}
|
|
4207
|
+
context.$implicit = entry.data;
|
|
4208
|
+
context.index = entry.index;
|
|
4209
|
+
context.isSticky = entry.isSticky;
|
|
4210
|
+
return true;
|
|
4211
|
+
}
|
|
4212
|
+
/** Items sharing a track key still render (a view each), but drag and drop needs unique IDs. */
|
|
4213
|
+
#warnDuplicateKeys(keys) {
|
|
4214
|
+
if (this.#warnedDuplicateKeys || keys === this.#rowKeysChecked || !isDevMode())
|
|
4215
|
+
return;
|
|
4216
|
+
this.#rowKeysChecked = keys;
|
|
4217
|
+
const seen = new Set();
|
|
4218
|
+
for (const key of keys) {
|
|
4219
|
+
if (seen.has(key)) {
|
|
4220
|
+
this.#warnedDuplicateKeys = true;
|
|
4221
|
+
console.warn(`[ngx-virtual-dnd] vdnd-virtual-scroll rendered two items with the track key ` +
|
|
4222
|
+
`${String(key)}. Give every item a unique ID (itemIdFn, or trackByFn).`);
|
|
4223
|
+
return;
|
|
4224
|
+
}
|
|
4225
|
+
seen.add(key);
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
/**
|
|
4229
|
+
* Put the placeholder before the row at `slot` (see #placeholderSlot), or take it out of the
|
|
4230
|
+
* DOM. It is a DragPlaceholderComponent of its own, outside this view: moving it or setting its
|
|
4231
|
+
* height checks no row.
|
|
4232
|
+
*/
|
|
4233
|
+
#renderPlaceholder(outlet, slot, height) {
|
|
4234
|
+
if (slot < 0 || height === null) {
|
|
4235
|
+
this.#placeholderElement()?.remove();
|
|
4236
|
+
return;
|
|
4237
|
+
}
|
|
4238
|
+
this.#placeholder ??= createComponent(DragPlaceholderComponent, {
|
|
4239
|
+
environmentInjector: this.#environmentInjector,
|
|
4240
|
+
});
|
|
4241
|
+
if (height !== this.#placeholderHeightSet) {
|
|
4242
|
+
this.#placeholder.setInput('itemHeight', height);
|
|
4243
|
+
this.#placeholder.changeDetectorRef.detectChanges();
|
|
4244
|
+
this.#placeholderHeightSet = height;
|
|
4245
|
+
}
|
|
4246
|
+
// Rows are inserted before the outlet's anchor, so the anchor follows the last of them
|
|
4247
|
+
const anchor = outlet.element.nativeElement;
|
|
4248
|
+
const parent = anchor.parentNode;
|
|
4249
|
+
const element = this.#placeholderElement();
|
|
4250
|
+
if (!parent || !element)
|
|
4251
|
+
return;
|
|
4252
|
+
const before = this.#firstNodeOfRows(slot, parent) ?? anchor;
|
|
4253
|
+
if (element.nextSibling !== before) {
|
|
4254
|
+
parent.insertBefore(element, before);
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4257
|
+
#placeholderElement() {
|
|
4258
|
+
return this.#placeholder?.location.nativeElement ?? null;
|
|
4259
|
+
}
|
|
4260
|
+
/** The first node in `parent` of the rows from `index` on (null when none has one). */
|
|
4261
|
+
#firstNodeOfRows(index, parent) {
|
|
4262
|
+
for (let i = index; i < this.#rows.length; i++) {
|
|
4263
|
+
const nodes = this.#rows[i].view.rootNodes.filter((node) => node.parentNode === parent);
|
|
4264
|
+
const node = firstInDocumentOrder(nodes);
|
|
4265
|
+
if (node)
|
|
4266
|
+
return node;
|
|
4267
|
+
}
|
|
4268
|
+
return null;
|
|
4269
|
+
}
|
|
3171
4270
|
ngOnDestroy() {
|
|
3172
4271
|
this.#scrollCleanup?.();
|
|
3173
4272
|
this.#resizeCleanup?.();
|
|
4273
|
+
const itemResizeObserver = this.#itemResizeObserver();
|
|
3174
4274
|
for (const element of this.#observedElementByKey.values()) {
|
|
3175
|
-
|
|
4275
|
+
itemResizeObserver?.unobserve(element);
|
|
3176
4276
|
}
|
|
3177
4277
|
this.#observedElementByKey.clear();
|
|
3178
|
-
|
|
4278
|
+
itemResizeObserver?.disconnect();
|
|
3179
4279
|
this.#shiftAnimator?.cancelAll();
|
|
4280
|
+
// Detached, so not destroyed with this view
|
|
4281
|
+
this.#destroyRowPool();
|
|
4282
|
+
// Created outside this view, so not destroyed with it (and destroy() leaves it in the DOM)
|
|
4283
|
+
this.#placeholderElement()?.remove();
|
|
4284
|
+
this.#placeholder?.destroy();
|
|
4285
|
+
this.#placeholder = null;
|
|
3180
4286
|
}
|
|
3181
4287
|
#createShiftAnimator() {
|
|
3182
4288
|
const config = inject(VDND_ANIMATION_CONFIG, { optional: true });
|
|
@@ -3190,10 +4296,25 @@ class VirtualScrollContainerComponent {
|
|
|
3190
4296
|
});
|
|
3191
4297
|
}
|
|
3192
4298
|
/**
|
|
3193
|
-
* Rendered item roots
|
|
3194
|
-
*
|
|
4299
|
+
* Rendered item roots, keyed by the row's track key (a recycled view renders another item, so
|
|
4300
|
+
* its element is no identity), and the placeholder.
|
|
3195
4301
|
*/
|
|
3196
4302
|
*#shiftAnimationEntries() {
|
|
4303
|
+
if (this.rowOutlet()) {
|
|
4304
|
+
for (const row of this.#rows) {
|
|
4305
|
+
for (const node of row.view.rootNodes) {
|
|
4306
|
+
if (node instanceof HTMLElement) {
|
|
4307
|
+
yield [row.key, node];
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
const placeholder = this.#placeholderElement();
|
|
4312
|
+
if (placeholder?.isConnected) {
|
|
4313
|
+
yield [placeholder, placeholder];
|
|
4314
|
+
}
|
|
4315
|
+
return;
|
|
4316
|
+
}
|
|
4317
|
+
// A subclass's own template (see #render) never recycles: each element is its own identity
|
|
3197
4318
|
const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
|
|
3198
4319
|
if (!wrapper)
|
|
3199
4320
|
return;
|
|
@@ -3204,50 +4325,52 @@ class VirtualScrollContainerComponent {
|
|
|
3204
4325
|
}
|
|
3205
4326
|
}
|
|
3206
4327
|
/**
|
|
3207
|
-
*
|
|
4328
|
+
* Create the ResizeObserver that measures individual item heights.
|
|
3208
4329
|
*/
|
|
3209
|
-
#
|
|
3210
|
-
this.#ngZone.runOutsideAngular(() => {
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
strategy.setMeasuredHeight(key, height);
|
|
3221
|
-
}
|
|
4330
|
+
#createItemResizeObserver() {
|
|
4331
|
+
return this.#ngZone.runOutsideAngular(() => new ResizeObserver((entries) => {
|
|
4332
|
+
const strategy = this.#strategy();
|
|
4333
|
+
for (const entry of entries) {
|
|
4334
|
+
const element = entry.target;
|
|
4335
|
+
const key = this.#observedElements.get(element);
|
|
4336
|
+
if (key === undefined)
|
|
4337
|
+
continue;
|
|
4338
|
+
const height = entry.borderBoxSize?.[0]?.blockSize ?? element.offsetHeight;
|
|
4339
|
+
if (height > 0) {
|
|
4340
|
+
strategy.setMeasuredHeight(key, height);
|
|
3222
4341
|
}
|
|
3223
|
-
}
|
|
3224
|
-
});
|
|
4342
|
+
}
|
|
4343
|
+
}));
|
|
3225
4344
|
}
|
|
3226
4345
|
/**
|
|
3227
4346
|
* Observe currently rendered items' DOM elements for height changes.
|
|
3228
|
-
* Called after view init
|
|
4347
|
+
* Called after view init; re-observes after each render in dynamic mode, and every rendered
|
|
4348
|
+
* item when a new observer is created.
|
|
3229
4349
|
*/
|
|
3230
4350
|
#observeRenderedItems() {
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
//
|
|
3234
|
-
|
|
3235
|
-
const
|
|
3236
|
-
const idFn = this.itemIdFn();
|
|
3237
|
-
const observer = this.#itemResizeObserver;
|
|
4351
|
+
// Re-observe whenever the rows change, once #render has put them in the DOM. The rows, not
|
|
4352
|
+
// renderedItems(): a placeholder move changes no row. A new item template changes no entry
|
|
4353
|
+
// but renders every row anew, as new elements.
|
|
4354
|
+
afterRenderEffect(() => {
|
|
4355
|
+
const observer = this.#itemResizeObserver();
|
|
3238
4356
|
if (!observer)
|
|
3239
4357
|
return;
|
|
4358
|
+
const rendered = this.#renderedRows();
|
|
4359
|
+
const idFn = this.itemIdFn();
|
|
4360
|
+
this.itemTemplate();
|
|
3240
4361
|
// Find the content wrapper and observe item elements
|
|
3241
4362
|
const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
|
|
3242
4363
|
if (!wrapper)
|
|
3243
4364
|
return;
|
|
4365
|
+
// The DOM element of each item, by its data-draggable-id: one query for all rows, where a
|
|
4366
|
+
// query per row scanned the wrapper once for each (quadratic in the rendered rows)
|
|
4367
|
+
const elementsById = mapByAttribute(wrapper, 'data-draggable-id');
|
|
3244
4368
|
const nextObservedByKey = new Map();
|
|
3245
4369
|
for (const entry of rendered) {
|
|
3246
4370
|
if (entry.type !== 'item' || !entry.data)
|
|
3247
4371
|
continue;
|
|
3248
4372
|
const key = idFn(entry.data);
|
|
3249
|
-
|
|
3250
|
-
const el = queryByAttribute(wrapper, 'data-draggable-id', key);
|
|
4373
|
+
const el = elementsById.get(key);
|
|
3251
4374
|
if (el) {
|
|
3252
4375
|
nextObservedByKey.set(key, el);
|
|
3253
4376
|
}
|
|
@@ -3275,8 +4398,11 @@ class VirtualScrollContainerComponent {
|
|
|
3275
4398
|
* Scroll to a specific position.
|
|
3276
4399
|
*/
|
|
3277
4400
|
scrollTo(position) {
|
|
3278
|
-
this.#elementRef.nativeElement
|
|
3279
|
-
|
|
4401
|
+
const element = this.#elementRef.nativeElement;
|
|
4402
|
+
element.scrollTop = position;
|
|
4403
|
+
// Store where the browser actually scrolled. It clamps to the scrollable range, and if that
|
|
4404
|
+
// leaves the element where it was, no scroll event arrives to correct an out-of-range value.
|
|
4405
|
+
this.#scrollTop.set(element.scrollTop);
|
|
3280
4406
|
}
|
|
3281
4407
|
/**
|
|
3282
4408
|
* Scroll to a specific item index.
|
|
@@ -3306,183 +4432,33 @@ class VirtualScrollContainerComponent {
|
|
|
3306
4432
|
this.scrollTo(newPosition);
|
|
3307
4433
|
}
|
|
3308
4434
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3309
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.
|
|
4435
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.0.6", type: VirtualScrollContainerComponent, isStandalone: true, selector: "vdnd-virtual-scroll", inputs: { itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, scrollContainerId: { classPropertyName: "scrollContainerId", publicName: "scrollContainerId", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, stickyItemIds: { classPropertyName: "stickyItemIds", publicName: "stickyItemIds", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: false, transformFunction: null }, recycleRows: { classPropertyName: "recycleRows", publicName: "recycleRows", isSignal: true, isRequired: false, transformFunction: null }, autoStickyDraggedItem: { classPropertyName: "autoStickyDraggedItem", publicName: "autoStickyDraggedItem", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style.height.px": "containerHeight() ?? null", "attr.data-item-height": "itemHeight()", "attr.data-total-items": "items().length" }, classAttribute: "vdnd-virtual-scroll" }, viewQueries: [{ propertyName: "spacer", first: true, predicate: ["spacer"], descendants: true, isSignal: true }, { propertyName: "contentWrapper", first: true, predicate: ["contentWrapper"], descendants: true, isSignal: true }, { propertyName: "rowOutlet", first: true, predicate: ["rows"], descendants: true, read: ViewContainerRef, isSignal: true }], usesOnChanges: true, ngImport: i0, template: `
|
|
3310
4436
|
<!-- Single spacer maintains scroll height -->
|
|
3311
|
-
<div class="vdnd-virtual-scroll-spacer"
|
|
4437
|
+
<div #spacer class="vdnd-virtual-scroll-spacer"></div>
|
|
3312
4438
|
|
|
3313
4439
|
<!-- Content wrapper positioned via GPU-accelerated transform -->
|
|
3314
|
-
<div class="vdnd-virtual-scroll-content-wrapper"
|
|
3315
|
-
|
|
3316
|
-
@if (entry.type === 'placeholder') {
|
|
3317
|
-
<vdnd-drag-placeholder [itemHeight]="placeholderHeight()" />
|
|
3318
|
-
} @else {
|
|
3319
|
-
<ng-container
|
|
3320
|
-
*ngTemplateOutlet="
|
|
3321
|
-
itemTemplate();
|
|
3322
|
-
context: {
|
|
3323
|
-
$implicit: entry.data,
|
|
3324
|
-
index: entry.index,
|
|
3325
|
-
isSticky: entry.isSticky,
|
|
3326
|
-
}
|
|
3327
|
-
"
|
|
3328
|
-
>
|
|
3329
|
-
</ng-container>
|
|
3330
|
-
}
|
|
3331
|
-
}
|
|
4440
|
+
<div #contentWrapper class="vdnd-virtual-scroll-content-wrapper">
|
|
4441
|
+
<ng-container #rows />
|
|
3332
4442
|
</div>
|
|
3333
|
-
`, 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"],
|
|
4443
|
+
`, isInline: true, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-virtual-scroll-spacer{position:absolute;top:0;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-virtual-scroll-content-wrapper{position:absolute;top:0;left:0;right:0;will-change:transform}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3334
4444
|
}
|
|
3335
4445
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, decorators: [{
|
|
3336
4446
|
type: Component,
|
|
3337
|
-
args: [{ selector: 'vdnd-virtual-scroll', changeDetection: ChangeDetectionStrategy.OnPush,
|
|
4447
|
+
args: [{ selector: 'vdnd-virtual-scroll', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
3338
4448
|
class: 'vdnd-virtual-scroll',
|
|
3339
4449
|
'[style.height.px]': 'containerHeight() ?? null',
|
|
3340
4450
|
'[attr.data-item-height]': 'itemHeight()',
|
|
3341
4451
|
'[attr.data-total-items]': 'items().length',
|
|
3342
4452
|
}, template: `
|
|
3343
4453
|
<!-- Single spacer maintains scroll height -->
|
|
3344
|
-
<div class="vdnd-virtual-scroll-spacer"
|
|
4454
|
+
<div #spacer class="vdnd-virtual-scroll-spacer"></div>
|
|
3345
4455
|
|
|
3346
4456
|
<!-- Content wrapper positioned via GPU-accelerated transform -->
|
|
3347
|
-
<div class="vdnd-virtual-scroll-content-wrapper"
|
|
3348
|
-
|
|
3349
|
-
@if (entry.type === 'placeholder') {
|
|
3350
|
-
<vdnd-drag-placeholder [itemHeight]="placeholderHeight()" />
|
|
3351
|
-
} @else {
|
|
3352
|
-
<ng-container
|
|
3353
|
-
*ngTemplateOutlet="
|
|
3354
|
-
itemTemplate();
|
|
3355
|
-
context: {
|
|
3356
|
-
$implicit: entry.data,
|
|
3357
|
-
index: entry.index,
|
|
3358
|
-
isSticky: entry.isSticky,
|
|
3359
|
-
}
|
|
3360
|
-
"
|
|
3361
|
-
>
|
|
3362
|
-
</ng-container>
|
|
3363
|
-
}
|
|
3364
|
-
}
|
|
4457
|
+
<div #contentWrapper class="vdnd-virtual-scroll-content-wrapper">
|
|
4458
|
+
<ng-container #rows />
|
|
3365
4459
|
</div>
|
|
3366
4460
|
`, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-virtual-scroll-spacer{position:absolute;top:0;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-virtual-scroll-content-wrapper{position:absolute;top:0;left:0;right:0;will-change:transform}\n"] }]
|
|
3367
|
-
}], 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 }] }] } });
|
|
3368
|
-
|
|
3369
|
-
/**
|
|
3370
|
-
* Plays the drop ("settle") animation: the drag preview glides from where it was released
|
|
3371
|
-
* into the dropped item's final position, while that item stays invisible underneath.
|
|
3372
|
-
*
|
|
3373
|
-
* Purely visual. The drag state and the drop events are already final when it starts, so it
|
|
3374
|
-
* never delays or changes a drop. Without a target (the item is not rendered, or scrolled out
|
|
3375
|
-
* of view) the ghost fades out where it was released.
|
|
3376
|
-
* @internal
|
|
3377
|
-
*/
|
|
3378
|
-
class DropAnimator {
|
|
3379
|
-
#config;
|
|
3380
|
-
#ghostAnimation = null;
|
|
3381
|
-
#hideAnimation = null;
|
|
3382
|
-
constructor(config) {
|
|
3383
|
-
this.#config = config;
|
|
3384
|
-
}
|
|
3385
|
-
/** Whether a drop animation would play right now (duration, WAAPI, reduced motion). */
|
|
3386
|
-
isEnabled() {
|
|
3387
|
-
if (this.#duration() <= 0)
|
|
3388
|
-
return false;
|
|
3389
|
-
if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
|
|
3390
|
-
return false;
|
|
3391
|
-
}
|
|
3392
|
-
if (typeof matchMedia !== 'function')
|
|
3393
|
-
return true;
|
|
3394
|
-
return !matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
3395
|
-
}
|
|
3396
|
-
/**
|
|
3397
|
-
* Animate `ghost` onto `target` (or fade it out without one), then call `onDone`.
|
|
3398
|
-
* `onDone` is not called when the animation is cancelled.
|
|
3399
|
-
*/
|
|
3400
|
-
play(ghost, target, onDone) {
|
|
3401
|
-
this.cancel();
|
|
3402
|
-
const timing = {
|
|
3403
|
-
duration: this.#duration(),
|
|
3404
|
-
easing: this.#config.dropEasing ?? DEFAULT_DROP_EASING,
|
|
3405
|
-
fill: 'forwards',
|
|
3406
|
-
};
|
|
3407
|
-
let ghostAnimation;
|
|
3408
|
-
if (target) {
|
|
3409
|
-
const from = ghost.getBoundingClientRect();
|
|
3410
|
-
const dx = target.rect.left - from.left;
|
|
3411
|
-
const dy = target.rect.top - from.top;
|
|
3412
|
-
// Travel on top of the preview's own position transform (width/height must not be
|
|
3413
|
-
// composited, so the base transform is repeated instead of using composite: 'add').
|
|
3414
|
-
const base = ghost.style.transform;
|
|
3415
|
-
ghostAnimation = ghost.animate([
|
|
3416
|
-
{
|
|
3417
|
-
transform: `${base} translate(0px, 0px)`,
|
|
3418
|
-
width: `${from.width}px`,
|
|
3419
|
-
height: `${from.height}px`,
|
|
3420
|
-
},
|
|
3421
|
-
{
|
|
3422
|
-
transform: `${base} translate(${dx}px, ${dy}px)`,
|
|
3423
|
-
width: `${target.rect.width}px`,
|
|
3424
|
-
height: `${target.rect.height}px`,
|
|
3425
|
-
},
|
|
3426
|
-
], timing);
|
|
3427
|
-
// Keep the real item invisible until the ghost lands on it, so it is never seen twice.
|
|
3428
|
-
this.#hideAnimation = target.element.animate([{ opacity: 0 }, { opacity: 0 }], {
|
|
3429
|
-
duration: timing.duration,
|
|
3430
|
-
});
|
|
3431
|
-
}
|
|
3432
|
-
else {
|
|
3433
|
-
ghostAnimation = ghost.animate([{ opacity: 1 }, { opacity: 0 }], timing);
|
|
3434
|
-
}
|
|
3435
|
-
this.#ghostAnimation = ghostAnimation;
|
|
3436
|
-
ghostAnimation.onfinish = () => {
|
|
3437
|
-
if (this.#ghostAnimation !== ghostAnimation)
|
|
3438
|
-
return;
|
|
3439
|
-
this.cancel();
|
|
3440
|
-
onDone();
|
|
3441
|
-
};
|
|
3442
|
-
}
|
|
3443
|
-
/** Stop the running animation immediately and reveal the real item. */
|
|
3444
|
-
cancel() {
|
|
3445
|
-
const ghostAnimation = this.#ghostAnimation;
|
|
3446
|
-
const hideAnimation = this.#hideAnimation;
|
|
3447
|
-
this.#ghostAnimation = null;
|
|
3448
|
-
this.#hideAnimation = null;
|
|
3449
|
-
ghostAnimation?.cancel();
|
|
3450
|
-
hideAnimation?.cancel();
|
|
3451
|
-
}
|
|
3452
|
-
#duration() {
|
|
3453
|
-
return this.#config.dropDuration ?? DEFAULT_DROP_DURATION;
|
|
3454
|
-
}
|
|
3455
|
-
}
|
|
3456
|
-
/**
|
|
3457
|
-
* Find the rendered element of a dropped item: first in the droppable it was dropped on, then
|
|
3458
|
-
* in the one it came from (a drop the consumer rejected or has not committed yet leaves it
|
|
3459
|
-
* there). Returns null when it is not rendered, or rendered but scrolled out of its list's view.
|
|
3460
|
-
*/
|
|
3461
|
-
function findDropTarget(draggableId, droppableIds) {
|
|
3462
|
-
if (typeof document === 'undefined')
|
|
3463
|
-
return null;
|
|
3464
|
-
for (const droppableId of droppableIds) {
|
|
3465
|
-
if (!droppableId)
|
|
3466
|
-
continue;
|
|
3467
|
-
const droppable = queryByAttribute(document, 'data-droppable-id', droppableId);
|
|
3468
|
-
const element = droppable
|
|
3469
|
-
? queryByAttribute(droppable, 'data-draggable-id', draggableId)
|
|
3470
|
-
: null;
|
|
3471
|
-
if (!droppable || !element)
|
|
3472
|
-
continue;
|
|
3473
|
-
const rect = element.getBoundingClientRect();
|
|
3474
|
-
// Hidden, or virtualized/scrolled out of the list's visible box: nowhere to land.
|
|
3475
|
-
if (rect.width === 0 && rect.height === 0)
|
|
3476
|
-
return null;
|
|
3477
|
-
const bounds = droppable.getBoundingClientRect();
|
|
3478
|
-
const visible = rect.bottom > bounds.top &&
|
|
3479
|
-
rect.top < bounds.bottom &&
|
|
3480
|
-
rect.right > bounds.left &&
|
|
3481
|
-
rect.left < bounds.right;
|
|
3482
|
-
return visible ? { element, rect } : null;
|
|
3483
|
-
}
|
|
3484
|
-
return null;
|
|
3485
|
-
}
|
|
4461
|
+
}], ctorParameters: () => [], propDecorators: { spacer: [{ type: i0.ViewChild, args: ['spacer', { isSignal: true }] }], contentWrapper: [{ type: i0.ViewChild, args: ['contentWrapper', { isSignal: true }] }], rowOutlet: [{ type: i0.ViewChild, args: ['rows', { ...{ read: ViewContainerRef }, isSignal: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], scrollContainerId: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollContainerId", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], stickyItemIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "stickyItemIds", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: false }] }], recycleRows: [{ type: i0.Input, args: [{ isSignal: true, alias: "recycleRows", required: false }] }], autoStickyDraggedItem: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoStickyDraggedItem", required: false }] }] } });
|
|
3486
4462
|
|
|
3487
4463
|
/**
|
|
3488
4464
|
* Renders a preview of the dragged item that follows the cursor.
|
|
@@ -3508,6 +4484,8 @@ class DragPreviewComponent {
|
|
|
3508
4484
|
#overlayContainer = inject(OverlayContainerService);
|
|
3509
4485
|
#elementRef = inject((ElementRef));
|
|
3510
4486
|
#injector = inject(Injector);
|
|
4487
|
+
#droppableRegistry = inject(DroppableRegistryService);
|
|
4488
|
+
#scheduler = inject(DragSchedulerService);
|
|
3511
4489
|
#dropAnimator = this.#createDropAnimator();
|
|
3512
4490
|
/** Optional custom template for the preview */
|
|
3513
4491
|
previewTemplate = input(...(ngDevMode ? [undefined, { debugName: "previewTemplate" }] : []));
|
|
@@ -3525,6 +4503,39 @@ class DragPreviewComponent {
|
|
|
3525
4503
|
isSettling = computed(() => this.#settling() !== null, ...(ngDevMode ? [{ debugName: "isSettling" }] : []));
|
|
3526
4504
|
/** The item shown: the live dragged item, or the dropped one while it settles */
|
|
3527
4505
|
displayedItem = computed(() => this.dragState.draggedItem() ?? this.#settling()?.item ?? null, ...(ngDevMode ? [{ debugName: "displayedItem" }] : []));
|
|
4506
|
+
/**
|
|
4507
|
+
* Where the preview goes, from everything but the cursor. The template and its effects never
|
|
4508
|
+
* read the cursor: a signal read there would schedule a full application render on every
|
|
4509
|
+
* pointer frame, and that render lands after the frame is painted (one frame late).
|
|
4510
|
+
*/
|
|
4511
|
+
#placement = computed(() => {
|
|
4512
|
+
const settling = this.#settling();
|
|
4513
|
+
if (settling && !this.dragState.isDragging()) {
|
|
4514
|
+
return {
|
|
4515
|
+
settled: settling.position,
|
|
4516
|
+
offset: { x: 0, y: 0 },
|
|
4517
|
+
initialPosition: null,
|
|
4518
|
+
lockAxis: null,
|
|
4519
|
+
};
|
|
4520
|
+
}
|
|
4521
|
+
return this.#placementFor({
|
|
4522
|
+
grabOffset: this.dragState.grabOffset(),
|
|
4523
|
+
initialPosition: this.dragState.initialPosition(),
|
|
4524
|
+
lockAxis: this.dragState.lockAxis(),
|
|
4525
|
+
});
|
|
4526
|
+
}, ...(ngDevMode ? [{ debugName: "#placement" }] : []));
|
|
4527
|
+
/**
|
|
4528
|
+
* Moves the preview inside the drag scheduler's frame, right after the hit-test wrote the
|
|
4529
|
+
* cursor (bound for stable add/remove). The render-time write in the constructor covers the
|
|
4530
|
+
* rest: the preview appearing, keyboard drags and the settle.
|
|
4531
|
+
*/
|
|
4532
|
+
#writeFramePosition = () => {
|
|
4533
|
+
const preview = this.previewElement()?.nativeElement;
|
|
4534
|
+
if (!preview || this.#settling() || !this.dragState.isDragging()) {
|
|
4535
|
+
return;
|
|
4536
|
+
}
|
|
4537
|
+
this.#place(preview, this.#placement(), this.dragState.cursorPosition());
|
|
4538
|
+
};
|
|
3528
4539
|
/** Whether this preview's template registration is currently counted (kept balanced). */
|
|
3529
4540
|
#templateRegistered = false;
|
|
3530
4541
|
/** The cloned element from drag state (used when no custom template is provided) */
|
|
@@ -3535,6 +4546,7 @@ class DragPreviewComponent {
|
|
|
3535
4546
|
return this.displayedItem()?.clonedElement ?? null;
|
|
3536
4547
|
}, ...(ngDevMode ? [{ debugName: "clonedElement" }] : []));
|
|
3537
4548
|
constructor() {
|
|
4549
|
+
this.#scheduler.addFrameWriter(this.#writeFramePosition);
|
|
3538
4550
|
// Teleport host element into the body-level overlay container after first render.
|
|
3539
4551
|
// This escapes any ancestor CSS transforms that would break position: fixed.
|
|
3540
4552
|
afterNextRender(() => {
|
|
@@ -3543,6 +4555,18 @@ class DragPreviewComponent {
|
|
|
3543
4555
|
container.appendChild(this.#elementRef.nativeElement);
|
|
3544
4556
|
}
|
|
3545
4557
|
});
|
|
4558
|
+
// Place the preview when it appears or anything but the cursor changes (drag start,
|
|
4559
|
+
// keyboard drags, the settle). The cursor is read untracked; pointer moves are written by
|
|
4560
|
+
// #writeFramePosition. Runs before the settle's afterNextRender reads the transform.
|
|
4561
|
+
afterRenderEffect({
|
|
4562
|
+
write: () => {
|
|
4563
|
+
const preview = this.previewElement()?.nativeElement;
|
|
4564
|
+
const placement = this.#placement();
|
|
4565
|
+
if (preview) {
|
|
4566
|
+
this.#place(preview, placement, untracked(this.dragState.cursorPosition));
|
|
4567
|
+
}
|
|
4568
|
+
},
|
|
4569
|
+
});
|
|
3546
4570
|
// Publish whether this preview renders via a custom template so the drag
|
|
3547
4571
|
// directives can skip the drag-start element clone when it won't be shown.
|
|
3548
4572
|
effect(() => {
|
|
@@ -3587,6 +4611,7 @@ class DragPreviewComponent {
|
|
|
3587
4611
|
});
|
|
3588
4612
|
}
|
|
3589
4613
|
ngOnDestroy() {
|
|
4614
|
+
this.#scheduler.removeFrameWriter(this.#writeFramePosition);
|
|
3590
4615
|
this.#dropAnimator?.cancel();
|
|
3591
4616
|
if (this.#templateRegistered) {
|
|
3592
4617
|
this.#overlayContainer.setTemplatePreviewActive(false);
|
|
@@ -3594,32 +4619,31 @@ class DragPreviewComponent {
|
|
|
3594
4619
|
}
|
|
3595
4620
|
this.#elementRef.nativeElement.remove();
|
|
3596
4621
|
}
|
|
3597
|
-
/**
|
|
4622
|
+
/**
|
|
4623
|
+
* Whether the preview is visible. A drag shows it once it has a position: `initialPosition`
|
|
4624
|
+
* is set from the drag-start cursor and, unlike `cursorPosition`, does not change per frame.
|
|
4625
|
+
*/
|
|
3598
4626
|
isVisible = computed(() => {
|
|
3599
|
-
return ((this.dragState.isDragging() && this.dragState.
|
|
4627
|
+
return ((this.dragState.isDragging() && this.dragState.initialPosition() !== null) ||
|
|
4628
|
+
this.isSettling());
|
|
3600
4629
|
}, ...(ngDevMode ? [{ debugName: "isVisible" }] : []));
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
4630
|
+
#placementFor(state) {
|
|
4631
|
+
return {
|
|
4632
|
+
settled: null,
|
|
4633
|
+
// Use grab offset if available (preserves grab position), otherwise fall back to cursorOffset input
|
|
4634
|
+
offset: state.grabOffset ?? this.cursorOffset(),
|
|
4635
|
+
initialPosition: state.initialPosition,
|
|
4636
|
+
lockAxis: state.lockAxis,
|
|
4637
|
+
};
|
|
4638
|
+
}
|
|
4639
|
+
#positionAt(placement, cursor) {
|
|
4640
|
+
if (placement.settled) {
|
|
4641
|
+
return placement.settled;
|
|
3606
4642
|
}
|
|
3607
|
-
return this.#positionFor({
|
|
3608
|
-
cursorPosition: this.dragState.cursorPosition(),
|
|
3609
|
-
grabOffset: this.dragState.grabOffset(),
|
|
3610
|
-
initialPosition: this.dragState.initialPosition(),
|
|
3611
|
-
lockAxis: this.dragState.lockAxis(),
|
|
3612
|
-
});
|
|
3613
|
-
}, ...(ngDevMode ? [{ debugName: "position" }] : []));
|
|
3614
|
-
#positionFor(state) {
|
|
3615
|
-
const cursor = state.cursorPosition;
|
|
3616
|
-
const initialPosition = state.initialPosition;
|
|
3617
|
-
const lockAxis = state.lockAxis;
|
|
3618
4643
|
if (!cursor) {
|
|
3619
4644
|
return { x: 0, y: 0 };
|
|
3620
4645
|
}
|
|
3621
|
-
|
|
3622
|
-
const offset = state.grabOffset ?? this.cursorOffset();
|
|
4646
|
+
const { offset, initialPosition, lockAxis } = placement;
|
|
3623
4647
|
let x = cursor.x - offset.x;
|
|
3624
4648
|
let y = cursor.y - offset.y;
|
|
3625
4649
|
// Apply axis locking if configured
|
|
@@ -3636,10 +4660,10 @@ class DragPreviewComponent {
|
|
|
3636
4660
|
return { x, y };
|
|
3637
4661
|
}
|
|
3638
4662
|
/** Transform-based positioning for better performance (avoid layout from left/top). */
|
|
3639
|
-
|
|
3640
|
-
const { x, y } = this
|
|
3641
|
-
|
|
3642
|
-
}
|
|
4663
|
+
#place(preview, placement, cursor) {
|
|
4664
|
+
const { x, y } = this.#positionAt(placement, cursor);
|
|
4665
|
+
preview.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
|
4666
|
+
}
|
|
3643
4667
|
/** Dimensions of the preview */
|
|
3644
4668
|
dimensions = computed(() => {
|
|
3645
4669
|
const item = this.displayedItem();
|
|
@@ -3675,7 +4699,7 @@ class DragPreviewComponent {
|
|
|
3675
4699
|
}
|
|
3676
4700
|
const settling = {
|
|
3677
4701
|
item: ended.draggedItem,
|
|
3678
|
-
position: this.#
|
|
4702
|
+
position: this.#positionAt(this.#placementFor(ended), ended.cursorPosition),
|
|
3679
4703
|
};
|
|
3680
4704
|
this.#settling.set(settling);
|
|
3681
4705
|
// A normal drop lands in the target list; a cancel, a rejected drop or one the consumer
|
|
@@ -3692,7 +4716,7 @@ class DragPreviewComponent {
|
|
|
3692
4716
|
this.#settling.set(null);
|
|
3693
4717
|
return;
|
|
3694
4718
|
}
|
|
3695
|
-
const target = findDropTarget(settling.item.draggableId, droppableIds);
|
|
4719
|
+
const target = findDropTarget(settling.item.draggableId, droppableIds, (id) => this.#droppableRegistry.getById(id));
|
|
3696
4720
|
this.#dropAnimator.play(ghost, target, () => {
|
|
3697
4721
|
if (this.#settling() === settling) {
|
|
3698
4722
|
this.#settling.set(null);
|
|
@@ -3712,7 +4736,6 @@ class DragPreviewComponent {
|
|
|
3712
4736
|
class="vdnd-drag-preview"
|
|
3713
4737
|
[class.vdnd-drag-preview-dropping]="isSettling()"
|
|
3714
4738
|
[attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
|
|
3715
|
-
[style.transform]="transform()"
|
|
3716
4739
|
[style.width.px]="dimensions().width"
|
|
3717
4740
|
[style.height.px]="dimensions().height"
|
|
3718
4741
|
>
|
|
@@ -3739,7 +4762,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
3739
4762
|
class="vdnd-drag-preview"
|
|
3740
4763
|
[class.vdnd-drag-preview-dropping]="isSettling()"
|
|
3741
4764
|
[attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
|
|
3742
|
-
[style.transform]="transform()"
|
|
3743
4765
|
[style.width.px]="dimensions().width"
|
|
3744
4766
|
[style.height.px]="dimensions().height"
|
|
3745
4767
|
>
|
|
@@ -3940,7 +4962,8 @@ class DroppableDirective {
|
|
|
3940
4962
|
#elementRef = inject((ElementRef));
|
|
3941
4963
|
#dragState = inject(DragStateService);
|
|
3942
4964
|
#autoScroll = inject(AutoScrollService);
|
|
3943
|
-
#
|
|
4965
|
+
#registry = inject(DroppableRegistryService);
|
|
4966
|
+
#ngZone = inject(NgZone);
|
|
3944
4967
|
#parentGroup = inject(VDND_GROUP_TOKEN, { optional: true });
|
|
3945
4968
|
/** Unique identifier for this droppable */
|
|
3946
4969
|
vdndDroppable = input.required(...(ngDevMode ? [{ debugName: "vdndDroppable" }] : []));
|
|
@@ -3969,7 +4992,7 @@ class DroppableDirective {
|
|
|
3969
4992
|
autoScrollConfig = input({}, ...(ngDevMode ? [{ debugName: "autoScrollConfig" }] : []));
|
|
3970
4993
|
/** Constrain drag preview and placeholder to container boundaries */
|
|
3971
4994
|
constrainToContainer = input(false, ...(ngDevMode ? [{ debugName: "constrainToContainer" }] : []));
|
|
3972
|
-
/** Emits when an item is dropped on this droppable */
|
|
4995
|
+
/** Emits when an item is dropped on this droppable: at release, right after its `dragEnd` */
|
|
3973
4996
|
// eslint-disable-next-line @angular-eslint/no-output-native
|
|
3974
4997
|
drop = output();
|
|
3975
4998
|
/**
|
|
@@ -3982,7 +5005,11 @@ class DroppableDirective {
|
|
|
3982
5005
|
const activeId = this.#dragState.activeDroppableId();
|
|
3983
5006
|
return activeId === this.vdndDroppable() && !this.disabled();
|
|
3984
5007
|
}, ...(ngDevMode ? [{ debugName: "isActive" }] : []));
|
|
3985
|
-
/**
|
|
5008
|
+
/**
|
|
5009
|
+
* `END_OF_LIST` while this droppable is active, otherwise null.
|
|
5010
|
+
* @deprecated It never identifies an item. For the position, use the `placeholderMove` output
|
|
5011
|
+
* (and `DropEvent.destination.index` on drop). Will be removed in the next major version.
|
|
5012
|
+
*/
|
|
3986
5013
|
placeholderId = computed(() => {
|
|
3987
5014
|
if (!this.isActive()) {
|
|
3988
5015
|
return null;
|
|
@@ -4010,14 +5037,8 @@ class DroppableDirective {
|
|
|
4010
5037
|
#lastPlaceholderIndex = null;
|
|
4011
5038
|
/** Drag whose initial placeholder position has already been seen by this droppable */
|
|
4012
5039
|
#placeholderTrackedDrag = null;
|
|
4013
|
-
/**
|
|
4014
|
-
#
|
|
4015
|
-
/**
|
|
4016
|
-
* The terminal drag snapshot this droppable has already processed. `endedDragState`
|
|
4017
|
-
* persists until the next drag starts, so this guards against replaying the same drop
|
|
4018
|
-
* when the effect re-runs (e.g. `disabled()` toggling) after the drag has ended.
|
|
4019
|
-
*/
|
|
4020
|
-
#handledEndedState = null;
|
|
5040
|
+
/** Whether this droppable has rendered, so its inputs have values */
|
|
5041
|
+
#rendered = false;
|
|
4021
5042
|
constructor() {
|
|
4022
5043
|
createAutoScrollRegistration({
|
|
4023
5044
|
autoScrollService: this.#autoScroll,
|
|
@@ -4030,67 +5051,41 @@ class DroppableDirective {
|
|
|
4030
5051
|
// would never register. AutoScrollService filters by live scroll geometry per drag.
|
|
4031
5052
|
canRegister: () => Boolean(this.effectiveGroup()),
|
|
4032
5053
|
});
|
|
4033
|
-
|
|
4034
|
-
//
|
|
4035
|
-
//
|
|
4036
|
-
|
|
5054
|
+
afterNextRender(() => (this.#rendered = true));
|
|
5055
|
+
// Register with the droppable registry, and again under the new key when the ID or group
|
|
5056
|
+
// changes. Drag code finds drop targets through the registry instead of querying the
|
|
5057
|
+
// document; a registration during an active drag makes this droppable a target from the
|
|
5058
|
+
// next hit-test. The registry also calls #handleDrop when a drag ends on this droppable.
|
|
5059
|
+
// The cleanup unregisters it, including on destroy.
|
|
5060
|
+
//
|
|
5061
|
+
// Not deferred to afterNextRender: this runs in the change detection that creates the
|
|
5062
|
+
// droppable (inputs are set by then), so afterNextRender hooks of that same render (drop
|
|
5063
|
+
// animation, keyboard focus restore) already find it, and so does a view refreshed only
|
|
5064
|
+
// by its own detectChanges(). Nothing reads the registry before change detection ends.
|
|
5065
|
+
effect((onCleanup) => {
|
|
4037
5066
|
const group = this.effectiveGroup();
|
|
4038
|
-
if (group) {
|
|
4039
|
-
|
|
5067
|
+
if (!group) {
|
|
5068
|
+
return;
|
|
4040
5069
|
}
|
|
5070
|
+
onCleanup(this.#registry.register(this.#elementRef.nativeElement, this.vdndDroppable(), group, (endedState) => this.#handleDrop(endedState)));
|
|
4041
5071
|
});
|
|
4042
5072
|
effect(() => {
|
|
4043
5073
|
const currentIndex = this.#placeholderInsertionIndex();
|
|
4044
5074
|
untracked(() => this.#handlePlaceholderMove(currentIndex));
|
|
4045
5075
|
});
|
|
4046
|
-
// React to state changes and handle drop events.
|
|
4047
|
-
//
|
|
4048
|
-
// Keyed only on the low-frequency fields (isActive/isDragging/draggedItem). The final
|
|
4049
|
-
// placeholder/index values #handleDrop needs are captured by DragStateService.endDrag()
|
|
4050
|
-
// into endedDragState() at the drag-end boundary, so this effect never has to poll the
|
|
4051
|
-
// high-frequency cursor/placeholder signals — reading them here would make it re-run and
|
|
4052
|
-
// re-allocate a snapshot on every 60fps frame, defeating the service's signal splitting.
|
|
4053
|
-
effect(() => {
|
|
4054
|
-
const active = this.isActive();
|
|
4055
|
-
const draggedItem = this.#dragState.draggedItem();
|
|
4056
|
-
const isDragging = this.#dragState.isDragging();
|
|
4057
|
-
// Handle drag end (drop). This droppable is the release target when it was either
|
|
4058
|
-
// observed active during the drag (#wasActive) OR named as the target in the ended
|
|
4059
|
-
// drag snapshot. The snapshot path covers the pointer-up flush, where the final
|
|
4060
|
-
// position is processed and the state cleared in the same synchronous task, so this
|
|
4061
|
-
// effect never observes isActive() === true mid-drag and #wasActive stays false.
|
|
4062
|
-
const endedState = untracked(() => this.#dragState.endedDragState());
|
|
4063
|
-
const endedTargetedThis = endedState?.activeDroppableId === this.vdndDroppable();
|
|
4064
|
-
if (!isDragging && draggedItem === null && (this.#wasActive || endedTargetedThis)) {
|
|
4065
|
-
// Consume each terminal snapshot exactly once. endedDragState persists until the
|
|
4066
|
-
// next drag starts and disabled()/other inputs re-run this effect, so without a
|
|
4067
|
-
// per-snapshot guard a later state change would replay the same historical drop.
|
|
4068
|
-
if (endedState !== this.#handledEndedState) {
|
|
4069
|
-
this.#handledEndedState = endedState;
|
|
4070
|
-
// Emit only for an enabled target that ended normally. A target disabled at
|
|
4071
|
-
// release still consumes the snapshot (so re-enabling can't resurrect the drop)
|
|
4072
|
-
// but emits nothing, keeping dragEnd and drop consistent.
|
|
4073
|
-
if (!this.disabled() && !this.#dragState.wasCancelled()) {
|
|
4074
|
-
this.#handleDrop();
|
|
4075
|
-
}
|
|
4076
|
-
}
|
|
4077
|
-
}
|
|
4078
|
-
this.#wasActive = active;
|
|
4079
|
-
});
|
|
4080
5076
|
}
|
|
4081
5077
|
ngOnDestroy() {
|
|
5078
|
+
// Destroyed before it rendered: its inputs may have no values yet and it never became the
|
|
5079
|
+
// active drop target. (Not an ngOnInit flag: a subclass with its own ngOnInit would skip
|
|
5080
|
+
// it.) A registry registration, if any, is removed by its effect's cleanup.
|
|
5081
|
+
if (!this.#rendered) {
|
|
5082
|
+
return;
|
|
5083
|
+
}
|
|
4082
5084
|
// Clean up if this droppable is destroyed while being active
|
|
4083
5085
|
if (this.isActive()) {
|
|
4084
5086
|
this.#dragState.setActiveDroppable(null);
|
|
4085
5087
|
}
|
|
4086
|
-
//
|
|
4087
|
-
this.#autoScroll.unregisterContainer(this.vdndDroppable());
|
|
4088
|
-
// If this droppable unmounts mid-drag, tell the calculator so it drops it from the
|
|
4089
|
-
// frozen candidate list (deferred re-query runs once the element has left the DOM).
|
|
4090
|
-
const group = untracked(() => this.effectiveGroup());
|
|
4091
|
-
if (group) {
|
|
4092
|
-
this.#positionCalculator.notifyCandidatesChanged(group);
|
|
4093
|
-
}
|
|
5088
|
+
// Auto-scroll and the droppable registry unregister through their effects' cleanup.
|
|
4094
5089
|
}
|
|
4095
5090
|
/**
|
|
4096
5091
|
* Emit placeholderMove when the placeholder moved within (or entered) this droppable.
|
|
@@ -4124,14 +5119,14 @@ class DroppableDirective {
|
|
|
4124
5119
|
});
|
|
4125
5120
|
}
|
|
4126
5121
|
/**
|
|
4127
|
-
*
|
|
5122
|
+
* Emit `drop` for a drag that ended on this droppable. Called by the registry from
|
|
5123
|
+
* `DragStateService.endDrag()`, with the state captured just before the reset. Returns
|
|
5124
|
+
* whether it emitted.
|
|
4128
5125
|
*/
|
|
4129
|
-
#handleDrop() {
|
|
4130
|
-
//
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
if (!state?.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
|
|
4134
|
-
return;
|
|
5126
|
+
#handleDrop(state) {
|
|
5127
|
+
// A target disabled at release is not a valid drop (dragEnd reports no destination).
|
|
5128
|
+
if (this.disabled() || !state.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
|
|
5129
|
+
return false;
|
|
4135
5130
|
}
|
|
4136
5131
|
const sourceDroppableId = state.sourceDroppableId ?? '';
|
|
4137
5132
|
const placeholderId = state.placeholderId ?? END_OF_LIST;
|
|
@@ -4151,7 +5146,7 @@ class DroppableDirective {
|
|
|
4151
5146
|
sourceDroppableId,
|
|
4152
5147
|
activeDroppableId: this.vdndDroppable(),
|
|
4153
5148
|
});
|
|
4154
|
-
|
|
5149
|
+
const event = {
|
|
4155
5150
|
source: {
|
|
4156
5151
|
draggableId: state.draggedItem.draggableId,
|
|
4157
5152
|
droppableId: sourceDroppableId,
|
|
@@ -4164,7 +5159,13 @@ class DroppableDirective {
|
|
|
4164
5159
|
index: destinationIndex,
|
|
4165
5160
|
data: this.vdndDroppableData(),
|
|
4166
5161
|
},
|
|
4167
|
-
}
|
|
5162
|
+
};
|
|
5163
|
+
// Delivered from the pointer/keyboard listener, which runs outside Angular's zone. With
|
|
5164
|
+
// zone.js a template listener marks its view dirty without scheduling a render, so emit
|
|
5165
|
+
// inside the zone for the consumer's handler (and async work it starts) to render. A
|
|
5166
|
+
// no-op when zoneless.
|
|
5167
|
+
this.#ngZone.run(() => this.drop.emit(event));
|
|
5168
|
+
return true;
|
|
4168
5169
|
}
|
|
4169
5170
|
/**
|
|
4170
5171
|
* Get the index of an item in a droppable.
|
|
@@ -4172,7 +5173,7 @@ class DroppableDirective {
|
|
|
4172
5173
|
*/
|
|
4173
5174
|
#getItemIndex(draggableId, droppableId) {
|
|
4174
5175
|
// Find all draggables in the source droppable
|
|
4175
|
-
const droppable =
|
|
5176
|
+
const droppable = this.#registry.getById(droppableId);
|
|
4176
5177
|
if (!droppable) {
|
|
4177
5178
|
return 0;
|
|
4178
5179
|
}
|
|
@@ -4245,6 +5246,30 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4245
5246
|
}]
|
|
4246
5247
|
}], ctorParameters: () => [], propDecorators: { vdndDroppable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppable", required: true }] }], vdndDroppableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableGroup", required: false }] }], vdndDroppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }], placeholderMove: [{ type: i0.Output, args: ["placeholderMove"] }] } });
|
|
4247
5248
|
|
|
5249
|
+
/**
|
|
5250
|
+
* Forwards the inner droppable's `drop` and `placeholderMove` to the sortable list's outputs.
|
|
5251
|
+
*
|
|
5252
|
+
* Not template listeners: Angular marks the listening view and every ancestor up to the root
|
|
5253
|
+
* dirty before running one, so each placeholder move would re-render the consumer's whole
|
|
5254
|
+
* component chain even when nothing listens to `placeholderMove`. A subscription marks nothing;
|
|
5255
|
+
* a consumer's own listener on the sortable list marks only its own view. Subscribed in the
|
|
5256
|
+
* constructor, before the droppable's first change detection can emit; both outputs drop their
|
|
5257
|
+
* listeners when the element is destroyed.
|
|
5258
|
+
*/
|
|
5259
|
+
class SortableListOutputsDirective {
|
|
5260
|
+
constructor() {
|
|
5261
|
+
const droppable = inject(DroppableDirective);
|
|
5262
|
+
const list = inject(VirtualSortableListComponent);
|
|
5263
|
+
droppable.drop.subscribe((event) => list.drop.emit(event));
|
|
5264
|
+
droppable.placeholderMove.subscribe((event) => list.placeholderMove.emit(event));
|
|
5265
|
+
}
|
|
5266
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: SortableListOutputsDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
5267
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.6", type: SortableListOutputsDirective, isStandalone: true, selector: "[vdndSortableListOutputs]", ngImport: i0 });
|
|
5268
|
+
}
|
|
5269
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: SortableListOutputsDirective, decorators: [{
|
|
5270
|
+
type: Directive,
|
|
5271
|
+
args: [{ selector: '[vdndSortableListOutputs]' }]
|
|
5272
|
+
}], ctorParameters: () => [] });
|
|
4248
5273
|
/**
|
|
4249
5274
|
* A high-level component that combines droppable, virtual scroll, and placeholder
|
|
4250
5275
|
* functionality into a single, easy-to-use component.
|
|
@@ -4322,6 +5347,13 @@ class VirtualSortableListComponent {
|
|
|
4322
5347
|
containerHeight = input(...(ngDevMode ? [undefined, { debugName: "containerHeight" }] : []));
|
|
4323
5348
|
/** Number of items to render above/below the visible area */
|
|
4324
5349
|
overscan = input(3, ...(ngDevMode ? [{ debugName: "overscan" }] : []));
|
|
5350
|
+
/**
|
|
5351
|
+
* Reuse the views of rows that scroll out to render the rows that scroll in, instead of
|
|
5352
|
+
* destroying them and creating new ones. Scrolling creates fewer components and elements, but
|
|
5353
|
+
* a row's components, element and DOM state (focus aside) then carry over to other items.
|
|
5354
|
+
* @default false
|
|
5355
|
+
*/
|
|
5356
|
+
recycleRows = input(false, ...(ngDevMode ? [{ debugName: "recycleRows" }] : []));
|
|
4325
5357
|
/** Enable auto-scroll when dragging near edges */
|
|
4326
5358
|
autoScrollEnabled = input(true, ...(ngDevMode ? [{ debugName: "autoScrollEnabled" }] : []));
|
|
4327
5359
|
/** Auto-scroll configuration */
|
|
@@ -4335,7 +5367,7 @@ class VirtualSortableListComponent {
|
|
|
4335
5367
|
/** Emits each time the placeholder moves within this list (every item displacement) */
|
|
4336
5368
|
placeholderMove = output();
|
|
4337
5369
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
4338
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualSortableListComponent, isStandalone: true, selector: "vdnd-sortable-list", inputs: { droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: true, transformFunction: null }, group: { classPropertyName: "group", publicName: "group", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableData: { classPropertyName: "droppableData", publicName: "droppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, constrainToContainer: { classPropertyName: "constrainToContainer", publicName: "constrainToContainer", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { drop: "drop", placeholderMove: "placeholderMove" }, host: { classAttribute: "vdnd-sortable-list" }, ngImport: i0, template: `
|
|
5370
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualSortableListComponent, isStandalone: true, selector: "vdnd-sortable-list", inputs: { droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: true, transformFunction: null }, group: { classPropertyName: "group", publicName: "group", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableData: { classPropertyName: "droppableData", publicName: "droppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, recycleRows: { classPropertyName: "recycleRows", publicName: "recycleRows", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, constrainToContainer: { classPropertyName: "constrainToContainer", publicName: "constrainToContainer", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { drop: "drop", placeholderMove: "placeholderMove" }, host: { classAttribute: "vdnd-sortable-list" }, ngImport: i0, template: `
|
|
4339
5371
|
<div
|
|
4340
5372
|
class="vdnd-sortable-list-droppable"
|
|
4341
5373
|
[vdndDroppable]="droppableId()"
|
|
@@ -4343,8 +5375,7 @@ class VirtualSortableListComponent {
|
|
|
4343
5375
|
[vdndDroppableData]="droppableData()"
|
|
4344
5376
|
[disabled]="disabled()"
|
|
4345
5377
|
[constrainToContainer]="constrainToContainer()"
|
|
4346
|
-
|
|
4347
|
-
(placeholderMove)="placeholderMove.emit($event)"
|
|
5378
|
+
vdndSortableListOutputs
|
|
4348
5379
|
>
|
|
4349
5380
|
<vdnd-virtual-scroll
|
|
4350
5381
|
[items]="items()"
|
|
@@ -4357,16 +5388,17 @@ class VirtualSortableListComponent {
|
|
|
4357
5388
|
[autoStickyDraggedItem]="true"
|
|
4358
5389
|
[containerHeight]="containerHeight()"
|
|
4359
5390
|
[overscan]="overscan()"
|
|
5391
|
+
[recycleRows]="recycleRows()"
|
|
4360
5392
|
[autoScrollEnabled]="autoScrollEnabled()"
|
|
4361
5393
|
[autoScrollConfig]="autoScrollConfig()"
|
|
4362
5394
|
>
|
|
4363
5395
|
</vdnd-virtual-scroll>
|
|
4364
5396
|
</div>
|
|
4365
|
-
`, isInline: true, styles: [":host{display:block}\n"], dependencies: [{ kind: "component", type: VirtualScrollContainerComponent, selector: "vdnd-virtual-scroll", inputs: ["itemTemplate", "scrollContainerId", "autoScrollEnabled", "autoScrollConfig", "items", "itemHeight", "dynamicItemHeight", "containerHeight", "overscan", "stickyItemIds", "itemIdFn", "trackByFn", "droppableId", "autoStickyDraggedItem"] }, { kind: "directive", type: DroppableDirective, selector: "[vdndDroppable]", inputs: ["vdndDroppable", "vdndDroppableGroup", "vdndDroppableData", "disabled", "autoScrollEnabled", "autoScrollConfig", "constrainToContainer"], outputs: ["drop", "placeholderMove"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
5397
|
+
`, isInline: true, styles: [":host{display:block}\n"], dependencies: [{ kind: "component", type: VirtualScrollContainerComponent, selector: "vdnd-virtual-scroll", inputs: ["itemTemplate", "scrollContainerId", "autoScrollEnabled", "autoScrollConfig", "items", "itemHeight", "dynamicItemHeight", "containerHeight", "overscan", "stickyItemIds", "itemIdFn", "trackByFn", "droppableId", "recycleRows", "autoStickyDraggedItem"] }, { kind: "directive", type: DroppableDirective, selector: "[vdndDroppable]", inputs: ["vdndDroppable", "vdndDroppableGroup", "vdndDroppableData", "disabled", "autoScrollEnabled", "autoScrollConfig", "constrainToContainer"], outputs: ["drop", "placeholderMove"] }, { kind: "directive", type: SortableListOutputsDirective, selector: "[vdndSortableListOutputs]" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
4366
5398
|
}
|
|
4367
5399
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, decorators: [{
|
|
4368
5400
|
type: Component,
|
|
4369
|
-
args: [{ selector: 'vdnd-sortable-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [VirtualScrollContainerComponent, DroppableDirective], host: {
|
|
5401
|
+
args: [{ selector: 'vdnd-sortable-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [VirtualScrollContainerComponent, DroppableDirective, SortableListOutputsDirective], host: {
|
|
4370
5402
|
class: 'vdnd-sortable-list',
|
|
4371
5403
|
}, template: `
|
|
4372
5404
|
<div
|
|
@@ -4376,8 +5408,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4376
5408
|
[vdndDroppableData]="droppableData()"
|
|
4377
5409
|
[disabled]="disabled()"
|
|
4378
5410
|
[constrainToContainer]="constrainToContainer()"
|
|
4379
|
-
|
|
4380
|
-
(placeholderMove)="placeholderMove.emit($event)"
|
|
5411
|
+
vdndSortableListOutputs
|
|
4381
5412
|
>
|
|
4382
5413
|
<vdnd-virtual-scroll
|
|
4383
5414
|
[items]="items()"
|
|
@@ -4390,13 +5421,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4390
5421
|
[autoStickyDraggedItem]="true"
|
|
4391
5422
|
[containerHeight]="containerHeight()"
|
|
4392
5423
|
[overscan]="overscan()"
|
|
5424
|
+
[recycleRows]="recycleRows()"
|
|
4393
5425
|
[autoScrollEnabled]="autoScrollEnabled()"
|
|
4394
5426
|
[autoScrollConfig]="autoScrollConfig()"
|
|
4395
5427
|
>
|
|
4396
5428
|
</vdnd-virtual-scroll>
|
|
4397
5429
|
</div>
|
|
4398
5430
|
`, styles: [":host{display:block}\n"] }]
|
|
4399
|
-
}], propDecorators: { droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: true }] }], group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }], placeholderMove: [{ type: i0.Output, args: ["placeholderMove"] }] } });
|
|
5431
|
+
}], propDecorators: { droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: true }] }], group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], recycleRows: [{ type: i0.Input, args: [{ isSignal: true, alias: "recycleRows", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }], placeholderMove: [{ type: i0.Output, args: ["placeholderMove"] }] } });
|
|
4400
5432
|
|
|
4401
5433
|
/**
|
|
4402
5434
|
* A virtual viewport component that provides efficient wrapper-based positioning
|
|
@@ -4557,8 +5589,9 @@ class VirtualViewportComponent {
|
|
|
4557
5589
|
});
|
|
4558
5590
|
}
|
|
4559
5591
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualViewportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
4560
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualViewportComponent, isStandalone: true, selector: "vdnd-virtual-viewport", inputs: { itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, contentOffset: { classPropertyName: "contentOffset", publicName: "contentOffset", isSignal: true, isRequired: false, 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 } }, host: { classAttribute: "vdnd-virtual-viewport" }, providers: [
|
|
5592
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualViewportComponent, isStandalone: true, selector: "vdnd-virtual-viewport", inputs: { itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, contentOffset: { classPropertyName: "contentOffset", publicName: "contentOffset", isSignal: true, isRequired: false, 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 } }, host: { attributes: { "data-virtual-viewport": "" }, properties: { "attr.data-content-offset": "contentOffset()" }, classAttribute: "vdnd-virtual-viewport" }, providers: [
|
|
4561
5593
|
{ provide: VDND_VIRTUAL_VIEWPORT, useExisting: VirtualViewportComponent },
|
|
5594
|
+
{ provide: VDND_OFFSET_ROWS_VIEWPORT, useExisting: VirtualViewportComponent },
|
|
4562
5595
|
{ provide: VDND_SCROLL_CONTAINER, useExisting: VirtualViewportComponent },
|
|
4563
5596
|
], ngImport: i0, template: `
|
|
4564
5597
|
<!-- Spacer maintains total scroll height -->
|
|
@@ -4582,9 +5615,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4582
5615
|
type: Component,
|
|
4583
5616
|
args: [{ selector: 'vdnd-virtual-viewport', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
|
|
4584
5617
|
{ provide: VDND_VIRTUAL_VIEWPORT, useExisting: VirtualViewportComponent },
|
|
5618
|
+
{ provide: VDND_OFFSET_ROWS_VIEWPORT, useExisting: VirtualViewportComponent },
|
|
4585
5619
|
{ provide: VDND_SCROLL_CONTAINER, useExisting: VirtualViewportComponent },
|
|
4586
5620
|
], host: {
|
|
4587
5621
|
class: 'vdnd-virtual-viewport',
|
|
5622
|
+
// Read by the drag index calculator: this element scrolls its own rows, which start
|
|
5623
|
+
// data-content-offset px below the top of the scroll area
|
|
5624
|
+
'data-virtual-viewport': '',
|
|
5625
|
+
'[attr.data-content-offset]': 'contentOffset()',
|
|
4588
5626
|
}, template: `
|
|
4589
5627
|
<!-- Spacer maintains total scroll height -->
|
|
4590
5628
|
<div
|
|
@@ -4679,6 +5717,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4679
5717
|
class VirtualContentComponent {
|
|
4680
5718
|
#elementRef = inject((ElementRef));
|
|
4681
5719
|
#ngZone = inject(NgZone);
|
|
5720
|
+
#isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
4682
5721
|
/**
|
|
4683
5722
|
* The parent scroll container injected via skip-self to get the actual scrollable element.
|
|
4684
5723
|
* We provide our own VDND_SCROLL_CONTAINER with adjusted values to children.
|
|
@@ -4807,6 +5846,10 @@ class VirtualContentComponent {
|
|
|
4807
5846
|
this.#measuredHeaderHeight.set(0);
|
|
4808
5847
|
return;
|
|
4809
5848
|
}
|
|
5849
|
+
// Server rendering: no layout to measure, and no ResizeObserver
|
|
5850
|
+
if (!this.#isBrowser) {
|
|
5851
|
+
return;
|
|
5852
|
+
}
|
|
4810
5853
|
const el = dir.elementRef.nativeElement;
|
|
4811
5854
|
this.#measuredHeaderHeight.set(el.offsetHeight);
|
|
4812
5855
|
let observer = null;
|
|
@@ -4885,15 +5928,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4885
5928
|
class KeyboardDragHandler {
|
|
4886
5929
|
#deps;
|
|
4887
5930
|
#boundKeyDown = null;
|
|
5931
|
+
/**
|
|
5932
|
+
* The drag this handler started. Keyboard drag state is shared by every draggable, so a key
|
|
5933
|
+
* that reaches another item (one focused by a click during the drag, for example) must not
|
|
5934
|
+
* move, drop or cancel this drag in that item's name.
|
|
5935
|
+
*/
|
|
5936
|
+
#ownDrag = null;
|
|
4888
5937
|
constructor(deps) {
|
|
4889
5938
|
this.#deps = deps;
|
|
4890
5939
|
this.#boundKeyDown = this.#onDocumentKeyDown.bind(this);
|
|
4891
5940
|
}
|
|
4892
5941
|
/**
|
|
4893
|
-
* Whether a keyboard drag is
|
|
5942
|
+
* Whether a keyboard drag this handler started is active.
|
|
4894
5943
|
*/
|
|
4895
5944
|
isActive() {
|
|
4896
|
-
return this.#
|
|
5945
|
+
return (this.#ownDrag !== null &&
|
|
5946
|
+
this.#deps.keyboardDrag.isActive() &&
|
|
5947
|
+
this.#deps.dragState.draggedItem() === this.#ownDrag);
|
|
4897
5948
|
}
|
|
4898
5949
|
/**
|
|
4899
5950
|
* Start a keyboard drag operation.
|
|
@@ -4937,6 +5988,7 @@ class KeyboardDragHandler {
|
|
|
4937
5988
|
width: rect.width,
|
|
4938
5989
|
data: ctx.data,
|
|
4939
5990
|
}, sourceIndex, totalItemCount, droppableId);
|
|
5991
|
+
this.#ownDrag = this.#deps.dragState.draggedItem();
|
|
4940
5992
|
// Add document-level keyboard listener (since element is hidden with display:none)
|
|
4941
5993
|
this.#deps.ngZone.runOutsideAngular(() => {
|
|
4942
5994
|
document.addEventListener('keydown', this.#boundKeyDown);
|
|
@@ -5024,22 +6076,25 @@ class KeyboardDragHandler {
|
|
|
5024
6076
|
this.#cleanupDocumentListener();
|
|
5025
6077
|
// Clear droppable metadata cache from this drag session
|
|
5026
6078
|
this.#deps.dragIndexCalculator.clearCache();
|
|
5027
|
-
|
|
5028
|
-
this.#deps.callbacks.onDragEnd({
|
|
6079
|
+
const event = {
|
|
5029
6080
|
draggableId: ctx.draggableId,
|
|
5030
6081
|
droppableId: this.#deps.callbacks.getParentDroppableId() ?? '',
|
|
5031
6082
|
cancelled: false,
|
|
5032
6083
|
data: ctx.data,
|
|
5033
6084
|
sourceIndex,
|
|
5034
6085
|
destinationIndex,
|
|
5035
|
-
}
|
|
6086
|
+
};
|
|
5036
6087
|
// The item stays in its source list when there is no valid target
|
|
5037
6088
|
const fallbackDroppableId = hasValidTarget
|
|
5038
6089
|
? activeDroppableId
|
|
5039
6090
|
: this.#deps.dragState.sourceDroppableId();
|
|
5040
|
-
this.#
|
|
5041
|
-
|
|
5042
|
-
|
|
6091
|
+
this.#endInZone(() => {
|
|
6092
|
+
this.#deps.callbacks.onDragEnd(event);
|
|
6093
|
+
// Ends the drag state, which delivers the drop
|
|
6094
|
+
this.#deps.keyboardDrag.completeKeyboardDrag();
|
|
6095
|
+
// Restore focus to the moved element after state updates
|
|
6096
|
+
this.#restoreFocus(ctx.draggableId, fallbackDroppableId);
|
|
6097
|
+
});
|
|
5043
6098
|
}
|
|
5044
6099
|
/**
|
|
5045
6100
|
* Cancel a keyboard drag operation.
|
|
@@ -5053,18 +6108,30 @@ class KeyboardDragHandler {
|
|
|
5053
6108
|
this.#cleanupDocumentListener();
|
|
5054
6109
|
// Clear droppable metadata cache from this drag session
|
|
5055
6110
|
this.#deps.dragIndexCalculator.clearCache();
|
|
5056
|
-
|
|
5057
|
-
this.#deps.callbacks.onDragEnd({
|
|
6111
|
+
const event = {
|
|
5058
6112
|
draggableId: ctx.draggableId,
|
|
5059
6113
|
droppableId: this.#deps.callbacks.getParentDroppableId() ?? '',
|
|
5060
6114
|
cancelled: true,
|
|
5061
6115
|
data: ctx.data,
|
|
5062
6116
|
sourceIndex,
|
|
5063
6117
|
destinationIndex: null,
|
|
6118
|
+
};
|
|
6119
|
+
this.#endInZone(() => {
|
|
6120
|
+
this.#deps.callbacks.onDragEnd(event);
|
|
6121
|
+
this.#deps.keyboardDrag.cancelKeyboardDrag();
|
|
6122
|
+
// Restore focus to the original element after state updates
|
|
6123
|
+
this.#restoreFocus(ctx.draggableId, sourceDroppableId);
|
|
5064
6124
|
});
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
6125
|
+
}
|
|
6126
|
+
/**
|
|
6127
|
+
* Run a drag end (dragEnd, the state reset that delivers drop, the focus restore) in one
|
|
6128
|
+
* entry into Angular's zone. The document listener runs outside it; with zone.js a
|
|
6129
|
+
* template listener marks its view dirty but schedules no render, and each entry renders
|
|
6130
|
+
* when it is left, so this renders the end once, with focus restored in that render. A
|
|
6131
|
+
* no-op when zoneless.
|
|
6132
|
+
*/
|
|
6133
|
+
#endInZone(end) {
|
|
6134
|
+
this.#deps.ngZone.run(end);
|
|
5068
6135
|
}
|
|
5069
6136
|
/**
|
|
5070
6137
|
* Teardown — remove any active document listener.
|
|
@@ -5092,7 +6159,7 @@ class KeyboardDragHandler {
|
|
|
5092
6159
|
}
|
|
5093
6160
|
else if (fallbackDroppableId) {
|
|
5094
6161
|
// Fallback: focus the first draggable in the list the item ended up in
|
|
5095
|
-
const container =
|
|
6162
|
+
const container = this.#deps.positionCalculator.getDroppableById(fallbackDroppableId);
|
|
5096
6163
|
const firstDraggable = container?.querySelector('[data-draggable-id]');
|
|
5097
6164
|
firstDraggable?.focus();
|
|
5098
6165
|
}
|
|
@@ -5132,19 +6199,42 @@ class KeyboardDragHandler {
|
|
|
5132
6199
|
* Document-level keydown listener active during keyboard drag.
|
|
5133
6200
|
*/
|
|
5134
6201
|
#onDocumentKeyDown(event) {
|
|
6202
|
+
// The drag ended without this handler (its state was reset elsewhere): stop listening, or
|
|
6203
|
+
// this listener would also take the keys of the next keyboard drag
|
|
6204
|
+
if (!this.isActive()) {
|
|
6205
|
+
this.#cleanupDocumentListener();
|
|
6206
|
+
return;
|
|
6207
|
+
}
|
|
5135
6208
|
this.handleKey(event);
|
|
5136
6209
|
}
|
|
5137
6210
|
/**
|
|
5138
6211
|
* Remove the document-level keyboard listener.
|
|
5139
6212
|
*/
|
|
5140
6213
|
#cleanupDocumentListener() {
|
|
5141
|
-
|
|
6214
|
+
this.#ownDrag = null;
|
|
6215
|
+
// Server rendering destroys the directive without a global document, and never added it
|
|
6216
|
+
if (this.#boundKeyDown && typeof document !== 'undefined') {
|
|
5142
6217
|
document.removeEventListener('keydown', this.#boundKeyDown);
|
|
5143
6218
|
}
|
|
5144
6219
|
}
|
|
5145
6220
|
}
|
|
5146
6221
|
|
|
5147
|
-
|
|
6222
|
+
/**
|
|
6223
|
+
* Elements inside a draggable that keep their own interaction: pressing one never starts a
|
|
6224
|
+
* pointer drag, and Space pressed on one never starts a keyboard drag.
|
|
6225
|
+
*/
|
|
6226
|
+
const INTERACTIVE_ELEMENT_SELECTOR = 'button, input, textarea, select, [contenteditable]';
|
|
6227
|
+
/** Class that excludes the element carrying it, and everything inside it, from starting a drag. */
|
|
6228
|
+
const NO_DRAG_CLASS = 'no-drag';
|
|
6229
|
+
/**
|
|
6230
|
+
* The `no-drag` element inside `draggable` (the draggable itself included) that contains
|
|
6231
|
+
* `target`, or null. A `no-drag` ancestor of the draggable does not count.
|
|
6232
|
+
*/
|
|
6233
|
+
function findNoDragElement(target, draggable) {
|
|
6234
|
+
const noDrag = target.closest(`.${NO_DRAG_CLASS}`);
|
|
6235
|
+
return noDrag !== null && draggable.contains(noDrag) ? noDrag : null;
|
|
6236
|
+
}
|
|
6237
|
+
|
|
5148
6238
|
/**
|
|
5149
6239
|
* Handles pointer-based (mouse + touch) drag operations.
|
|
5150
6240
|
*
|
|
@@ -5154,7 +6244,8 @@ var _a;
|
|
|
5154
6244
|
* - Drag delay timer
|
|
5155
6245
|
* - Forwarding moves to the directive (DragSchedulerService coalesces them per frame)
|
|
5156
6246
|
* - Document-level listener management
|
|
5157
|
-
* - Escape key cancellation during pointer drag
|
|
6247
|
+
* - Escape key cancellation during pointer drag, and cancellation when the window loses focus
|
|
6248
|
+
* or the page is hidden (the release would never arrive) or the system cancels the touch
|
|
5158
6249
|
*/
|
|
5159
6250
|
class PointerDragHandler {
|
|
5160
6251
|
#deps;
|
|
@@ -5162,10 +6253,18 @@ class PointerDragHandler {
|
|
|
5162
6253
|
#startPosition = null;
|
|
5163
6254
|
/** Whether we're currently tracking a potential drag */
|
|
5164
6255
|
#isTracking = false;
|
|
6256
|
+
/** The item the tracked press landed on */
|
|
6257
|
+
#pressedId = null;
|
|
6258
|
+
/** Whether the document and window listeners of a press are added */
|
|
6259
|
+
#listening = false;
|
|
6260
|
+
/** Identifier of the finger that pressed, for touch gestures (other fingers are ignored) */
|
|
6261
|
+
#touchId = null;
|
|
5165
6262
|
/** Bound event handlers for cleanup */
|
|
5166
6263
|
#boundPointerMove = null;
|
|
5167
6264
|
#boundPointerUp = null;
|
|
5168
6265
|
#boundKeyDown = null;
|
|
6266
|
+
#boundWindowBlur = null;
|
|
6267
|
+
#boundVisibilityChange = null;
|
|
5169
6268
|
/** Timer ID for drag delay */
|
|
5170
6269
|
#delayTimerId = null;
|
|
5171
6270
|
/** Whether the delay has been satisfied (user held long enough) */
|
|
@@ -5175,6 +6274,8 @@ class PointerDragHandler {
|
|
|
5175
6274
|
this.#boundPointerMove = this.#onPointerMove.bind(this);
|
|
5176
6275
|
this.#boundPointerUp = this.#onPointerUp.bind(this);
|
|
5177
6276
|
this.#boundKeyDown = this.#onEscapeKeyDown.bind(this);
|
|
6277
|
+
this.#boundWindowBlur = this.#onFocusLost.bind(this);
|
|
6278
|
+
this.#boundVisibilityChange = this.#onVisibilityChange.bind(this);
|
|
5178
6279
|
}
|
|
5179
6280
|
/**
|
|
5180
6281
|
* The position where the user initially pressed down.
|
|
@@ -5209,8 +6310,13 @@ class PointerDragHandler {
|
|
|
5209
6310
|
}
|
|
5210
6311
|
// Check for elements that should not trigger drag
|
|
5211
6312
|
const target = event.target;
|
|
5212
|
-
if (target.closest(
|
|
5213
|
-
target.
|
|
6313
|
+
if (target.closest(INTERACTIVE_ELEMENT_SELECTOR) ||
|
|
6314
|
+
findNoDragElement(target, ctx.element) !== null) {
|
|
6315
|
+
return;
|
|
6316
|
+
}
|
|
6317
|
+
// Track the finger that pressed this draggable; the others are ignored for the gesture.
|
|
6318
|
+
const touch = 'touches' in event ? PointerDragHandler.#pressingTouch(event, ctx.element) : null;
|
|
6319
|
+
if ('touches' in event && !touch) {
|
|
5214
6320
|
return;
|
|
5215
6321
|
}
|
|
5216
6322
|
const delay = ctx.dragDelay;
|
|
@@ -5222,15 +6328,41 @@ class PointerDragHandler {
|
|
|
5222
6328
|
event.preventDefault();
|
|
5223
6329
|
}
|
|
5224
6330
|
event.stopPropagation();
|
|
6331
|
+
// A second finger on this draggable while the first is still down: keep following the first.
|
|
6332
|
+
if ('touches' in event && touch && this.#isTracking && this.#trackedTouch(event.touches)) {
|
|
6333
|
+
return;
|
|
6334
|
+
}
|
|
6335
|
+
// Only one drag at a time: a second finger or a press during a keyboard drag must not
|
|
6336
|
+
// replace the drag in progress. The press keeps the default handling above, so a mouse press
|
|
6337
|
+
// still doesn't focus this item (which would take the keys of a keyboard drag).
|
|
6338
|
+
if (this.#deps.callbacks.isOtherDragActive()) {
|
|
6339
|
+
return;
|
|
6340
|
+
}
|
|
5225
6341
|
this.#isTracking = true;
|
|
5226
|
-
this.#
|
|
5227
|
-
|
|
6342
|
+
this.#pressedId = ctx.draggableId;
|
|
6343
|
+
if (touch) {
|
|
6344
|
+
this.#touchId = touch.identifier;
|
|
6345
|
+
this.#startPosition = { x: touch.clientX, y: touch.clientY };
|
|
6346
|
+
}
|
|
6347
|
+
else {
|
|
6348
|
+
const mouse = event;
|
|
6349
|
+
this.#startPosition = { x: mouse.clientX, y: mouse.clientY };
|
|
6350
|
+
}
|
|
6351
|
+
// Handle drag delay. A repeated press (e.g. after a release the page never saw) restarts the
|
|
6352
|
+
// delay; the previous timer must not survive, or it fires after the gesture has ended.
|
|
6353
|
+
this.#cancelDelayTimer();
|
|
5228
6354
|
if (delay > 0) {
|
|
5229
|
-
this.#delayReady = false;
|
|
5230
6355
|
this.#delayTimerId = setTimeout(() => {
|
|
6356
|
+
this.#delayTimerId = null;
|
|
6357
|
+
// Another drag started while this press was held, or the press no longer applies (see
|
|
6358
|
+
// #isStalePress): drop it rather than show it as ready to drag.
|
|
6359
|
+
if (this.#deps.callbacks.isOtherDragActive() ||
|
|
6360
|
+
this.#isStalePress(this.#deps.getContext())) {
|
|
6361
|
+
this.cleanup();
|
|
6362
|
+
return;
|
|
6363
|
+
}
|
|
5231
6364
|
this.#delayReady = true;
|
|
5232
6365
|
this.#deps.callbacks.onPendingChange(true); // Emit ready state when delay passes
|
|
5233
|
-
this.#delayTimerId = null;
|
|
5234
6366
|
}, delay);
|
|
5235
6367
|
}
|
|
5236
6368
|
else {
|
|
@@ -5249,7 +6381,12 @@ class PointerDragHandler {
|
|
|
5249
6381
|
}
|
|
5250
6382
|
// Listen for Escape key on document to cancel drag
|
|
5251
6383
|
document.addEventListener('keydown', this.#boundKeyDown);
|
|
6384
|
+
// Switching windows or tabs mid-drag means the release never reaches this page: cancel
|
|
6385
|
+
// instead of leaving the drag active until the next press.
|
|
6386
|
+
window.addEventListener('blur', this.#boundWindowBlur);
|
|
6387
|
+
document.addEventListener('visibilitychange', this.#boundVisibilityChange);
|
|
5252
6388
|
});
|
|
6389
|
+
this.#listening = true;
|
|
5253
6390
|
}
|
|
5254
6391
|
/**
|
|
5255
6392
|
* Remove listeners + cancel timers.
|
|
@@ -5257,9 +6394,17 @@ class PointerDragHandler {
|
|
|
5257
6394
|
*/
|
|
5258
6395
|
cleanup() {
|
|
5259
6396
|
this.#isTracking = false;
|
|
6397
|
+
this.#pressedId = null;
|
|
5260
6398
|
this.#startPosition = null;
|
|
6399
|
+
this.#touchId = null;
|
|
5261
6400
|
this.#deps.callbacks.onPendingChange(false); // Clear pending state on cleanup
|
|
5262
6401
|
this.#cancelDelayTimer();
|
|
6402
|
+
// Nothing to remove unless a press added listeners (most draggables are never pressed; a
|
|
6403
|
+
// virtual list destroys one each time a row scrolls out). Server rendering adds none.
|
|
6404
|
+
if (!this.#listening) {
|
|
6405
|
+
return;
|
|
6406
|
+
}
|
|
6407
|
+
this.#listening = false;
|
|
5263
6408
|
// Remove event listeners
|
|
5264
6409
|
if (this.#boundPointerMove) {
|
|
5265
6410
|
document.removeEventListener('mousemove', this.#boundPointerMove);
|
|
@@ -5273,9 +6418,15 @@ class PointerDragHandler {
|
|
|
5273
6418
|
if (this.#boundKeyDown) {
|
|
5274
6419
|
document.removeEventListener('keydown', this.#boundKeyDown);
|
|
5275
6420
|
}
|
|
6421
|
+
if (this.#boundWindowBlur) {
|
|
6422
|
+
window.removeEventListener('blur', this.#boundWindowBlur);
|
|
6423
|
+
}
|
|
6424
|
+
if (this.#boundVisibilityChange) {
|
|
6425
|
+
document.removeEventListener('visibilitychange', this.#boundVisibilityChange);
|
|
6426
|
+
}
|
|
5276
6427
|
}
|
|
5277
6428
|
/**
|
|
5278
|
-
*
|
|
6429
|
+
* Teardown when the directive is destroyed: removes listeners and cancels timers.
|
|
5279
6430
|
*/
|
|
5280
6431
|
destroy() {
|
|
5281
6432
|
this.cleanup();
|
|
@@ -5287,10 +6438,31 @@ class PointerDragHandler {
|
|
|
5287
6438
|
if (!this.#isTracking) {
|
|
5288
6439
|
return;
|
|
5289
6440
|
}
|
|
5290
|
-
|
|
5291
|
-
|
|
6441
|
+
let position;
|
|
6442
|
+
if ('touches' in event) {
|
|
6443
|
+
// `touches` holds every finger's current position, so the tracked finger's is there even
|
|
6444
|
+
// when only another finger moved
|
|
6445
|
+
const touch = this.#trackedTouch(event.touches) ?? this.#trackedTouch(event.changedTouches);
|
|
6446
|
+
if (!touch) {
|
|
6447
|
+
// The tracked finger is gone. While dragging, keep other fingers from scrolling the page.
|
|
6448
|
+
if (this.#deps.callbacks.isDragging()) {
|
|
6449
|
+
event.preventDefault();
|
|
6450
|
+
}
|
|
6451
|
+
return;
|
|
6452
|
+
}
|
|
6453
|
+
position = { x: touch.clientX, y: touch.clientY };
|
|
6454
|
+
}
|
|
6455
|
+
else {
|
|
6456
|
+
position = { x: event.clientX, y: event.clientY };
|
|
6457
|
+
}
|
|
5292
6458
|
// Check if we've moved past the threshold
|
|
5293
6459
|
if (!this.#deps.callbacks.isDragging() && this.#startPosition) {
|
|
6460
|
+
// Read only before the drag starts, not on every move of a drag: nothing in it matters then
|
|
6461
|
+
const ctx = this.#deps.getContext();
|
|
6462
|
+
if (this.#isStalePress(ctx)) {
|
|
6463
|
+
this.cleanup();
|
|
6464
|
+
return;
|
|
6465
|
+
}
|
|
5294
6466
|
const distance = Math.sqrt(Math.pow(position.x - this.#startPosition.x, 2) +
|
|
5295
6467
|
Math.pow(position.y - this.#startPosition.y, 2));
|
|
5296
6468
|
if (distance < ctx.dragThreshold) {
|
|
@@ -5304,6 +6476,11 @@ class PointerDragHandler {
|
|
|
5304
6476
|
this.cleanup();
|
|
5305
6477
|
return;
|
|
5306
6478
|
}
|
|
6479
|
+
// Another press won the race and started its drag first: give up this one.
|
|
6480
|
+
if (this.#deps.callbacks.isOtherDragActive()) {
|
|
6481
|
+
this.cleanup();
|
|
6482
|
+
return;
|
|
6483
|
+
}
|
|
5307
6484
|
// Start the drag - now we can prevent default
|
|
5308
6485
|
event.preventDefault();
|
|
5309
6486
|
this.#deps.callbacks.onDragStart(position);
|
|
@@ -5327,13 +6504,35 @@ class PointerDragHandler {
|
|
|
5327
6504
|
if (!this.#isTracking) {
|
|
5328
6505
|
return;
|
|
5329
6506
|
}
|
|
6507
|
+
// Another finger lifted (or was cancelled) while the tracked one is still down. Touches
|
|
6508
|
+
// without an identifier (malformed synthetic events) can't be told apart: any release ends.
|
|
6509
|
+
if ('touches' in event &&
|
|
6510
|
+
typeof this.#touchId === 'number' &&
|
|
6511
|
+
this.#trackedTouch(event.changedTouches) === null &&
|
|
6512
|
+
this.#trackedTouch(event.touches) !== null) {
|
|
6513
|
+
return;
|
|
6514
|
+
}
|
|
5330
6515
|
// Only prevent default if we were actually dragging
|
|
5331
6516
|
// Otherwise, allow native touch behavior (like scroll momentum) to complete
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
this.#deps.callbacks.
|
|
6517
|
+
// The drag end runs consumer handlers (dragEnd, drop); stop tracking even if one throws
|
|
6518
|
+
try {
|
|
6519
|
+
if (this.#deps.callbacks.isDragging()) {
|
|
6520
|
+
event.preventDefault();
|
|
6521
|
+
// The system took the touch (an incoming call, an OS gesture, too many fingers): the
|
|
6522
|
+
// user never released, so this is a cancel, not a drop
|
|
6523
|
+
this.#deps.callbacks.onDragEnd(event.type === 'touchcancel');
|
|
6524
|
+
}
|
|
6525
|
+
}
|
|
6526
|
+
finally {
|
|
6527
|
+
this.cleanup();
|
|
5335
6528
|
}
|
|
5336
|
-
|
|
6529
|
+
}
|
|
6530
|
+
/**
|
|
6531
|
+
* Whether the tracked press no longer applies to the draggable: it renders another item now, or
|
|
6532
|
+
* it left the page (a virtual list recycled or pooled its row).
|
|
6533
|
+
*/
|
|
6534
|
+
#isStalePress(ctx) {
|
|
6535
|
+
return ctx.draggableId !== this.#pressedId || !ctx.element.isConnected;
|
|
5337
6536
|
}
|
|
5338
6537
|
/**
|
|
5339
6538
|
* Cancel the delay timer if active.
|
|
@@ -5350,24 +6549,87 @@ class PointerDragHandler {
|
|
|
5350
6549
|
*/
|
|
5351
6550
|
#onEscapeKeyDown(event) {
|
|
5352
6551
|
if (event.key === 'Escape' && this.#deps.callbacks.isDragging()) {
|
|
5353
|
-
|
|
5354
|
-
|
|
6552
|
+
try {
|
|
6553
|
+
this.#deps.callbacks.onDragEnd(true);
|
|
6554
|
+
}
|
|
6555
|
+
finally {
|
|
6556
|
+
this.cleanup();
|
|
6557
|
+
}
|
|
6558
|
+
}
|
|
6559
|
+
}
|
|
6560
|
+
/**
|
|
6561
|
+
* Cancel the drag (or drop the pending press) when the window loses focus.
|
|
6562
|
+
*/
|
|
6563
|
+
#onFocusLost() {
|
|
6564
|
+
if (!this.#isTracking) {
|
|
6565
|
+
return;
|
|
6566
|
+
}
|
|
6567
|
+
try {
|
|
6568
|
+
if (this.#deps.callbacks.isDragging()) {
|
|
6569
|
+
this.#deps.callbacks.onDragEnd(true);
|
|
6570
|
+
}
|
|
6571
|
+
}
|
|
6572
|
+
finally {
|
|
5355
6573
|
this.cleanup();
|
|
5356
6574
|
}
|
|
5357
6575
|
}
|
|
5358
6576
|
/**
|
|
5359
|
-
*
|
|
6577
|
+
* Treat the page being hidden (tab switch, app switch on mobile) as focus loss.
|
|
5360
6578
|
*/
|
|
5361
|
-
|
|
5362
|
-
if ('
|
|
5363
|
-
|
|
5364
|
-
|
|
6579
|
+
#onVisibilityChange() {
|
|
6580
|
+
if (document.visibilityState === 'hidden') {
|
|
6581
|
+
this.#onFocusLost();
|
|
6582
|
+
}
|
|
6583
|
+
}
|
|
6584
|
+
/**
|
|
6585
|
+
* The tracked finger in `touches`, or null. Synthetic touch events (e.g. from test tools) can
|
|
6586
|
+
* leave a list undefined, so a missing list counts as empty. Read by index, without copying the
|
|
6587
|
+
* list or creating an iterator: this runs for every touch move.
|
|
6588
|
+
*/
|
|
6589
|
+
#trackedTouch(touches) {
|
|
6590
|
+
if (!touches) {
|
|
6591
|
+
return null;
|
|
6592
|
+
}
|
|
6593
|
+
// eslint-disable-next-line @typescript-eslint/prefer-for-of -- for...of creates an iterator
|
|
6594
|
+
for (let i = 0; i < touches.length; i++) {
|
|
6595
|
+
if (touches[i].identifier === this.#touchId) {
|
|
6596
|
+
return touches[i];
|
|
6597
|
+
}
|
|
5365
6598
|
}
|
|
5366
|
-
return
|
|
6599
|
+
return null;
|
|
6600
|
+
}
|
|
6601
|
+
/**
|
|
6602
|
+
* The finger that pressed this draggable: of the touches that started with this event, the
|
|
6603
|
+
* one on the draggable (several fingers can land in one touchstart).
|
|
6604
|
+
*/
|
|
6605
|
+
static #pressingTouch(event, element) {
|
|
6606
|
+
// Either list can be undefined on synthetic events
|
|
6607
|
+
const started = Array.from(event.changedTouches ?? []);
|
|
6608
|
+
const onElement = started.find((touch) => touch.target instanceof Node && element.contains(touch.target));
|
|
6609
|
+
return onElement ?? started[0] ?? Array.from(event.touches ?? [])[0] ?? null;
|
|
5367
6610
|
}
|
|
5368
6611
|
}
|
|
5369
|
-
_a = PointerDragHandler;
|
|
5370
6612
|
|
|
6613
|
+
/** Key names as Angular's `keydown.<key>` bindings spell them, for the `event.key` values that differ */
|
|
6614
|
+
const KEY_NAMES = {
|
|
6615
|
+
' ': 'space',
|
|
6616
|
+
Esc: 'escape',
|
|
6617
|
+
'\x1B': 'escape',
|
|
6618
|
+
Up: 'arrowup',
|
|
6619
|
+
Down: 'arrowdown',
|
|
6620
|
+
Left: 'arrowleft',
|
|
6621
|
+
Right: 'arrowright',
|
|
6622
|
+
};
|
|
6623
|
+
/** The keys an item handles */
|
|
6624
|
+
const HANDLED_KEYS = new Set([
|
|
6625
|
+
'space',
|
|
6626
|
+
'enter',
|
|
6627
|
+
'arrowup',
|
|
6628
|
+
'arrowdown',
|
|
6629
|
+
'arrowleft',
|
|
6630
|
+
'arrowright',
|
|
6631
|
+
'escape',
|
|
6632
|
+
]);
|
|
5371
6633
|
/**
|
|
5372
6634
|
* Makes an element draggable within the virtual scroll drag-and-drop system.
|
|
5373
6635
|
*
|
|
@@ -5444,9 +6706,16 @@ class DraggableDirective {
|
|
|
5444
6706
|
dragStart = output();
|
|
5445
6707
|
/** Emits when drag ends */
|
|
5446
6708
|
dragEnd = output();
|
|
5447
|
-
/**
|
|
5448
|
-
#
|
|
5449
|
-
|
|
6709
|
+
/** The item whose press is ready to drag (its delay has passed) */
|
|
6710
|
+
#pendingId = signal(null, ...(ngDevMode ? [{ debugName: "#pendingId" }] : []));
|
|
6711
|
+
/**
|
|
6712
|
+
* Whether this element is in the "ready to drag" state (delay has passed). Tied to the pressed
|
|
6713
|
+
* item: a recycled row that renders another item is not ready to drag.
|
|
6714
|
+
*/
|
|
6715
|
+
isPending = computed(() => {
|
|
6716
|
+
const pendingId = this.#pendingId();
|
|
6717
|
+
return pendingId !== null && pendingId === this.vdndDraggable();
|
|
6718
|
+
}, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
|
|
5450
6719
|
/** Whether this element is currently being dragged (based on global drag state) */
|
|
5451
6720
|
isDragging = computed(() => {
|
|
5452
6721
|
const draggedItem = this.#dragState.draggedItem();
|
|
@@ -5454,6 +6723,64 @@ class DraggableDirective {
|
|
|
5454
6723
|
}, ...(ngDevMode ? [{ debugName: "isDragging" }] : []));
|
|
5455
6724
|
#keyboardHandler;
|
|
5456
6725
|
#pointerHandler;
|
|
6726
|
+
/** Set by ngOnInit, which creates the handlers */
|
|
6727
|
+
#initialized = false;
|
|
6728
|
+
/**
|
|
6729
|
+
* Keys pressed on the item: one listener, added outside Angular's zone, instead of a
|
|
6730
|
+
* `(keydown.<key>)` host binding per key, as Angular adds a listener to every row for each.
|
|
6731
|
+
* Like those bindings, it ignores a key pressed with Shift, Ctrl, Alt or Meta, runs the handler
|
|
6732
|
+
* of a key it handles in the zone (so zone.js apps still render after it), prevents the key's
|
|
6733
|
+
* default action when the handler returns false, and reports what the handler throws to the
|
|
6734
|
+
* ErrorHandler. Added in the constructor, so it runs before the element's template listeners,
|
|
6735
|
+
* as host listeners do.
|
|
6736
|
+
*/
|
|
6737
|
+
#onKeydown = (event) => {
|
|
6738
|
+
if (!this.#initialized || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
|
|
6739
|
+
return;
|
|
6740
|
+
}
|
|
6741
|
+
const key = event.key ? (KEY_NAMES[event.key] ?? event.key.toLowerCase()) : '';
|
|
6742
|
+
if (!HANDLED_KEYS.has(key)) {
|
|
6743
|
+
return;
|
|
6744
|
+
}
|
|
6745
|
+
this.#ngZone.run(() => {
|
|
6746
|
+
try {
|
|
6747
|
+
if (this.#handleKey(key, event) === false) {
|
|
6748
|
+
event.preventDefault();
|
|
6749
|
+
}
|
|
6750
|
+
}
|
|
6751
|
+
catch (error) {
|
|
6752
|
+
this.#envInjector.get(ErrorHandler).handleError(error);
|
|
6753
|
+
}
|
|
6754
|
+
});
|
|
6755
|
+
};
|
|
6756
|
+
/**
|
|
6757
|
+
* Touch presses on the item. Added programmatically, not as a host binding (which Angular adds
|
|
6758
|
+
* without options, so non-passive): a swipe that starts on an element with a non-passive
|
|
6759
|
+
* touchstart listener can't scroll until the main thread has run it. With a drag delay the press
|
|
6760
|
+
* never cancels the scroll (the page must scroll when the user swipes before the delay passes),
|
|
6761
|
+
* so the listener is passive; without one it prevents the default action and is non-passive.
|
|
6762
|
+
* Like the keydown listener, it runs the handler in the zone (as the mousedown host binding
|
|
6763
|
+
* does) and reports what it throws to the ErrorHandler. Added once the inputs are set, so unlike
|
|
6764
|
+
* a host listener it runs after the element's template listeners.
|
|
6765
|
+
*/
|
|
6766
|
+
#onTouchStart = (event) => {
|
|
6767
|
+
if (!this.#initialized) {
|
|
6768
|
+
return;
|
|
6769
|
+
}
|
|
6770
|
+
this.#ngZone.run(() => {
|
|
6771
|
+
try {
|
|
6772
|
+
this.onPointerDown(event, true);
|
|
6773
|
+
}
|
|
6774
|
+
catch (error) {
|
|
6775
|
+
this.#envInjector.get(ErrorHandler).handleError(error);
|
|
6776
|
+
}
|
|
6777
|
+
});
|
|
6778
|
+
};
|
|
6779
|
+
/** Whether the touchstart listener is passive, or null while it isn't added */
|
|
6780
|
+
#touchStartPassive = null;
|
|
6781
|
+
constructor() {
|
|
6782
|
+
this.#ngZone.runOutsideAngular(() => this.#elementRef.nativeElement.addEventListener('keydown', this.#onKeydown));
|
|
6783
|
+
}
|
|
5457
6784
|
/** Cached constraint flag from source droppable */
|
|
5458
6785
|
#constrainToContainer = false;
|
|
5459
6786
|
/** Element whose rect is used for clamping (scroll container or droppable itself) */
|
|
@@ -5464,9 +6791,7 @@ class DraggableDirective {
|
|
|
5464
6791
|
* Update the pending state and emit the change event.
|
|
5465
6792
|
*/
|
|
5466
6793
|
#setPending(pending) {
|
|
5467
|
-
|
|
5468
|
-
this.#isPending.set(pending);
|
|
5469
|
-
}
|
|
6794
|
+
this.#pendingId.set(pending ? this.vdndDraggable() : null);
|
|
5470
6795
|
}
|
|
5471
6796
|
/**
|
|
5472
6797
|
* Find the element to use for container constraint clamping.
|
|
@@ -5491,7 +6816,8 @@ class DraggableDirective {
|
|
|
5491
6816
|
ngZone: this.#ngZone,
|
|
5492
6817
|
envInjector: this.#envInjector,
|
|
5493
6818
|
callbacks: {
|
|
5494
|
-
onDragStart: (event) => this.dragStart.emit(event),
|
|
6819
|
+
onDragStart: (event) => this.#ngZone.run(() => this.dragStart.emit(event)),
|
|
6820
|
+
// The keyboard handler ends the drag inside the zone (dragEnd, drop, focus restore)
|
|
5495
6821
|
onDragEnd: (event) => this.dragEnd.emit(event),
|
|
5496
6822
|
getParentDroppableId: () => this.#getParentDroppableId(),
|
|
5497
6823
|
calculateSourceIndex: (el, droppable) => this.#calculateSourceIndex(el, droppable),
|
|
@@ -5516,9 +6842,11 @@ class DraggableDirective {
|
|
|
5516
6842
|
onDragEnd: (cancelled) => this.#endDrag(cancelled),
|
|
5517
6843
|
onPendingChange: (pending) => this.#setPending(pending),
|
|
5518
6844
|
isDragging: () => this.isDragging(),
|
|
6845
|
+
isOtherDragActive: () => this.#dragState.isDragging() && !this.isDragging(),
|
|
5519
6846
|
},
|
|
5520
6847
|
getContext: () => ({
|
|
5521
6848
|
element: this.#elementRef.nativeElement,
|
|
6849
|
+
draggableId: this.vdndDraggable(),
|
|
5522
6850
|
groupName: this.#effectiveGroup(),
|
|
5523
6851
|
disabled: this.disabled(),
|
|
5524
6852
|
dragHandle: this.dragHandle(),
|
|
@@ -5526,8 +6854,20 @@ class DraggableDirective {
|
|
|
5526
6854
|
dragDelay: this.dragDelay(),
|
|
5527
6855
|
}),
|
|
5528
6856
|
});
|
|
6857
|
+
this.#initialized = true;
|
|
6858
|
+
this.#listenForTouchStart();
|
|
6859
|
+
}
|
|
6860
|
+
ngOnChanges() {
|
|
6861
|
+
this.#listenForTouchStart();
|
|
5529
6862
|
}
|
|
5530
6863
|
ngOnDestroy() {
|
|
6864
|
+
this.#elementRef.nativeElement.removeEventListener('keydown', this.#onKeydown);
|
|
6865
|
+
this.#elementRef.nativeElement.removeEventListener('touchstart', this.#onTouchStart);
|
|
6866
|
+
// Destroyed before its first change detection: there are no handlers yet, and its inputs
|
|
6867
|
+
// have no values (reading a bound ID would throw).
|
|
6868
|
+
if (!this.#initialized) {
|
|
6869
|
+
return;
|
|
6870
|
+
}
|
|
5531
6871
|
// If destroyed mid-drag, cancel to avoid stale global state / ongoing RAF loops.
|
|
5532
6872
|
if (this.isDragging()) {
|
|
5533
6873
|
this.#endDrag(true);
|
|
@@ -5535,6 +6875,35 @@ class DraggableDirective {
|
|
|
5535
6875
|
this.#pointerHandler.destroy();
|
|
5536
6876
|
this.#keyboardHandler.destroy();
|
|
5537
6877
|
}
|
|
6878
|
+
/**
|
|
6879
|
+
* Add the touchstart listener, or re-add it when the drag delay changes whether it can be
|
|
6880
|
+
* passive. Lifecycle hooks, not an effect: creating a view effect schedules a change detection
|
|
6881
|
+
* that visits every ancestor, and a virtual list creates rows on each scroll step.
|
|
6882
|
+
*/
|
|
6883
|
+
#listenForTouchStart() {
|
|
6884
|
+
// The handler cancels the scroll of a touch press only when there is no delay
|
|
6885
|
+
const passive = this.dragDelay() !== 0;
|
|
6886
|
+
if (passive === this.#touchStartPassive) {
|
|
6887
|
+
return;
|
|
6888
|
+
}
|
|
6889
|
+
const element = this.#elementRef.nativeElement;
|
|
6890
|
+
element.removeEventListener('touchstart', this.#onTouchStart);
|
|
6891
|
+
this.#ngZone.runOutsideAngular(() => element.addEventListener('touchstart', this.#onTouchStart, { passive }));
|
|
6892
|
+
this.#touchStartPassive = passive;
|
|
6893
|
+
}
|
|
6894
|
+
/** Run the handler of a key the item handles (see HANDLED_KEYS), returning what it returns. */
|
|
6895
|
+
#handleKey(key, event) {
|
|
6896
|
+
switch (key) {
|
|
6897
|
+
case 'space':
|
|
6898
|
+
return this.onKeyboardActivate(event);
|
|
6899
|
+
case 'enter':
|
|
6900
|
+
return this.onEnterKey(event);
|
|
6901
|
+
case 'escape':
|
|
6902
|
+
return this.onEscape();
|
|
6903
|
+
default:
|
|
6904
|
+
return this.onArrowKey(event);
|
|
6905
|
+
}
|
|
6906
|
+
}
|
|
5538
6907
|
/**
|
|
5539
6908
|
* Handle pointer down (mouse or touch).
|
|
5540
6909
|
*/
|
|
@@ -5549,20 +6918,49 @@ class DraggableDirective {
|
|
|
5549
6918
|
if (this.disabled()) {
|
|
5550
6919
|
return;
|
|
5551
6920
|
}
|
|
6921
|
+
// Space on a control inside the item types into it or clicks it, just as a press on
|
|
6922
|
+
// one never starts a pointer drag.
|
|
6923
|
+
if (this.#isFromNestedControl(event)) {
|
|
6924
|
+
return;
|
|
6925
|
+
}
|
|
5552
6926
|
event.preventDefault();
|
|
5553
|
-
event.stopPropagation(); // Prevent document listener from receiving this event
|
|
5554
6927
|
// If we're in a keyboard drag, Space drops the item
|
|
5555
6928
|
if (this.#keyboardHandler.isActive()) {
|
|
6929
|
+
event.stopPropagation(); // Prevent document listener from receiving this event
|
|
5556
6930
|
this.#keyboardHandler.complete();
|
|
5557
6931
|
return;
|
|
5558
6932
|
}
|
|
5559
|
-
//
|
|
5560
|
-
|
|
6933
|
+
// Another drag is in progress: don't replace it. The key goes on to the document, where a
|
|
6934
|
+
// keyboard drag of another item drops with it.
|
|
6935
|
+
if (this.#dragState.isDragging()) {
|
|
5561
6936
|
return;
|
|
5562
6937
|
}
|
|
5563
|
-
// Start keyboard drag
|
|
6938
|
+
// Start keyboard drag. The document listener it adds must not receive this event.
|
|
6939
|
+
event.stopPropagation();
|
|
5564
6940
|
this.#keyboardHandler.activate();
|
|
5565
6941
|
}
|
|
6942
|
+
/**
|
|
6943
|
+
* Whether the event comes from a control (or from inside a `no-drag` element) nested inside this
|
|
6944
|
+
* draggable. Neither the draggable itself nor a control around it counts, so a
|
|
6945
|
+
* `<button vdndDraggable>` still picks up with Space, and neither does a control that is the
|
|
6946
|
+
* drag handle, such as a button handle. Controls inside the handle do count.
|
|
6947
|
+
*/
|
|
6948
|
+
#isFromNestedControl(event) {
|
|
6949
|
+
const host = this.#elementRef.nativeElement;
|
|
6950
|
+
const target = event.target;
|
|
6951
|
+
if (!(target instanceof Element) || target === host) {
|
|
6952
|
+
return false;
|
|
6953
|
+
}
|
|
6954
|
+
if (findNoDragElement(target, host) !== null) {
|
|
6955
|
+
return true;
|
|
6956
|
+
}
|
|
6957
|
+
const control = target.closest(INTERACTIVE_ELEMENT_SELECTOR);
|
|
6958
|
+
if (control === null || control === host || !host.contains(control)) {
|
|
6959
|
+
return false;
|
|
6960
|
+
}
|
|
6961
|
+
const handleSelector = this.dragHandle();
|
|
6962
|
+
return !handleSelector || !control.matches(handleSelector);
|
|
6963
|
+
}
|
|
5566
6964
|
/**
|
|
5567
6965
|
* Handle Enter key (alternative to Space for dropping during keyboard drag).
|
|
5568
6966
|
*/
|
|
@@ -5683,13 +7081,16 @@ class DraggableDirective {
|
|
|
5683
7081
|
// Start auto-scroll monitoring — registers as a scheduler participant.
|
|
5684
7082
|
this.#autoScroll.startMonitoring(() => this.#recalculatePlaceholder());
|
|
5685
7083
|
// Emit drag start event
|
|
5686
|
-
|
|
7084
|
+
// Pointer listeners run outside Angular's zone. With zone.js a template listener marks
|
|
7085
|
+
// its view dirty but schedules no render, so emit inside the zone (a no-op when zoneless).
|
|
7086
|
+
const event = {
|
|
5687
7087
|
draggableId: this.vdndDraggable(),
|
|
5688
7088
|
droppableId: parentDroppableId ?? '',
|
|
5689
7089
|
data: this.vdndDraggableData(),
|
|
5690
7090
|
position,
|
|
5691
7091
|
sourceIndex,
|
|
5692
|
-
}
|
|
7092
|
+
};
|
|
7093
|
+
this.#ngZone.run(() => this.dragStart.emit(event));
|
|
5693
7094
|
}
|
|
5694
7095
|
/**
|
|
5695
7096
|
* Calculate the source index of the dragged element BEFORE it's hidden.
|
|
@@ -5701,49 +7102,21 @@ class DraggableDirective {
|
|
|
5701
7102
|
return 0;
|
|
5702
7103
|
}
|
|
5703
7104
|
const rect = element.getBoundingClientRect();
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
let containerRect;
|
|
5709
|
-
let scrollTop;
|
|
5710
|
-
if (virtualScroll) {
|
|
5711
|
-
containerRect = virtualScroll.getBoundingClientRect();
|
|
5712
|
-
scrollTop = virtualScroll.scrollTop;
|
|
5713
|
-
}
|
|
5714
|
-
else if (virtualContent) {
|
|
5715
|
-
// Page-level scroll: use scrollable parent rect + content offset so that
|
|
5716
|
-
// relativeY is measured from the start of the virtual items, not the header.
|
|
5717
|
-
const scrollableParent = virtualContent.closest('.vdnd-scrollable');
|
|
5718
|
-
if (scrollableParent) {
|
|
5719
|
-
containerRect = scrollableParent.getBoundingClientRect();
|
|
5720
|
-
const contentOffsetAttr = virtualContent.getAttribute('data-content-offset');
|
|
5721
|
-
const contentOffset = contentOffsetAttr ? parseFloat(contentOffsetAttr) : 0;
|
|
5722
|
-
scrollTop =
|
|
5723
|
-
scrollableParent.scrollTop - (Number.isFinite(contentOffset) ? contentOffset : 0);
|
|
5724
|
-
}
|
|
5725
|
-
else {
|
|
5726
|
-
containerRect = virtualContent.getBoundingClientRect();
|
|
5727
|
-
scrollTop = 0;
|
|
5728
|
-
}
|
|
5729
|
-
}
|
|
5730
|
-
else {
|
|
5731
|
-
containerRect = droppableElement.getBoundingClientRect();
|
|
5732
|
-
scrollTop = droppableElement.scrollTop;
|
|
5733
|
-
}
|
|
7105
|
+
// The same measurement that places the placeholder: it knows each container's scroll
|
|
7106
|
+
// element and subtracts space reserved above the rows (contentOffset, page headers).
|
|
7107
|
+
const geometry = this.#dragIndexCalculator.getScrollGeometry(droppableElement, rect.height);
|
|
7108
|
+
const relativeY = rect.top - geometry.rect.top + geometry.scrollTop;
|
|
5734
7109
|
// Try to use registered strategy for accurate offset-based lookup
|
|
5735
7110
|
const droppableId = this.#positionCalculator.getDroppableId(droppableElement);
|
|
5736
7111
|
if (droppableId) {
|
|
5737
7112
|
const strategy = this.#dragIndexCalculator.getStrategyForDroppable(droppableId);
|
|
5738
7113
|
if (strategy) {
|
|
5739
|
-
const relativeY = rect.top - containerRect.top + scrollTop;
|
|
5740
7114
|
return strategy.findIndexAtOffset(relativeY);
|
|
5741
7115
|
}
|
|
5742
7116
|
}
|
|
5743
7117
|
// Preserve the geometry fallback for a virtual container whose strategy is unavailable.
|
|
5744
|
-
if (
|
|
7118
|
+
if (geometry.isVirtual) {
|
|
5745
7119
|
const itemHeight = rect.height || 50;
|
|
5746
|
-
const relativeY = rect.top - containerRect.top + scrollTop;
|
|
5747
7120
|
return Math.round(relativeY / itemHeight);
|
|
5748
7121
|
}
|
|
5749
7122
|
// Non-virtual fallback: derive the logical index from preceding draggable siblings.
|
|
@@ -5933,22 +7306,27 @@ class DraggableDirective {
|
|
|
5933
7306
|
sourceDroppableId: this.#dragState.sourceDroppableId(),
|
|
5934
7307
|
activeDroppableId: this.#dragState.activeDroppableId(),
|
|
5935
7308
|
});
|
|
5936
|
-
|
|
5937
|
-
this.dragEnd.emit({
|
|
7309
|
+
const event = {
|
|
5938
7310
|
draggableId: this.vdndDraggable(),
|
|
5939
7311
|
droppableId: this.#getParentDroppableId() ?? '',
|
|
5940
7312
|
cancelled,
|
|
5941
7313
|
data: this.vdndDraggableData(),
|
|
5942
7314
|
sourceIndex,
|
|
5943
7315
|
destinationIndex,
|
|
7316
|
+
};
|
|
7317
|
+
// dragEnd, then the state reset that delivers drop, in ONE zone entry: listeners run
|
|
7318
|
+
// outside Angular's zone, and with zone.js each entry renders when it is left, so
|
|
7319
|
+
// separate entries would render the drag end twice. A no-op when zoneless.
|
|
7320
|
+
this.#ngZone.run(() => {
|
|
7321
|
+
this.dragEnd.emit(event);
|
|
7322
|
+
// Clear drag state - this triggers isDragging computed to become false
|
|
7323
|
+
if (cancelled) {
|
|
7324
|
+
this.#dragState.cancelDrag();
|
|
7325
|
+
}
|
|
7326
|
+
else {
|
|
7327
|
+
this.#dragState.endDrag();
|
|
7328
|
+
}
|
|
5944
7329
|
});
|
|
5945
|
-
// Clear drag state - this triggers isDragging computed to become false
|
|
5946
|
-
if (cancelled) {
|
|
5947
|
-
this.#dragState.cancelDrag();
|
|
5948
|
-
}
|
|
5949
|
-
else {
|
|
5950
|
-
this.#dragState.endDrag();
|
|
5951
|
-
}
|
|
5952
7330
|
}
|
|
5953
7331
|
/**
|
|
5954
7332
|
* Get the parent droppable ID.
|
|
@@ -5962,7 +7340,7 @@ class DraggableDirective {
|
|
|
5962
7340
|
return droppable ? this.#positionCalculator.getDroppableId(droppable) : null;
|
|
5963
7341
|
}
|
|
5964
7342
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DraggableDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
5965
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: DraggableDirective, isStandalone: true, selector: "[vdndDraggable]", inputs: { vdndDraggable: { classPropertyName: "vdndDraggable", publicName: "vdndDraggable", isSignal: true, isRequired: true, transformFunction: null }, vdndDraggableGroup: { classPropertyName: "vdndDraggableGroup", publicName: "vdndDraggableGroup", isSignal: true, isRequired: false, transformFunction: null }, vdndDraggableData: { classPropertyName: "vdndDraggableData", publicName: "vdndDraggableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, dragHandle: { classPropertyName: "dragHandle", publicName: "dragHandle", isSignal: true, isRequired: false, transformFunction: null }, dragThreshold: { classPropertyName: "dragThreshold", publicName: "dragThreshold", isSignal: true, isRequired: false, transformFunction: null }, dragDelay: { classPropertyName: "dragDelay", publicName: "dragDelay", isSignal: true, isRequired: false, transformFunction: null }, lockAxis: { classPropertyName: "lockAxis", publicName: "lockAxis", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dragStart: "dragStart", dragEnd: "dragEnd" }, host: { listeners: { "mousedown": "onPointerDown($event, false)"
|
|
7343
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: DraggableDirective, isStandalone: true, selector: "[vdndDraggable]", inputs: { vdndDraggable: { classPropertyName: "vdndDraggable", publicName: "vdndDraggable", isSignal: true, isRequired: true, transformFunction: null }, vdndDraggableGroup: { classPropertyName: "vdndDraggableGroup", publicName: "vdndDraggableGroup", isSignal: true, isRequired: false, transformFunction: null }, vdndDraggableData: { classPropertyName: "vdndDraggableData", publicName: "vdndDraggableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, dragHandle: { classPropertyName: "dragHandle", publicName: "dragHandle", isSignal: true, isRequired: false, transformFunction: null }, dragThreshold: { classPropertyName: "dragThreshold", publicName: "dragThreshold", isSignal: true, isRequired: false, transformFunction: null }, dragDelay: { classPropertyName: "dragDelay", publicName: "dragDelay", isSignal: true, isRequired: false, transformFunction: null }, lockAxis: { classPropertyName: "lockAxis", publicName: "lockAxis", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dragStart: "dragStart", dragEnd: "dragEnd" }, host: { listeners: { "mousedown": "onPointerDown($event, false)" }, properties: { "attr.data-draggable-id": "vdndDraggable()", "class.vdnd-draggable": "true", "class.vdnd-draggable-dragging": "isDragging()", "class.vdnd-draggable-disabled": "disabled()", "class.vdnd-drag-pending": "isPending()", "style.display": "isDragging() ? \"none\" : null", "attr.aria-grabbed": "isDragging() ? \"true\" : \"false\"", "tabindex": "disabled() ? -1 : 0" } }, usesOnChanges: true, ngImport: i0 });
|
|
5966
7344
|
}
|
|
5967
7345
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DraggableDirective, decorators: [{
|
|
5968
7346
|
type: Directive,
|
|
@@ -5978,17 +7356,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
5978
7356
|
'[attr.aria-grabbed]': 'isDragging() ? "true" : "false"',
|
|
5979
7357
|
'[tabindex]': 'disabled() ? -1 : 0',
|
|
5980
7358
|
'(mousedown)': 'onPointerDown($event, false)',
|
|
5981
|
-
'(touchstart)': 'onPointerDown($event, true)',
|
|
5982
|
-
'(keydown.space)': 'onKeyboardActivate($event)',
|
|
5983
|
-
'(keydown.enter)': 'onEnterKey($event)',
|
|
5984
|
-
'(keydown.arrowup)': 'onArrowKey($event)',
|
|
5985
|
-
'(keydown.arrowdown)': 'onArrowKey($event)',
|
|
5986
|
-
'(keydown.arrowleft)': 'onArrowKey($event)',
|
|
5987
|
-
'(keydown.arrowright)': 'onArrowKey($event)',
|
|
5988
|
-
'(keydown.escape)': 'onEscape()',
|
|
5989
7359
|
},
|
|
5990
7360
|
}]
|
|
5991
|
-
}], propDecorators: { vdndDraggable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggable", required: true }] }], vdndDraggableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableGroup", required: false }] }], vdndDraggableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }], dragThreshold: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragThreshold", required: false }] }], dragDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragDelay", required: false }] }], lockAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "lockAxis", required: false }] }], dragStart: [{ type: i0.Output, args: ["dragStart"] }], dragEnd: [{ type: i0.Output, args: ["dragEnd"] }] } });
|
|
7361
|
+
}], ctorParameters: () => [], propDecorators: { vdndDraggable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggable", required: true }] }], vdndDraggableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableGroup", required: false }] }], vdndDraggableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }], dragThreshold: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragThreshold", required: false }] }], dragDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragDelay", required: false }] }], lockAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "lockAxis", required: false }] }], dragStart: [{ type: i0.Output, args: ["dragStart"] }], dragEnd: [{ type: i0.Output, args: ["dragEnd"] }] } });
|
|
5992
7362
|
|
|
5993
7363
|
/**
|
|
5994
7364
|
* Directive that marks an element as a scrollable container for virtual scrolling.
|
|
@@ -6160,6 +7530,7 @@ class VirtualForDirective {
|
|
|
6160
7530
|
#scrollContainer = inject(VDND_SCROLL_CONTAINER);
|
|
6161
7531
|
#injector = inject(Injector);
|
|
6162
7532
|
#ngZone = inject(NgZone);
|
|
7533
|
+
#isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
6163
7534
|
#dragState = inject(DragStateService);
|
|
6164
7535
|
#dragIndexCalculator = inject(DragIndexCalculatorService);
|
|
6165
7536
|
/**
|
|
@@ -6168,6 +7539,8 @@ class VirtualForDirective {
|
|
|
6168
7539
|
* so we skip individual absolute positioning.
|
|
6169
7540
|
*/
|
|
6170
7541
|
#viewport = inject(VDND_VIRTUAL_VIEWPORT, { optional: true });
|
|
7542
|
+
/** A vdnd-virtual-viewport, whose rows start contentOffset px down (see #rowsScrollTop) */
|
|
7543
|
+
#offsetRowsViewport = inject(VDND_OFFSET_ROWS_VIEWPORT, { optional: true });
|
|
6171
7544
|
/**
|
|
6172
7545
|
* Optional parent droppable directive.
|
|
6173
7546
|
* When present, droppableId is inherited automatically.
|
|
@@ -6189,7 +7562,7 @@ class VirtualForDirective {
|
|
|
6189
7562
|
#placeholderInDom = false;
|
|
6190
7563
|
/** ResizeObserver for dynamic height measurement */
|
|
6191
7564
|
#resizeObserver = null;
|
|
6192
|
-
/** Map from observed HTMLElement to its trackBy key */
|
|
7565
|
+
/** Map from observed HTMLElement to its trackBy key (reset with each new observer) */
|
|
6193
7566
|
#observedElements = new WeakMap();
|
|
6194
7567
|
// ========== Inputs ==========
|
|
6195
7568
|
/** The array of items to iterate over */
|
|
@@ -6266,8 +7639,21 @@ class VirtualForDirective {
|
|
|
6266
7639
|
const strategy = this.#strategy();
|
|
6267
7640
|
// Read version to subscribe to dynamic height changes
|
|
6268
7641
|
strategy.version();
|
|
6269
|
-
return strategy.getFirstVisibleIndex(this.#
|
|
7642
|
+
return strategy.getFirstVisibleIndex(this.#rowsScrollTop());
|
|
6270
7643
|
}, ...(ngDevMode ? [{ debugName: "#firstVisibleIndex" }] : []));
|
|
7644
|
+
/**
|
|
7645
|
+
* How far the rows are scrolled. A vdnd-virtual-viewport's rows start contentOffset px down its
|
|
7646
|
+
* scroll area, but its scrollTop() is the raw position (scrollBy() relies on it), so subtract the
|
|
7647
|
+
* offset here. vdnd-virtual-content's scrollTop() already excludes its offset.
|
|
7648
|
+
*/
|
|
7649
|
+
#rowsScrollTop() {
|
|
7650
|
+
const scrollTop = this.#scrollContainer.scrollTop();
|
|
7651
|
+
const viewport = this.#viewport;
|
|
7652
|
+
if (viewport === null || viewport !== this.#offsetRowsViewport) {
|
|
7653
|
+
return scrollTop;
|
|
7654
|
+
}
|
|
7655
|
+
return Math.max(0, scrollTop - viewport.contentOffset());
|
|
7656
|
+
}
|
|
6271
7657
|
/** Number of visible items */
|
|
6272
7658
|
#visibleCount = computed(() => {
|
|
6273
7659
|
const height = this.#scrollContainer.containerHeight();
|
|
@@ -6286,38 +7672,47 @@ class VirtualForDirective {
|
|
|
6286
7672
|
const end = Math.min(total - 1, first + visible + overscan);
|
|
6287
7673
|
return { start, end };
|
|
6288
7674
|
}, ...(ngDevMode ? [{ debugName: "#renderRange" }] : []));
|
|
6289
|
-
/**
|
|
6290
|
-
|
|
6291
|
-
|
|
7675
|
+
/**
|
|
7676
|
+
* Every item's track key, in order. Computed once per items change, and only when something
|
|
7677
|
+
* reads it: a dynamic-height strategy or a drag. A fixed-height strategy needs only the count.
|
|
7678
|
+
*/
|
|
7679
|
+
#itemKeys = computed(() => {
|
|
6292
7680
|
const trackByFn = this.vdndVirtualForTrackBy();
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
/** Index of the dragged item in this list (-1 if not present or not dragging) */
|
|
7681
|
+
return this.vdndVirtualForOf().map((item, i) => trackByFn(i, item));
|
|
7682
|
+
}, ...(ngDevMode ? [{ debugName: "#itemKeys" }] : []));
|
|
7683
|
+
/**
|
|
7684
|
+
* Index of the dragged item in this list (-1 if not present or not dragging). The index where
|
|
7685
|
+
* the drag found its item is checked first: it spares a search of every track key.
|
|
7686
|
+
*/
|
|
6300
7687
|
#draggedItemIndex = computed(() => {
|
|
6301
7688
|
const draggedItem = this.#dragState.draggedItem();
|
|
6302
7689
|
if (!draggedItem)
|
|
6303
7690
|
return -1;
|
|
6304
|
-
const
|
|
6305
|
-
|
|
7691
|
+
const items = this.vdndVirtualForOf();
|
|
7692
|
+
const sourceIndex = this.#dragState.sourceIndex();
|
|
7693
|
+
if (sourceIndex !== null && sourceIndex >= 0 && sourceIndex < items.length) {
|
|
7694
|
+
const item = items[sourceIndex];
|
|
7695
|
+
if (this.vdndVirtualForTrackBy()(sourceIndex, item) === draggedItem.draggableId ||
|
|
7696
|
+
(draggedItem.data !== null && draggedItem.data !== undefined && item === draggedItem.data)) {
|
|
7697
|
+
return sourceIndex;
|
|
7698
|
+
}
|
|
7699
|
+
}
|
|
7700
|
+
// The last item with the key, as an index map filled in order would give
|
|
7701
|
+
const byId = this.#itemKeys().lastIndexOf(draggedItem.draggableId);
|
|
7702
|
+
if (byId >= 0) {
|
|
6306
7703
|
return byId;
|
|
6307
7704
|
}
|
|
6308
7705
|
const data = draggedItem.data;
|
|
6309
7706
|
if (data !== null && data !== undefined) {
|
|
6310
|
-
return
|
|
7707
|
+
return items.indexOf(data);
|
|
6311
7708
|
}
|
|
6312
7709
|
return -1;
|
|
6313
7710
|
}, ...(ngDevMode ? [{ debugName: "#draggedItemIndex" }] : []));
|
|
6314
7711
|
constructor() {
|
|
6315
|
-
// Keep strategy
|
|
7712
|
+
// Keep the strategy's items in sync. A fixed-height strategy needs only their count, which
|
|
7713
|
+
// spares computing every item's track key on each items change (each drop).
|
|
6316
7714
|
effect(() => {
|
|
6317
|
-
|
|
6318
|
-
const trackByFn = this.vdndVirtualForTrackBy();
|
|
6319
|
-
const keys = items.map((item, i) => trackByFn(i, item));
|
|
6320
|
-
this.#strategy().setItemKeys(keys);
|
|
7715
|
+
setStrategyItems(this.#strategy(), this.vdndVirtualForOf().length, () => this.#itemKeys());
|
|
6321
7716
|
});
|
|
6322
7717
|
// Register strategy with drag index calculator for accurate position lookups
|
|
6323
7718
|
effect((onCleanup) => {
|
|
@@ -6333,24 +7728,46 @@ class VirtualForDirective {
|
|
|
6333
7728
|
// correctly skip the hidden item — not just findIndexAtOffset.
|
|
6334
7729
|
effect(() => {
|
|
6335
7730
|
const strategy = this.#strategy();
|
|
6336
|
-
const draggedIndex = this.#draggedItemIndex();
|
|
6337
7731
|
const droppableId = this.#effectiveDroppableId();
|
|
6338
7732
|
const sourceDroppableId = this.#dragState.sourceDroppableId();
|
|
6339
7733
|
const isDragging = this.#dragState.isDragging();
|
|
6340
7734
|
const isSourceList = isDragging && droppableId !== undefined && droppableId === sourceDroppableId;
|
|
6341
|
-
|
|
7735
|
+
// Only the source list looks for the dragged item (in all its keys)
|
|
7736
|
+
const draggedIndex = isSourceList ? this.#draggedItemIndex() : -1;
|
|
7737
|
+
if (draggedIndex >= 0) {
|
|
6342
7738
|
strategy.setExcludedIndex(draggedIndex);
|
|
6343
7739
|
}
|
|
6344
7740
|
else {
|
|
6345
7741
|
strategy.setExcludedIndex(null);
|
|
6346
7742
|
}
|
|
6347
7743
|
});
|
|
7744
|
+
// Measure item heights in dynamic height mode: the directive's own input, or the viewport's
|
|
7745
|
+
// strategy when inherited. Both can change at runtime, and the strategy is replaced when they
|
|
7746
|
+
// do, so this creates, replaces or removes the observer. A new observer reports every
|
|
7747
|
+
// rendered item, so a new strategy gets their heights.
|
|
7748
|
+
effect((onCleanup) => {
|
|
7749
|
+
// Read the strategy first so the effect tracks its replacement even when the input is on
|
|
7750
|
+
const strategy = this.#strategy();
|
|
7751
|
+
const measure = this.vdndVirtualForDynamicItemHeight() || strategy instanceof DynamicHeightStrategy;
|
|
7752
|
+
// No ResizeObserver during server rendering
|
|
7753
|
+
if (!measure || !this.#isBrowser) {
|
|
7754
|
+
return;
|
|
7755
|
+
}
|
|
7756
|
+
untracked(() => this.#startMeasuring());
|
|
7757
|
+
onCleanup(() => this.#stopMeasuring());
|
|
7758
|
+
});
|
|
6348
7759
|
// React to changes and update views
|
|
6349
7760
|
effect(() => {
|
|
6350
7761
|
this.#updateViews();
|
|
6351
7762
|
});
|
|
6352
7763
|
}
|
|
6353
7764
|
ngOnInit() {
|
|
7765
|
+
// Server rendering: there is no ResizeObserver, and elements added outside the template
|
|
7766
|
+
// (spacer, placeholder) would not match the client's DOM during hydration. The browser adds
|
|
7767
|
+
// the spacer on init, so the server markup lacks only the list's scroll height until then.
|
|
7768
|
+
if (!this.#isBrowser) {
|
|
7769
|
+
return;
|
|
7770
|
+
}
|
|
6354
7771
|
// Only create spacer when NOT inside a viewport component
|
|
6355
7772
|
// (viewport provides its own spacer and wrapper positioning)
|
|
6356
7773
|
if (!this.#useViewportPositioning) {
|
|
@@ -6358,12 +7775,6 @@ class VirtualForDirective {
|
|
|
6358
7775
|
}
|
|
6359
7776
|
// Create placeholder element for drag operations
|
|
6360
7777
|
this.#createPlaceholder();
|
|
6361
|
-
// Set up ResizeObserver for dynamic height mode.
|
|
6362
|
-
// Check both the directive's own input and the viewport's strategy (when inherited).
|
|
6363
|
-
if (this.vdndVirtualForDynamicItemHeight() ||
|
|
6364
|
-
this.#strategy() instanceof DynamicHeightStrategy) {
|
|
6365
|
-
this.#setupResizeObserver();
|
|
6366
|
-
}
|
|
6367
7778
|
}
|
|
6368
7779
|
ngOnDestroy() {
|
|
6369
7780
|
this.#viewPool.forEach((view) => view.destroy());
|
|
@@ -6432,6 +7843,19 @@ class VirtualForDirective {
|
|
|
6432
7843
|
spacer.style.height = `${strategy.getTotalHeight(total)}px`;
|
|
6433
7844
|
}, { injector: this.#injector });
|
|
6434
7845
|
}
|
|
7846
|
+
/** Create the ResizeObserver and observe the items already rendered. */
|
|
7847
|
+
#startMeasuring() {
|
|
7848
|
+
this.#setupResizeObserver();
|
|
7849
|
+
for (const [key, view] of this.#activeViews) {
|
|
7850
|
+
this.#observeViewElements(view, key);
|
|
7851
|
+
}
|
|
7852
|
+
}
|
|
7853
|
+
/** Disconnect the ResizeObserver and forget what it observed. */
|
|
7854
|
+
#stopMeasuring() {
|
|
7855
|
+
this.#resizeObserver?.disconnect();
|
|
7856
|
+
this.#resizeObserver = null;
|
|
7857
|
+
this.#observedElements = new WeakMap();
|
|
7858
|
+
}
|
|
6435
7859
|
/**
|
|
6436
7860
|
* Set up ResizeObserver for dynamic height measurement.
|
|
6437
7861
|
*/
|
|
@@ -6509,14 +7933,12 @@ class VirtualForDirective {
|
|
|
6509
7933
|
strategy.version();
|
|
6510
7934
|
const placeholderIndex = this.#placeholderIndex();
|
|
6511
7935
|
const showPlaceholder = this.#shouldShowPlaceholder();
|
|
6512
|
-
const draggedIndex = this.#draggedItemIndex();
|
|
6513
7936
|
const droppableId = this.#effectiveDroppableId();
|
|
6514
7937
|
const sourceDroppableId = this.#dragState.sourceDroppableId();
|
|
6515
7938
|
const isSourceList = droppableId ? droppableId === sourceDroppableId : true;
|
|
6516
|
-
|
|
6517
|
-
|
|
6518
|
-
|
|
6519
|
-
draggedIndex < items.length;
|
|
7939
|
+
// Only the source list keeps the dragged item rendered, so only it looks for the item
|
|
7940
|
+
const draggedIndex = this.#dragState.isDragging() && isSourceList ? this.#draggedItemIndex() : -1;
|
|
7941
|
+
const shouldKeepDragged = draggedIndex >= 0 && draggedIndex < items.length;
|
|
6520
7942
|
// Snapshot positions before the DOM changes so displaced items can slide
|
|
6521
7943
|
this.#shiftAnimator?.beforeUpdate(this.#dragState.isDragging(), placeholderIndex);
|
|
6522
7944
|
// Notify viewport of render start index for wrapper positioning
|
|
@@ -6626,14 +8048,8 @@ class VirtualForDirective {
|
|
|
6626
8048
|
// Move unused views to pool
|
|
6627
8049
|
for (const [key, view] of this.#activeViews) {
|
|
6628
8050
|
if (!neededKeys.has(key)) {
|
|
6629
|
-
const index = this.#viewContainer.indexOf(view);
|
|
6630
|
-
if (index >= 0) {
|
|
6631
|
-
this.#viewContainer.detach(index);
|
|
6632
|
-
}
|
|
6633
|
-
this.#unobserveViewElements(view);
|
|
6634
|
-
this.#cancelShiftAnimation(view);
|
|
6635
|
-
this.#viewPool.push(view);
|
|
6636
8051
|
this.#activeViews.delete(key);
|
|
8052
|
+
this.#poolView(view);
|
|
6637
8053
|
}
|
|
6638
8054
|
}
|
|
6639
8055
|
// Remove placeholder from DOM if not needed
|
|
@@ -6645,6 +8061,7 @@ class VirtualForDirective {
|
|
|
6645
8061
|
let viewContainerIndex = 0;
|
|
6646
8062
|
let placeholderDomPosition = -1;
|
|
6647
8063
|
const renderedKeys = new Set();
|
|
8064
|
+
const changedViews = [];
|
|
6648
8065
|
for (const entry of itemsToRender) {
|
|
6649
8066
|
if (entry.type === 'placeholder') {
|
|
6650
8067
|
placeholderDomPosition = viewContainerIndex;
|
|
@@ -6655,7 +8072,7 @@ class VirtualForDirective {
|
|
|
6655
8072
|
continue;
|
|
6656
8073
|
}
|
|
6657
8074
|
renderedKeys.add(entry.key);
|
|
6658
|
-
const view = this.#getOrCreateView(entry.key, entry.context);
|
|
8075
|
+
const view = this.#getOrCreateView(entry.key, entry.context, changedViews);
|
|
6659
8076
|
// Ensure view is at correct position in ViewContainerRef
|
|
6660
8077
|
const currentIndex = this.#viewContainer.indexOf(view);
|
|
6661
8078
|
if (currentIndex !== viewContainerIndex) {
|
|
@@ -6680,8 +8097,33 @@ class VirtualForDirective {
|
|
|
6680
8097
|
}
|
|
6681
8098
|
viewContainerIndex++;
|
|
6682
8099
|
}
|
|
8100
|
+
this.#renderChangedViews(changedViews);
|
|
6683
8101
|
return placeholderDomPosition;
|
|
6684
8102
|
}
|
|
8103
|
+
/**
|
|
8104
|
+
* Take a row's view out of the container and pool it. It keeps its element, so its shift
|
|
8105
|
+
* animation is cancelled (the item it renders next must not slide from this one's position),
|
|
8106
|
+
* and a drop animation that hides it shows it again.
|
|
8107
|
+
*/
|
|
8108
|
+
#poolView(view) {
|
|
8109
|
+
const index = this.#viewContainer.indexOf(view);
|
|
8110
|
+
if (index >= 0) {
|
|
8111
|
+
this.#viewContainer.detach(index);
|
|
8112
|
+
}
|
|
8113
|
+
this.#unobserveViewElements(view);
|
|
8114
|
+
// A leave animation (`animate.leave`) keeps the row in the DOM, and Angular removes it when
|
|
8115
|
+
// the animation ends: in the row of another item by then, if the view were reused. A row
|
|
8116
|
+
// whose element is dragged (its draggable ID is not its track key) is destroyed too, which
|
|
8117
|
+
// cancels the drag instead of handing it to another item.
|
|
8118
|
+
const draggedElement = this.#dragState.draggedItem()?.element;
|
|
8119
|
+
if (view.rootNodes.some((node) => node.parentNode !== null || (!!draggedElement && node.contains(draggedElement)))) {
|
|
8120
|
+
view.destroy();
|
|
8121
|
+
return;
|
|
8122
|
+
}
|
|
8123
|
+
this.#cancelShiftAnimation(view);
|
|
8124
|
+
revealDropTargetIn(view.rootNodes);
|
|
8125
|
+
this.#viewPool.push(view);
|
|
8126
|
+
}
|
|
6685
8127
|
/**
|
|
6686
8128
|
* Clamp target indices for move/insert to avoid out-of-range operations when
|
|
6687
8129
|
* reconciliation is given invalid duplicate keys or transiently inconsistent view state.
|
|
@@ -6707,28 +8149,59 @@ class VirtualForDirective {
|
|
|
6707
8149
|
}
|
|
6708
8150
|
/**
|
|
6709
8151
|
* Get an existing view or create/recycle one from the pool.
|
|
8152
|
+
* Adds the view to `changedViews` when it needs rendering: new, taken from the pool, or with a
|
|
8153
|
+
* changed context.
|
|
6710
8154
|
*/
|
|
6711
|
-
#getOrCreateView(key, context) {
|
|
6712
|
-
|
|
8155
|
+
#getOrCreateView(key, context, changedViews) {
|
|
8156
|
+
const activeView = this.#activeViews.get(key);
|
|
8157
|
+
let view = activeView ?? this.#viewPool.pop();
|
|
6713
8158
|
if (view) {
|
|
6714
|
-
//
|
|
6715
|
-
|
|
6716
|
-
view.
|
|
8159
|
+
// A pooled view was detached, so change detection skipped it: render it even when it
|
|
8160
|
+
// comes back with the same context, as what its template shows may have changed since
|
|
8161
|
+
if (this.#updateContext(view.context, context) || !activeView) {
|
|
8162
|
+
changedViews.push(view);
|
|
8163
|
+
}
|
|
6717
8164
|
}
|
|
6718
8165
|
else {
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
if (view) {
|
|
6722
|
-
Object.assign(view.context, context);
|
|
6723
|
-
view.markForCheck();
|
|
6724
|
-
}
|
|
6725
|
-
else {
|
|
6726
|
-
view = this.#templateRef.createEmbeddedView(context);
|
|
6727
|
-
}
|
|
6728
|
-
this.#activeViews.set(key, view);
|
|
8166
|
+
view = this.#templateRef.createEmbeddedView(context);
|
|
8167
|
+
changedViews.push(view);
|
|
6729
8168
|
}
|
|
8169
|
+
this.#activeViews.set(key, view);
|
|
6730
8170
|
return view;
|
|
6731
8171
|
}
|
|
8172
|
+
/** Copy the fields of `next` into `context`. Returns whether any of them changed. */
|
|
8173
|
+
#updateContext(context, next) {
|
|
8174
|
+
if (Object.is(context.$implicit, next.$implicit) &&
|
|
8175
|
+
context.index === next.index &&
|
|
8176
|
+
context.first === next.first &&
|
|
8177
|
+
context.last === next.last &&
|
|
8178
|
+
context.count === next.count) {
|
|
8179
|
+
return false;
|
|
8180
|
+
}
|
|
8181
|
+
context.$implicit = next.$implicit;
|
|
8182
|
+
context.index = next.index;
|
|
8183
|
+
context.first = next.first;
|
|
8184
|
+
context.last = next.last;
|
|
8185
|
+
context.count = next.count;
|
|
8186
|
+
return true;
|
|
8187
|
+
}
|
|
8188
|
+
/**
|
|
8189
|
+
* Render the views whose context changed, and only those. `markForCheck()` would mark every
|
|
8190
|
+
* ancestor view up to the root as well, re-rendering all of them (and every row of the list)
|
|
8191
|
+
* on each scroll step. Untracked, so the rows' signal reads do not become this effect's
|
|
8192
|
+
* dependencies.
|
|
8193
|
+
*/
|
|
8194
|
+
#renderChangedViews(changedViews) {
|
|
8195
|
+
if (changedViews.length === 0)
|
|
8196
|
+
return;
|
|
8197
|
+
untracked(() => {
|
|
8198
|
+
for (const view of changedViews) {
|
|
8199
|
+
if (!view.destroyed) {
|
|
8200
|
+
view.detectChanges();
|
|
8201
|
+
}
|
|
8202
|
+
}
|
|
8203
|
+
});
|
|
8204
|
+
}
|
|
6732
8205
|
/**
|
|
6733
8206
|
* Apply absolute positioning styles to a view's root nodes.
|
|
6734
8207
|
*/
|
|
@@ -6867,9 +8340,9 @@ function moveItem(event, lists) {
|
|
|
6867
8340
|
reorderItems(event, sourceList);
|
|
6868
8341
|
return;
|
|
6869
8342
|
}
|
|
6870
|
-
// Cross-list move
|
|
6871
|
-
|
|
6872
|
-
const item =
|
|
8343
|
+
// Cross-list move. Read untracked, as in reorderItems: an effect that applies the drop must
|
|
8344
|
+
// not depend on the lists it changes.
|
|
8345
|
+
const item = untracked(sourceList)[sourceIndex];
|
|
6873
8346
|
if (item === undefined) {
|
|
6874
8347
|
if (isDevMode()) {
|
|
6875
8348
|
console.warn(`[ngx-virtual-dnd] [moveItem] Could not find item at index ${sourceIndex}`);
|
|
@@ -6911,6 +8384,14 @@ function reorderItems(event, list) {
|
|
|
6911
8384
|
if (sourceIndex === destIndex) {
|
|
6912
8385
|
return;
|
|
6913
8386
|
}
|
|
8387
|
+
// The list may have changed during the drag; never splice in a missing item as undefined.
|
|
8388
|
+
// Read untracked: from an effect, a tracked read would make the effect re-run on this reorder.
|
|
8389
|
+
if (untracked(list)[sourceIndex] === undefined) {
|
|
8390
|
+
if (isDevMode()) {
|
|
8391
|
+
console.warn(`[ngx-virtual-dnd] [reorderItems] Could not find item at index ${sourceIndex}`);
|
|
8392
|
+
}
|
|
8393
|
+
return;
|
|
8394
|
+
}
|
|
6914
8395
|
list.update((items) => {
|
|
6915
8396
|
const newItems = [...items];
|
|
6916
8397
|
const [removed] = newItems.splice(sourceIndex, 1);
|