ngx-virtual-dnd 3.2.0 → 3.2.1
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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { signal, InjectionToken, computed, effect, Injectable, inject, NgZone, input, ChangeDetectionStrategy, Component, DestroyRef, afterNextRender, ElementRef, Injector, untracked, viewChild, Directive, isDevMode, output, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
|
|
3
|
-
import { NgTemplateOutlet } from '@angular/common';
|
|
2
|
+
import { signal, InjectionToken, computed, effect, Injectable, inject, NgZone, input, ChangeDetectionStrategy, Component, DestroyRef, afterNextRender, ElementRef, Injector, PLATFORM_ID, untracked, afterRenderEffect, viewChild, Directive, isDevMode, output, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } 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
|
|
|
@@ -451,6 +454,14 @@ const VDND_SCROLL_CONTAINER = new InjectionToken('VDND_SCROLL_CONTAINER');
|
|
|
451
454
|
* a viewport component that handles wrapper-based positioning.
|
|
452
455
|
*/
|
|
453
456
|
const VDND_VIRTUAL_VIEWPORT = new InjectionToken('VDND_VIRTUAL_VIEWPORT');
|
|
457
|
+
/**
|
|
458
|
+
* Internal, not exported from the package. Provided by vdnd-virtual-viewport next to
|
|
459
|
+
* VDND_VIRTUAL_VIEWPORT: its rows start contentOffset px down its scroll area while its
|
|
460
|
+
* scrollTop() is the raw position, so VirtualForDirective subtracts the offset when the viewport
|
|
461
|
+
* it injects is this one. A token instead of an instanceof check keeps the viewport component out
|
|
462
|
+
* of apps that don't use it.
|
|
463
|
+
*/
|
|
464
|
+
const VDND_OFFSET_ROWS_VIEWPORT = new InjectionToken('VDND_OFFSET_ROWS_VIEWPORT');
|
|
454
465
|
|
|
455
466
|
/** Default shift animation duration (ms) used when the config omits `shiftDuration`. */
|
|
456
467
|
const DEFAULT_SHIFT_DURATION = 200;
|
|
@@ -506,7 +517,7 @@ class DragStateService {
|
|
|
506
517
|
#keyboardTargetIndex = signal(null, ...(ngDevMode ? [{ debugName: "#keyboardTargetIndex" }] : []));
|
|
507
518
|
/** ID of the droppable currently being hovered over — changes on droppable crossings */
|
|
508
519
|
#activeDroppableId = signal(null, ...(ngDevMode ? [{ debugName: "#activeDroppableId" }] : []));
|
|
509
|
-
/**
|
|
520
|
+
/** `END_OF_LIST` while a droppable is targeted, otherwise null; see placeholderIndex */
|
|
510
521
|
#placeholderId = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderId" }] : []));
|
|
511
522
|
/** Index where the placeholder should be inserted — updated on each placeholder move */
|
|
512
523
|
#placeholderIndex = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderIndex" }] : []));
|
|
@@ -530,7 +541,11 @@ class DragStateService {
|
|
|
530
541
|
sourceIndex = computed(() => this.#state().sourceIndex, ...(ngDevMode ? [{ debugName: "sourceIndex" }] : []));
|
|
531
542
|
/** ID of the droppable currently being hovered over */
|
|
532
543
|
activeDroppableId = this.#activeDroppableId.asReadonly();
|
|
533
|
-
/**
|
|
544
|
+
/**
|
|
545
|
+
* `END_OF_LIST` while a droppable is targeted, otherwise null.
|
|
546
|
+
* @deprecated It never identifies an item. Use `placeholderIndex`. Will be removed in the next
|
|
547
|
+
* major version.
|
|
548
|
+
*/
|
|
534
549
|
placeholderId = this.#placeholderId.asReadonly();
|
|
535
550
|
/** Index where the placeholder should be inserted */
|
|
536
551
|
placeholderIndex = this.#placeholderIndex.asReadonly();
|
|
@@ -572,6 +587,7 @@ class DragStateService {
|
|
|
572
587
|
}
|
|
573
588
|
/**
|
|
574
589
|
* Start a drag operation.
|
|
590
|
+
* `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
|
|
575
591
|
*/
|
|
576
592
|
startDrag(item, cursorPosition, grabOffset, lockAxis, activeDroppableId, placeholderId, placeholderIndex, sourceIndex, isKeyboardDrag, axisLockPosition) {
|
|
577
593
|
// Reset terminal drag metadata at start of new drag
|
|
@@ -595,6 +611,7 @@ class DragStateService {
|
|
|
595
611
|
}
|
|
596
612
|
/**
|
|
597
613
|
* Update the drag position and targets.
|
|
614
|
+
* `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
|
|
598
615
|
*/
|
|
599
616
|
updateDragPosition(update) {
|
|
600
617
|
if (!this.#state().isDragging) {
|
|
@@ -619,7 +636,9 @@ class DragStateService {
|
|
|
619
636
|
this.#activeDroppableId.set(droppableId);
|
|
620
637
|
}
|
|
621
638
|
/**
|
|
622
|
-
* Update just the placeholder
|
|
639
|
+
* Update just the placeholder ID.
|
|
640
|
+
* @deprecated Sets only the deprecated `placeholderId`. Will be removed in the next major
|
|
641
|
+
* version.
|
|
623
642
|
*/
|
|
624
643
|
setPlaceholder(placeholderId) {
|
|
625
644
|
if (!this.#state().isDragging) {
|
|
@@ -633,6 +652,7 @@ class DragStateService {
|
|
|
633
652
|
* Used by the autoscroll scroll-only fast path: when a scroll fires, the cursor has not
|
|
634
653
|
* moved, so only the placeholder index needs recalculation. Skipping the cursor and
|
|
635
654
|
* active-droppable writes avoids spurious re-evaluation of computeds that read those signals.
|
|
655
|
+
* `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
|
|
636
656
|
*/
|
|
637
657
|
updateScrollOnlyPlaceholder(placeholderId, placeholderIndex) {
|
|
638
658
|
if (!this.#state().isDragging) {
|
|
@@ -760,6 +780,10 @@ function queryAllByAttribute(root, attributeName, attributeValue) {
|
|
|
760
780
|
return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
|
|
761
781
|
}
|
|
762
782
|
|
|
783
|
+
/** The parent lookups stop below `<body>`: it and `<html>` never count as list or item. */
|
|
784
|
+
function isPageRoot(element) {
|
|
785
|
+
return element.tagName === 'BODY' || element.tagName === 'HTML';
|
|
786
|
+
}
|
|
763
787
|
/**
|
|
764
788
|
* Service for calculating drop positions and finding elements at cursor positions.
|
|
765
789
|
* This is the core algorithm that makes virtual scroll + drag-and-drop work together.
|
|
@@ -774,8 +798,6 @@ class PositionCalculatorService {
|
|
|
774
798
|
#DROPPABLE_DISABLED_ATTR = 'data-droppable-disabled';
|
|
775
799
|
/** Data attribute used to identify draggable elements */
|
|
776
800
|
#DRAGGABLE_ID_ATTR = 'data-draggable-id';
|
|
777
|
-
/** Maximum DOM levels to traverse when looking for parent elements */
|
|
778
|
-
#MAX_PARENT_TRAVERSAL = 15;
|
|
779
801
|
/** Reusable result object for getNearEdge (avoids per-frame allocation) */
|
|
780
802
|
#nearEdgeResult = { top: false, bottom: false, left: false, right: false };
|
|
781
803
|
/** Active drag session rect snapshot, or null when no drag is in progress. */
|
|
@@ -1071,15 +1093,19 @@ class PositionCalculatorService {
|
|
|
1071
1093
|
* @returns The droppable parent element, or null if none found
|
|
1072
1094
|
*/
|
|
1073
1095
|
getDroppableParent(element, groupName) {
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1096
|
+
// An empty group name never matches (an empty attribute value is no group)
|
|
1097
|
+
if (!groupName) {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
// Match on the attribute's presence and compare the value here, so the group name needs no
|
|
1101
|
+
// selector escaping. Nearer droppables of other groups are skipped.
|
|
1102
|
+
const selector = `[${this.#DROPPABLE_GROUP_ATTR}]`;
|
|
1103
|
+
let current = element.closest(selector);
|
|
1104
|
+
while (current && !isPageRoot(current)) {
|
|
1105
|
+
if (current.getAttribute(this.#DROPPABLE_GROUP_ATTR) === groupName) {
|
|
1079
1106
|
return current;
|
|
1080
1107
|
}
|
|
1081
|
-
current = current.parentElement;
|
|
1082
|
-
count++;
|
|
1108
|
+
current = current.parentElement?.closest(selector) ?? null;
|
|
1083
1109
|
}
|
|
1084
1110
|
return null;
|
|
1085
1111
|
}
|
|
@@ -1090,17 +1116,9 @@ class PositionCalculatorService {
|
|
|
1090
1116
|
* @returns The draggable parent element, or null if none found
|
|
1091
1117
|
*/
|
|
1092
1118
|
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;
|
|
1119
|
+
const selector = `[${this.#DRAGGABLE_ID_ATTR}]:not([${this.#DRAGGABLE_ID_ATTR}=""])`;
|
|
1120
|
+
const draggable = element.closest(selector);
|
|
1121
|
+
return draggable && !isPageRoot(draggable) ? draggable : null;
|
|
1104
1122
|
}
|
|
1105
1123
|
/**
|
|
1106
1124
|
* Get the draggable ID from an element.
|
|
@@ -1334,8 +1352,14 @@ class DragSchedulerService {
|
|
|
1334
1352
|
for (const participant of this.#participants) {
|
|
1335
1353
|
participant();
|
|
1336
1354
|
}
|
|
1355
|
+
// A participant can end the drag (the autoscroll callback may cancel it), which stops
|
|
1356
|
+
// the scheduler and clears onTick.
|
|
1357
|
+
const onTick = this.#onTick;
|
|
1358
|
+
if (!onTick) {
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1337
1361
|
// Phase 2 — main compute (cursor-based hit-test + signal write)
|
|
1338
|
-
|
|
1362
|
+
onTick(this.#pendingCursor, cursorDirty);
|
|
1339
1363
|
// Reschedule only if still active (stop() may have been called by onTick).
|
|
1340
1364
|
if (this.#onTick !== null) {
|
|
1341
1365
|
this.#scheduleNextTick();
|
|
@@ -1868,6 +1892,11 @@ class ElementCloneService {
|
|
|
1868
1892
|
el.setAttribute('disabled', 'true');
|
|
1869
1893
|
}
|
|
1870
1894
|
});
|
|
1895
|
+
// A checked radio joins its group as soon as the clone is connected, which unchecks the
|
|
1896
|
+
// radio in the source item. Without a name the cloned radios belong to no group.
|
|
1897
|
+
clone.querySelectorAll('input[type="radio"][name]').forEach((radio) => {
|
|
1898
|
+
radio.removeAttribute('name');
|
|
1899
|
+
});
|
|
1871
1900
|
// Remove focus styling classes that might interfere
|
|
1872
1901
|
clone.classList.remove('vdnd-draggable-dragging');
|
|
1873
1902
|
clone.classList.remove('vdnd-draggable-disabled');
|
|
@@ -2118,6 +2147,7 @@ class DragIndexCalculatorService {
|
|
|
2118
2147
|
const cached = this.#droppableCache.get(droppableElement);
|
|
2119
2148
|
if (cached)
|
|
2120
2149
|
return cached;
|
|
2150
|
+
const isViewport = droppableElement.hasAttribute('data-virtual-viewport');
|
|
2121
2151
|
const virtualScrollElement = droppableElement.querySelector('vdnd-virtual-scroll');
|
|
2122
2152
|
const virtualContentElement = (droppableElement.matches('vdnd-virtual-content')
|
|
2123
2153
|
? droppableElement
|
|
@@ -2125,7 +2155,13 @@ class DragIndexCalculatorService {
|
|
|
2125
2155
|
let containerType;
|
|
2126
2156
|
let scrollContainer;
|
|
2127
2157
|
let scrollableParent = null;
|
|
2128
|
-
if (
|
|
2158
|
+
if (isViewport) {
|
|
2159
|
+
// The viewport scrolls its own rows. Checked first so a list nested in a row is not
|
|
2160
|
+
// mistaken for this one.
|
|
2161
|
+
containerType = 'viewport';
|
|
2162
|
+
scrollContainer = droppableElement;
|
|
2163
|
+
}
|
|
2164
|
+
else if (virtualScrollElement) {
|
|
2129
2165
|
containerType = 'virtualScroll';
|
|
2130
2166
|
scrollContainer = virtualScrollElement;
|
|
2131
2167
|
}
|
|
@@ -2164,29 +2200,19 @@ class DragIndexCalculatorService {
|
|
|
2164
2200
|
getTotalItemCount(args) {
|
|
2165
2201
|
return this.#getTotalItemCount(args.droppableElement, args.isSameList, args.draggedItemHeight);
|
|
2166
2202
|
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Live scroll geometry of a droppable's rows (see {@link DroppableScrollGeometry}). The same
|
|
2205
|
+
* measurement places the placeholder and finds the dragged item's source index.
|
|
2206
|
+
*/
|
|
2207
|
+
getScrollGeometry(droppableElement, draggedItemHeight) {
|
|
2208
|
+
return this.#getScrollGeometry(this.#resolveDroppable(droppableElement, draggedItemHeight));
|
|
2209
|
+
}
|
|
2167
2210
|
calculatePlaceholderIndex(args) {
|
|
2168
2211
|
const { droppableElement, position, grabOffset, draggedItemHeight, sourceDroppableId, sourceIndex, } = args;
|
|
2169
2212
|
// Resolve cached metadata (DOM queries run once per droppable per drag session)
|
|
2170
2213
|
const cache = this.#resolveDroppable(droppableElement, draggedItemHeight);
|
|
2171
2214
|
// 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
|
-
}
|
|
2215
|
+
const { rect, scrollTop: currentScrollTop } = this.#getScrollGeometry(cache);
|
|
2190
2216
|
const { strategy, itemHeight, droppableId: currentDroppableId, isConstrainedToContainer, } = cache;
|
|
2191
2217
|
// Calculate preview center position mathematically
|
|
2192
2218
|
// The preview is positioned at: cursorPosition - grabOffset (see drag-preview.component.ts)
|
|
@@ -2274,6 +2300,35 @@ class DragIndexCalculatorService {
|
|
|
2274
2300
|
placeholderIndex = Math.max(0, Math.min(placeholderIndex, totalItems));
|
|
2275
2301
|
return { index: placeholderIndex, placeholderId: END_OF_LIST };
|
|
2276
2302
|
}
|
|
2303
|
+
#getScrollGeometry(cache) {
|
|
2304
|
+
const { containerType, scrollContainer } = cache;
|
|
2305
|
+
const rect = scrollContainer.getBoundingClientRect();
|
|
2306
|
+
const isVirtual = containerType === 'virtualScroll' || containerType === 'virtualContent';
|
|
2307
|
+
switch (containerType) {
|
|
2308
|
+
case 'viewport':
|
|
2309
|
+
return {
|
|
2310
|
+
rect,
|
|
2311
|
+
scrollTop: scrollContainer.scrollTop - this.#readContentOffset(scrollContainer),
|
|
2312
|
+
isVirtual,
|
|
2313
|
+
};
|
|
2314
|
+
case 'virtualContent':
|
|
2315
|
+
// Without a vdndScrollable parent the content element itself is measured, unscrolled
|
|
2316
|
+
return cache.scrollableParent
|
|
2317
|
+
? {
|
|
2318
|
+
rect,
|
|
2319
|
+
scrollTop: scrollContainer.scrollTop - this.#readContentOffset(cache.virtualContentElement),
|
|
2320
|
+
isVirtual,
|
|
2321
|
+
}
|
|
2322
|
+
: { rect, scrollTop: 0, isVirtual };
|
|
2323
|
+
default:
|
|
2324
|
+
return { rect, scrollTop: scrollContainer.scrollTop, isVirtual };
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
/** The `data-content-offset` (px reserved above the rows) of a virtual container, or 0. */
|
|
2328
|
+
#readContentOffset(element) {
|
|
2329
|
+
const offset = parseFloat(element?.getAttribute('data-content-offset') ?? '');
|
|
2330
|
+
return Number.isFinite(offset) ? offset : 0;
|
|
2331
|
+
}
|
|
2277
2332
|
#getTotalItemCount(droppableElement, isSameList, draggedItemHeight) {
|
|
2278
2333
|
// Check if a registered strategy exists
|
|
2279
2334
|
const droppableId = this.#positionCalculator.getDroppableId(droppableElement);
|
|
@@ -2401,10 +2456,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
2401
2456
|
}, template: ``, styles: [":host{display:block;pointer-events:none}\n"] }]
|
|
2402
2457
|
}], propDecorators: { itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }] } });
|
|
2403
2458
|
|
|
2459
|
+
/** A layout value, or 0 where there is no layout (server rendering leaves them undefined). */
|
|
2460
|
+
function layoutValue(value) {
|
|
2461
|
+
return Number.isFinite(value) ? value : 0;
|
|
2462
|
+
}
|
|
2404
2463
|
function bindRafThrottledScrollTopSignal(options) {
|
|
2405
2464
|
const { element, ngZone, scrollTop, thresholdPx = 5, onCommit } = options;
|
|
2406
2465
|
let pendingRaf = null;
|
|
2407
|
-
let lastCommittedScrollTop = element.scrollTop;
|
|
2466
|
+
let lastCommittedScrollTop = layoutValue(element.scrollTop);
|
|
2408
2467
|
const onScroll = () => {
|
|
2409
2468
|
if (pendingRaf !== null) {
|
|
2410
2469
|
return;
|
|
@@ -2438,7 +2497,7 @@ function bindRafThrottledScrollTopSignal(options) {
|
|
|
2438
2497
|
function bindResizeObserverHeightSignal(options) {
|
|
2439
2498
|
const { element, ngZone, height, minDeltaPx = 1 } = options;
|
|
2440
2499
|
if (typeof ResizeObserver === 'undefined') {
|
|
2441
|
-
height.set(element.clientHeight);
|
|
2500
|
+
height.set(layoutValue(element.clientHeight));
|
|
2442
2501
|
return () => undefined;
|
|
2443
2502
|
}
|
|
2444
2503
|
let observer = null;
|
|
@@ -2453,7 +2512,7 @@ function bindResizeObserverHeightSignal(options) {
|
|
|
2453
2512
|
});
|
|
2454
2513
|
observer.observe(element);
|
|
2455
2514
|
});
|
|
2456
|
-
height.set(element.clientHeight);
|
|
2515
|
+
height.set(layoutValue(element.clientHeight));
|
|
2457
2516
|
return () => {
|
|
2458
2517
|
observer?.disconnect();
|
|
2459
2518
|
observer = null;
|
|
@@ -2716,11 +2775,12 @@ class VirtualScrollContainerComponent {
|
|
|
2716
2775
|
#keyboardDrag = inject(KeyboardDragService);
|
|
2717
2776
|
#ngZone = inject(NgZone);
|
|
2718
2777
|
#injector = inject(Injector);
|
|
2778
|
+
#isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
2719
2779
|
/** Cleanup function for scroll listener */
|
|
2720
2780
|
#scrollCleanup = null;
|
|
2721
2781
|
#resizeCleanup = null;
|
|
2722
|
-
/** ResizeObserver for dynamic height measurement */
|
|
2723
|
-
#itemResizeObserver = null;
|
|
2782
|
+
/** ResizeObserver for dynamic height measurement (only in dynamic height mode) */
|
|
2783
|
+
#itemResizeObserver = signal(null, ...(ngDevMode ? [{ debugName: "#itemResizeObserver" }] : []));
|
|
2724
2784
|
/** Map from observed HTMLElement to its trackBy key */
|
|
2725
2785
|
#observedElements = new WeakMap();
|
|
2726
2786
|
/** Stable mapping from rendered key to observed element for unobserve cleanup */
|
|
@@ -3098,10 +3158,23 @@ class VirtualScrollContainerComponent {
|
|
|
3098
3158
|
});
|
|
3099
3159
|
}
|
|
3100
3160
|
ngOnInit() {
|
|
3101
|
-
//
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3161
|
+
// Measure rows in dynamic height mode. dynamicItemHeight can change at runtime, and the
|
|
3162
|
+
// strategy is replaced when it (or itemHeight) does, so this creates, replaces or removes the
|
|
3163
|
+
// observer. #observeRenderedItems observes every rendered row with each new observer, so a
|
|
3164
|
+
// new strategy gets their heights.
|
|
3165
|
+
effect((onCleanup) => {
|
|
3166
|
+
// No ResizeObserver during server rendering
|
|
3167
|
+
if (!this.dynamicItemHeight() || !this.#isBrowser)
|
|
3168
|
+
return;
|
|
3169
|
+
this.#strategy();
|
|
3170
|
+
const observer = untracked(() => this.#createItemResizeObserver());
|
|
3171
|
+
this.#itemResizeObserver.set(observer);
|
|
3172
|
+
onCleanup(() => {
|
|
3173
|
+
observer.disconnect();
|
|
3174
|
+
this.#observedElementByKey.clear();
|
|
3175
|
+
this.#itemResizeObserver.set(null);
|
|
3176
|
+
});
|
|
3177
|
+
}, { injector: this.#injector });
|
|
3105
3178
|
}
|
|
3106
3179
|
ngAfterViewInit() {
|
|
3107
3180
|
const element = this.#elementRef.nativeElement;
|
|
@@ -3120,9 +3193,7 @@ class VirtualScrollContainerComponent {
|
|
|
3120
3193
|
thresholdPx: 5,
|
|
3121
3194
|
});
|
|
3122
3195
|
// Observe rendered items for dynamic height measurement
|
|
3123
|
-
|
|
3124
|
-
this.#observeRenderedItems();
|
|
3125
|
-
}
|
|
3196
|
+
this.#observeRenderedItems();
|
|
3126
3197
|
}
|
|
3127
3198
|
/**
|
|
3128
3199
|
* Scroll so the placeholder is fully visible during a keyboard drag into this list.
|
|
@@ -3171,11 +3242,12 @@ class VirtualScrollContainerComponent {
|
|
|
3171
3242
|
ngOnDestroy() {
|
|
3172
3243
|
this.#scrollCleanup?.();
|
|
3173
3244
|
this.#resizeCleanup?.();
|
|
3245
|
+
const itemResizeObserver = this.#itemResizeObserver();
|
|
3174
3246
|
for (const element of this.#observedElementByKey.values()) {
|
|
3175
|
-
|
|
3247
|
+
itemResizeObserver?.unobserve(element);
|
|
3176
3248
|
}
|
|
3177
3249
|
this.#observedElementByKey.clear();
|
|
3178
|
-
|
|
3250
|
+
itemResizeObserver?.disconnect();
|
|
3179
3251
|
this.#shiftAnimator?.cancelAll();
|
|
3180
3252
|
}
|
|
3181
3253
|
#createShiftAnimator() {
|
|
@@ -3204,39 +3276,37 @@ class VirtualScrollContainerComponent {
|
|
|
3204
3276
|
}
|
|
3205
3277
|
}
|
|
3206
3278
|
/**
|
|
3207
|
-
*
|
|
3279
|
+
* Create the ResizeObserver that measures individual item heights.
|
|
3208
3280
|
*/
|
|
3209
|
-
#
|
|
3210
|
-
this.#ngZone.runOutsideAngular(() => {
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
strategy.setMeasuredHeight(key, height);
|
|
3221
|
-
}
|
|
3281
|
+
#createItemResizeObserver() {
|
|
3282
|
+
return this.#ngZone.runOutsideAngular(() => new ResizeObserver((entries) => {
|
|
3283
|
+
const strategy = this.#strategy();
|
|
3284
|
+
for (const entry of entries) {
|
|
3285
|
+
const element = entry.target;
|
|
3286
|
+
const key = this.#observedElements.get(element);
|
|
3287
|
+
if (key === undefined)
|
|
3288
|
+
continue;
|
|
3289
|
+
const height = entry.borderBoxSize?.[0]?.blockSize ?? element.offsetHeight;
|
|
3290
|
+
if (height > 0) {
|
|
3291
|
+
strategy.setMeasuredHeight(key, height);
|
|
3222
3292
|
}
|
|
3223
|
-
}
|
|
3224
|
-
});
|
|
3293
|
+
}
|
|
3294
|
+
}));
|
|
3225
3295
|
}
|
|
3226
3296
|
/**
|
|
3227
3297
|
* Observe currently rendered items' DOM elements for height changes.
|
|
3228
|
-
* Called after view init
|
|
3298
|
+
* Called after view init; re-observes after each render in dynamic mode, and every rendered
|
|
3299
|
+
* item when a new observer is created.
|
|
3229
3300
|
*/
|
|
3230
3301
|
#observeRenderedItems() {
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
const rendered = this.renderedItems();
|
|
3236
|
-
const idFn = this.itemIdFn();
|
|
3237
|
-
const observer = this.#itemResizeObserver;
|
|
3302
|
+
// Re-observe whenever rendered items change, once they are in the DOM (a plain effect runs
|
|
3303
|
+
// before the template renders them, so newly rendered rows would be missed)
|
|
3304
|
+
afterRenderEffect(() => {
|
|
3305
|
+
const observer = this.#itemResizeObserver();
|
|
3238
3306
|
if (!observer)
|
|
3239
3307
|
return;
|
|
3308
|
+
const rendered = this.renderedItems();
|
|
3309
|
+
const idFn = this.itemIdFn();
|
|
3240
3310
|
// Find the content wrapper and observe item elements
|
|
3241
3311
|
const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
|
|
3242
3312
|
if (!wrapper)
|
|
@@ -3275,8 +3345,11 @@ class VirtualScrollContainerComponent {
|
|
|
3275
3345
|
* Scroll to a specific position.
|
|
3276
3346
|
*/
|
|
3277
3347
|
scrollTo(position) {
|
|
3278
|
-
this.#elementRef.nativeElement
|
|
3279
|
-
|
|
3348
|
+
const element = this.#elementRef.nativeElement;
|
|
3349
|
+
element.scrollTop = position;
|
|
3350
|
+
// Store where the browser actually scrolled. It clamps to the scrollable range, and if that
|
|
3351
|
+
// leaves the element where it was, no scroll event arrives to correct an out-of-range value.
|
|
3352
|
+
this.#scrollTop.set(element.scrollTop);
|
|
3280
3353
|
}
|
|
3281
3354
|
/**
|
|
3282
3355
|
* Scroll to a specific item index.
|
|
@@ -3982,7 +4055,11 @@ class DroppableDirective {
|
|
|
3982
4055
|
const activeId = this.#dragState.activeDroppableId();
|
|
3983
4056
|
return activeId === this.vdndDroppable() && !this.disabled();
|
|
3984
4057
|
}, ...(ngDevMode ? [{ debugName: "isActive" }] : []));
|
|
3985
|
-
/**
|
|
4058
|
+
/**
|
|
4059
|
+
* `END_OF_LIST` while this droppable is active, otherwise null.
|
|
4060
|
+
* @deprecated It never identifies an item. For the position, use the `placeholderMove` output
|
|
4061
|
+
* (and `DropEvent.destination.index` on drop). Will be removed in the next major version.
|
|
4062
|
+
*/
|
|
3986
4063
|
placeholderId = computed(() => {
|
|
3987
4064
|
if (!this.isActive()) {
|
|
3988
4065
|
return null;
|
|
@@ -4018,6 +4095,8 @@ class DroppableDirective {
|
|
|
4018
4095
|
* when the effect re-runs (e.g. `disabled()` toggling) after the drag has ended.
|
|
4019
4096
|
*/
|
|
4020
4097
|
#handledEndedState = null;
|
|
4098
|
+
/** Whether this droppable has rendered, so its inputs have values */
|
|
4099
|
+
#rendered = false;
|
|
4021
4100
|
constructor() {
|
|
4022
4101
|
createAutoScrollRegistration({
|
|
4023
4102
|
autoScrollService: this.#autoScroll,
|
|
@@ -4034,6 +4113,7 @@ class DroppableDirective {
|
|
|
4034
4113
|
// Matters when it mounts DURING an active drag — the candidate snapshot was frozen at
|
|
4035
4114
|
// drag start, so without this a conditionally rendered list would never become a target.
|
|
4036
4115
|
afterNextRender(() => {
|
|
4116
|
+
this.#rendered = true;
|
|
4037
4117
|
const group = this.effectiveGroup();
|
|
4038
4118
|
if (group) {
|
|
4039
4119
|
this.#positionCalculator.notifyCandidatesChanged(group);
|
|
@@ -4079,12 +4159,17 @@ class DroppableDirective {
|
|
|
4079
4159
|
});
|
|
4080
4160
|
}
|
|
4081
4161
|
ngOnDestroy() {
|
|
4162
|
+
// Destroyed before it rendered: its inputs may have no values yet, and it never became a
|
|
4163
|
+
// drop target or a candidate the calculator knows about. (Not an ngOnInit flag: a subclass
|
|
4164
|
+
// with its own ngOnInit would skip it.)
|
|
4165
|
+
if (!this.#rendered) {
|
|
4166
|
+
return;
|
|
4167
|
+
}
|
|
4082
4168
|
// Clean up if this droppable is destroyed while being active
|
|
4083
4169
|
if (this.isActive()) {
|
|
4084
4170
|
this.#dragState.setActiveDroppable(null);
|
|
4085
4171
|
}
|
|
4086
|
-
//
|
|
4087
|
-
this.#autoScroll.unregisterContainer(this.vdndDroppable());
|
|
4172
|
+
// Auto-scroll unregisters itself on destroy (see createAutoScrollRegistration).
|
|
4088
4173
|
// If this droppable unmounts mid-drag, tell the calculator so it drops it from the
|
|
4089
4174
|
// frozen candidate list (deferred re-query runs once the element has left the DOM).
|
|
4090
4175
|
const group = untracked(() => this.effectiveGroup());
|
|
@@ -4557,8 +4642,9 @@ class VirtualViewportComponent {
|
|
|
4557
4642
|
});
|
|
4558
4643
|
}
|
|
4559
4644
|
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: [
|
|
4645
|
+
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
4646
|
{ provide: VDND_VIRTUAL_VIEWPORT, useExisting: VirtualViewportComponent },
|
|
4647
|
+
{ provide: VDND_OFFSET_ROWS_VIEWPORT, useExisting: VirtualViewportComponent },
|
|
4562
4648
|
{ provide: VDND_SCROLL_CONTAINER, useExisting: VirtualViewportComponent },
|
|
4563
4649
|
], ngImport: i0, template: `
|
|
4564
4650
|
<!-- Spacer maintains total scroll height -->
|
|
@@ -4582,9 +4668,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4582
4668
|
type: Component,
|
|
4583
4669
|
args: [{ selector: 'vdnd-virtual-viewport', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
|
|
4584
4670
|
{ provide: VDND_VIRTUAL_VIEWPORT, useExisting: VirtualViewportComponent },
|
|
4671
|
+
{ provide: VDND_OFFSET_ROWS_VIEWPORT, useExisting: VirtualViewportComponent },
|
|
4585
4672
|
{ provide: VDND_SCROLL_CONTAINER, useExisting: VirtualViewportComponent },
|
|
4586
4673
|
], host: {
|
|
4587
4674
|
class: 'vdnd-virtual-viewport',
|
|
4675
|
+
// Read by the drag index calculator: this element scrolls its own rows, which start
|
|
4676
|
+
// data-content-offset px below the top of the scroll area
|
|
4677
|
+
'data-virtual-viewport': '',
|
|
4678
|
+
'[attr.data-content-offset]': 'contentOffset()',
|
|
4588
4679
|
}, template: `
|
|
4589
4680
|
<!-- Spacer maintains total scroll height -->
|
|
4590
4681
|
<div
|
|
@@ -4679,6 +4770,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4679
4770
|
class VirtualContentComponent {
|
|
4680
4771
|
#elementRef = inject((ElementRef));
|
|
4681
4772
|
#ngZone = inject(NgZone);
|
|
4773
|
+
#isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
4682
4774
|
/**
|
|
4683
4775
|
* The parent scroll container injected via skip-self to get the actual scrollable element.
|
|
4684
4776
|
* We provide our own VDND_SCROLL_CONTAINER with adjusted values to children.
|
|
@@ -4807,6 +4899,10 @@ class VirtualContentComponent {
|
|
|
4807
4899
|
this.#measuredHeaderHeight.set(0);
|
|
4808
4900
|
return;
|
|
4809
4901
|
}
|
|
4902
|
+
// Server rendering: no layout to measure, and no ResizeObserver
|
|
4903
|
+
if (!this.#isBrowser) {
|
|
4904
|
+
return;
|
|
4905
|
+
}
|
|
4810
4906
|
const el = dir.elementRef.nativeElement;
|
|
4811
4907
|
this.#measuredHeaderHeight.set(el.offsetHeight);
|
|
4812
4908
|
let observer = null;
|
|
@@ -4885,15 +4981,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
4885
4981
|
class KeyboardDragHandler {
|
|
4886
4982
|
#deps;
|
|
4887
4983
|
#boundKeyDown = null;
|
|
4984
|
+
/**
|
|
4985
|
+
* The drag this handler started. Keyboard drag state is shared by every draggable, so a key
|
|
4986
|
+
* that reaches another item (one focused by a click during the drag, for example) must not
|
|
4987
|
+
* move, drop or cancel this drag in that item's name.
|
|
4988
|
+
*/
|
|
4989
|
+
#ownDrag = null;
|
|
4888
4990
|
constructor(deps) {
|
|
4889
4991
|
this.#deps = deps;
|
|
4890
4992
|
this.#boundKeyDown = this.#onDocumentKeyDown.bind(this);
|
|
4891
4993
|
}
|
|
4892
4994
|
/**
|
|
4893
|
-
* Whether a keyboard drag is
|
|
4995
|
+
* Whether a keyboard drag this handler started is active.
|
|
4894
4996
|
*/
|
|
4895
4997
|
isActive() {
|
|
4896
|
-
return this.#
|
|
4998
|
+
return (this.#ownDrag !== null &&
|
|
4999
|
+
this.#deps.keyboardDrag.isActive() &&
|
|
5000
|
+
this.#deps.dragState.draggedItem() === this.#ownDrag);
|
|
4897
5001
|
}
|
|
4898
5002
|
/**
|
|
4899
5003
|
* Start a keyboard drag operation.
|
|
@@ -4937,6 +5041,7 @@ class KeyboardDragHandler {
|
|
|
4937
5041
|
width: rect.width,
|
|
4938
5042
|
data: ctx.data,
|
|
4939
5043
|
}, sourceIndex, totalItemCount, droppableId);
|
|
5044
|
+
this.#ownDrag = this.#deps.dragState.draggedItem();
|
|
4940
5045
|
// Add document-level keyboard listener (since element is hidden with display:none)
|
|
4941
5046
|
this.#deps.ngZone.runOutsideAngular(() => {
|
|
4942
5047
|
document.addEventListener('keydown', this.#boundKeyDown);
|
|
@@ -5132,19 +5237,42 @@ class KeyboardDragHandler {
|
|
|
5132
5237
|
* Document-level keydown listener active during keyboard drag.
|
|
5133
5238
|
*/
|
|
5134
5239
|
#onDocumentKeyDown(event) {
|
|
5240
|
+
// The drag ended without this handler (its state was reset elsewhere): stop listening, or
|
|
5241
|
+
// this listener would also take the keys of the next keyboard drag
|
|
5242
|
+
if (!this.isActive()) {
|
|
5243
|
+
this.#cleanupDocumentListener();
|
|
5244
|
+
return;
|
|
5245
|
+
}
|
|
5135
5246
|
this.handleKey(event);
|
|
5136
5247
|
}
|
|
5137
5248
|
/**
|
|
5138
5249
|
* Remove the document-level keyboard listener.
|
|
5139
5250
|
*/
|
|
5140
5251
|
#cleanupDocumentListener() {
|
|
5141
|
-
|
|
5252
|
+
this.#ownDrag = null;
|
|
5253
|
+
// Server rendering destroys the directive without a global document, and never added it
|
|
5254
|
+
if (this.#boundKeyDown && typeof document !== 'undefined') {
|
|
5142
5255
|
document.removeEventListener('keydown', this.#boundKeyDown);
|
|
5143
5256
|
}
|
|
5144
5257
|
}
|
|
5145
5258
|
}
|
|
5146
5259
|
|
|
5147
|
-
|
|
5260
|
+
/**
|
|
5261
|
+
* Elements inside a draggable that keep their own interaction: pressing one never starts a
|
|
5262
|
+
* pointer drag, and Space pressed on one never starts a keyboard drag.
|
|
5263
|
+
*/
|
|
5264
|
+
const INTERACTIVE_ELEMENT_SELECTOR = 'button, input, textarea, select, [contenteditable]';
|
|
5265
|
+
/** Class that excludes the element carrying it, and everything inside it, from starting a drag. */
|
|
5266
|
+
const NO_DRAG_CLASS = 'no-drag';
|
|
5267
|
+
/**
|
|
5268
|
+
* The `no-drag` element inside `draggable` (the draggable itself included) that contains
|
|
5269
|
+
* `target`, or null. A `no-drag` ancestor of the draggable does not count.
|
|
5270
|
+
*/
|
|
5271
|
+
function findNoDragElement(target, draggable) {
|
|
5272
|
+
const noDrag = target.closest(`.${NO_DRAG_CLASS}`);
|
|
5273
|
+
return noDrag !== null && draggable.contains(noDrag) ? noDrag : null;
|
|
5274
|
+
}
|
|
5275
|
+
|
|
5148
5276
|
/**
|
|
5149
5277
|
* Handles pointer-based (mouse + touch) drag operations.
|
|
5150
5278
|
*
|
|
@@ -5154,7 +5282,8 @@ var _a;
|
|
|
5154
5282
|
* - Drag delay timer
|
|
5155
5283
|
* - Forwarding moves to the directive (DragSchedulerService coalesces them per frame)
|
|
5156
5284
|
* - Document-level listener management
|
|
5157
|
-
* - Escape key cancellation during pointer drag
|
|
5285
|
+
* - Escape key cancellation during pointer drag, and cancellation when the window loses focus
|
|
5286
|
+
* or the page is hidden (the release would never arrive) or the system cancels the touch
|
|
5158
5287
|
*/
|
|
5159
5288
|
class PointerDragHandler {
|
|
5160
5289
|
#deps;
|
|
@@ -5162,10 +5291,14 @@ class PointerDragHandler {
|
|
|
5162
5291
|
#startPosition = null;
|
|
5163
5292
|
/** Whether we're currently tracking a potential drag */
|
|
5164
5293
|
#isTracking = false;
|
|
5294
|
+
/** Identifier of the finger that pressed, for touch gestures (other fingers are ignored) */
|
|
5295
|
+
#touchId = null;
|
|
5165
5296
|
/** Bound event handlers for cleanup */
|
|
5166
5297
|
#boundPointerMove = null;
|
|
5167
5298
|
#boundPointerUp = null;
|
|
5168
5299
|
#boundKeyDown = null;
|
|
5300
|
+
#boundWindowBlur = null;
|
|
5301
|
+
#boundVisibilityChange = null;
|
|
5169
5302
|
/** Timer ID for drag delay */
|
|
5170
5303
|
#delayTimerId = null;
|
|
5171
5304
|
/** Whether the delay has been satisfied (user held long enough) */
|
|
@@ -5175,6 +5308,8 @@ class PointerDragHandler {
|
|
|
5175
5308
|
this.#boundPointerMove = this.#onPointerMove.bind(this);
|
|
5176
5309
|
this.#boundPointerUp = this.#onPointerUp.bind(this);
|
|
5177
5310
|
this.#boundKeyDown = this.#onEscapeKeyDown.bind(this);
|
|
5311
|
+
this.#boundWindowBlur = this.#onFocusLost.bind(this);
|
|
5312
|
+
this.#boundVisibilityChange = this.#onVisibilityChange.bind(this);
|
|
5178
5313
|
}
|
|
5179
5314
|
/**
|
|
5180
5315
|
* The position where the user initially pressed down.
|
|
@@ -5209,8 +5344,13 @@ class PointerDragHandler {
|
|
|
5209
5344
|
}
|
|
5210
5345
|
// Check for elements that should not trigger drag
|
|
5211
5346
|
const target = event.target;
|
|
5212
|
-
if (target.closest(
|
|
5213
|
-
target.
|
|
5347
|
+
if (target.closest(INTERACTIVE_ELEMENT_SELECTOR) ||
|
|
5348
|
+
findNoDragElement(target, ctx.element) !== null) {
|
|
5349
|
+
return;
|
|
5350
|
+
}
|
|
5351
|
+
// Track the finger that pressed this draggable; the others are ignored for the gesture.
|
|
5352
|
+
const touch = 'touches' in event ? PointerDragHandler.#pressingTouch(event, ctx.element) : null;
|
|
5353
|
+
if ('touches' in event && !touch) {
|
|
5214
5354
|
return;
|
|
5215
5355
|
}
|
|
5216
5356
|
const delay = ctx.dragDelay;
|
|
@@ -5222,15 +5362,39 @@ class PointerDragHandler {
|
|
|
5222
5362
|
event.preventDefault();
|
|
5223
5363
|
}
|
|
5224
5364
|
event.stopPropagation();
|
|
5365
|
+
// A second finger on this draggable while the first is still down: keep following the first.
|
|
5366
|
+
if ('touches' in event && touch && this.#isTracking && this.#trackedTouch(event.touches)) {
|
|
5367
|
+
return;
|
|
5368
|
+
}
|
|
5369
|
+
// Only one drag at a time: a second finger or a press during a keyboard drag must not
|
|
5370
|
+
// replace the drag in progress. The press keeps the default handling above, so a mouse press
|
|
5371
|
+
// still doesn't focus this item (which would take the keys of a keyboard drag).
|
|
5372
|
+
if (this.#deps.callbacks.isOtherDragActive()) {
|
|
5373
|
+
return;
|
|
5374
|
+
}
|
|
5225
5375
|
this.#isTracking = true;
|
|
5226
|
-
|
|
5227
|
-
|
|
5376
|
+
if (touch) {
|
|
5377
|
+
this.#touchId = touch.identifier;
|
|
5378
|
+
this.#startPosition = { x: touch.clientX, y: touch.clientY };
|
|
5379
|
+
}
|
|
5380
|
+
else {
|
|
5381
|
+
const mouse = event;
|
|
5382
|
+
this.#startPosition = { x: mouse.clientX, y: mouse.clientY };
|
|
5383
|
+
}
|
|
5384
|
+
// Handle drag delay. A repeated press (e.g. after a release the page never saw) restarts the
|
|
5385
|
+
// delay; the previous timer must not survive, or it fires after the gesture has ended.
|
|
5386
|
+
this.#cancelDelayTimer();
|
|
5228
5387
|
if (delay > 0) {
|
|
5229
|
-
this.#delayReady = false;
|
|
5230
5388
|
this.#delayTimerId = setTimeout(() => {
|
|
5389
|
+
this.#delayTimerId = null;
|
|
5390
|
+
// Another drag started while this press was held: drop the press rather than show it
|
|
5391
|
+
// as ready to drag during that drag.
|
|
5392
|
+
if (this.#deps.callbacks.isOtherDragActive()) {
|
|
5393
|
+
this.cleanup();
|
|
5394
|
+
return;
|
|
5395
|
+
}
|
|
5231
5396
|
this.#delayReady = true;
|
|
5232
5397
|
this.#deps.callbacks.onPendingChange(true); // Emit ready state when delay passes
|
|
5233
|
-
this.#delayTimerId = null;
|
|
5234
5398
|
}, delay);
|
|
5235
5399
|
}
|
|
5236
5400
|
else {
|
|
@@ -5249,6 +5413,10 @@ class PointerDragHandler {
|
|
|
5249
5413
|
}
|
|
5250
5414
|
// Listen for Escape key on document to cancel drag
|
|
5251
5415
|
document.addEventListener('keydown', this.#boundKeyDown);
|
|
5416
|
+
// Switching windows or tabs mid-drag means the release never reaches this page: cancel
|
|
5417
|
+
// instead of leaving the drag active until the next press.
|
|
5418
|
+
window.addEventListener('blur', this.#boundWindowBlur);
|
|
5419
|
+
document.addEventListener('visibilitychange', this.#boundVisibilityChange);
|
|
5252
5420
|
});
|
|
5253
5421
|
}
|
|
5254
5422
|
/**
|
|
@@ -5258,8 +5426,13 @@ class PointerDragHandler {
|
|
|
5258
5426
|
cleanup() {
|
|
5259
5427
|
this.#isTracking = false;
|
|
5260
5428
|
this.#startPosition = null;
|
|
5429
|
+
this.#touchId = null;
|
|
5261
5430
|
this.#deps.callbacks.onPendingChange(false); // Clear pending state on cleanup
|
|
5262
5431
|
this.#cancelDelayTimer();
|
|
5432
|
+
// Server rendering destroys the directive without a global document, and never added any
|
|
5433
|
+
if (typeof document === 'undefined') {
|
|
5434
|
+
return;
|
|
5435
|
+
}
|
|
5263
5436
|
// Remove event listeners
|
|
5264
5437
|
if (this.#boundPointerMove) {
|
|
5265
5438
|
document.removeEventListener('mousemove', this.#boundPointerMove);
|
|
@@ -5273,9 +5446,15 @@ class PointerDragHandler {
|
|
|
5273
5446
|
if (this.#boundKeyDown) {
|
|
5274
5447
|
document.removeEventListener('keydown', this.#boundKeyDown);
|
|
5275
5448
|
}
|
|
5449
|
+
if (this.#boundWindowBlur) {
|
|
5450
|
+
window.removeEventListener('blur', this.#boundWindowBlur);
|
|
5451
|
+
}
|
|
5452
|
+
if (this.#boundVisibilityChange) {
|
|
5453
|
+
document.removeEventListener('visibilitychange', this.#boundVisibilityChange);
|
|
5454
|
+
}
|
|
5276
5455
|
}
|
|
5277
5456
|
/**
|
|
5278
|
-
*
|
|
5457
|
+
* Teardown when the directive is destroyed: removes listeners and cancels timers.
|
|
5279
5458
|
*/
|
|
5280
5459
|
destroy() {
|
|
5281
5460
|
this.cleanup();
|
|
@@ -5287,7 +5466,23 @@ class PointerDragHandler {
|
|
|
5287
5466
|
if (!this.#isTracking) {
|
|
5288
5467
|
return;
|
|
5289
5468
|
}
|
|
5290
|
-
|
|
5469
|
+
let position;
|
|
5470
|
+
if ('touches' in event) {
|
|
5471
|
+
// `touches` holds every finger's current position, so the tracked finger's is there even
|
|
5472
|
+
// when only another finger moved
|
|
5473
|
+
const touch = this.#trackedTouch(event.touches) ?? this.#trackedTouch(event.changedTouches);
|
|
5474
|
+
if (!touch) {
|
|
5475
|
+
// The tracked finger is gone. While dragging, keep other fingers from scrolling the page.
|
|
5476
|
+
if (this.#deps.callbacks.isDragging()) {
|
|
5477
|
+
event.preventDefault();
|
|
5478
|
+
}
|
|
5479
|
+
return;
|
|
5480
|
+
}
|
|
5481
|
+
position = { x: touch.clientX, y: touch.clientY };
|
|
5482
|
+
}
|
|
5483
|
+
else {
|
|
5484
|
+
position = { x: event.clientX, y: event.clientY };
|
|
5485
|
+
}
|
|
5291
5486
|
const ctx = this.#deps.getContext();
|
|
5292
5487
|
// Check if we've moved past the threshold
|
|
5293
5488
|
if (!this.#deps.callbacks.isDragging() && this.#startPosition) {
|
|
@@ -5304,6 +5499,11 @@ class PointerDragHandler {
|
|
|
5304
5499
|
this.cleanup();
|
|
5305
5500
|
return;
|
|
5306
5501
|
}
|
|
5502
|
+
// Another press won the race and started its drag first: give up this one.
|
|
5503
|
+
if (this.#deps.callbacks.isOtherDragActive()) {
|
|
5504
|
+
this.cleanup();
|
|
5505
|
+
return;
|
|
5506
|
+
}
|
|
5307
5507
|
// Start the drag - now we can prevent default
|
|
5308
5508
|
event.preventDefault();
|
|
5309
5509
|
this.#deps.callbacks.onDragStart(position);
|
|
@@ -5327,11 +5527,21 @@ class PointerDragHandler {
|
|
|
5327
5527
|
if (!this.#isTracking) {
|
|
5328
5528
|
return;
|
|
5329
5529
|
}
|
|
5530
|
+
// Another finger lifted (or was cancelled) while the tracked one is still down. Touches
|
|
5531
|
+
// without an identifier (malformed synthetic events) can't be told apart: any release ends.
|
|
5532
|
+
if ('touches' in event &&
|
|
5533
|
+
typeof this.#touchId === 'number' &&
|
|
5534
|
+
this.#trackedTouch(event.changedTouches) === null &&
|
|
5535
|
+
this.#trackedTouch(event.touches) !== null) {
|
|
5536
|
+
return;
|
|
5537
|
+
}
|
|
5330
5538
|
// Only prevent default if we were actually dragging
|
|
5331
5539
|
// Otherwise, allow native touch behavior (like scroll momentum) to complete
|
|
5332
5540
|
if (this.#deps.callbacks.isDragging()) {
|
|
5333
5541
|
event.preventDefault();
|
|
5334
|
-
|
|
5542
|
+
// The system took the touch (an incoming call, an OS gesture, too many fingers): the user
|
|
5543
|
+
// never released, so this is a cancel, not a drop
|
|
5544
|
+
this.#deps.callbacks.onDragEnd(event.type === 'touchcancel');
|
|
5335
5545
|
}
|
|
5336
5546
|
this.cleanup();
|
|
5337
5547
|
}
|
|
@@ -5356,17 +5566,43 @@ class PointerDragHandler {
|
|
|
5356
5566
|
}
|
|
5357
5567
|
}
|
|
5358
5568
|
/**
|
|
5359
|
-
*
|
|
5569
|
+
* Cancel the drag (or drop the pending press) when the window loses focus.
|
|
5360
5570
|
*/
|
|
5361
|
-
|
|
5362
|
-
if (
|
|
5363
|
-
|
|
5364
|
-
|
|
5571
|
+
#onFocusLost() {
|
|
5572
|
+
if (!this.#isTracking) {
|
|
5573
|
+
return;
|
|
5574
|
+
}
|
|
5575
|
+
if (this.#deps.callbacks.isDragging()) {
|
|
5576
|
+
this.#deps.callbacks.onDragEnd(true);
|
|
5577
|
+
}
|
|
5578
|
+
this.cleanup();
|
|
5579
|
+
}
|
|
5580
|
+
/**
|
|
5581
|
+
* Treat the page being hidden (tab switch, app switch on mobile) as focus loss.
|
|
5582
|
+
*/
|
|
5583
|
+
#onVisibilityChange() {
|
|
5584
|
+
if (document.visibilityState === 'hidden') {
|
|
5585
|
+
this.#onFocusLost();
|
|
5365
5586
|
}
|
|
5366
|
-
|
|
5587
|
+
}
|
|
5588
|
+
/**
|
|
5589
|
+
* The tracked finger in `touches`, or null. Synthetic touch events (e.g. from test tools) can
|
|
5590
|
+
* leave a list undefined, so a missing list counts as empty.
|
|
5591
|
+
*/
|
|
5592
|
+
#trackedTouch(touches) {
|
|
5593
|
+
return Array.from(touches ?? []).find((touch) => touch.identifier === this.#touchId) ?? null;
|
|
5594
|
+
}
|
|
5595
|
+
/**
|
|
5596
|
+
* The finger that pressed this draggable: of the touches that started with this event, the
|
|
5597
|
+
* one on the draggable (several fingers can land in one touchstart).
|
|
5598
|
+
*/
|
|
5599
|
+
static #pressingTouch(event, element) {
|
|
5600
|
+
// Either list can be undefined on synthetic events
|
|
5601
|
+
const started = Array.from(event.changedTouches ?? []);
|
|
5602
|
+
const onElement = started.find((touch) => touch.target instanceof Node && element.contains(touch.target));
|
|
5603
|
+
return onElement ?? started[0] ?? Array.from(event.touches ?? [])[0] ?? null;
|
|
5367
5604
|
}
|
|
5368
5605
|
}
|
|
5369
|
-
_a = PointerDragHandler;
|
|
5370
5606
|
|
|
5371
5607
|
/**
|
|
5372
5608
|
* Makes an element draggable within the virtual scroll drag-and-drop system.
|
|
@@ -5454,6 +5690,8 @@ class DraggableDirective {
|
|
|
5454
5690
|
}, ...(ngDevMode ? [{ debugName: "isDragging" }] : []));
|
|
5455
5691
|
#keyboardHandler;
|
|
5456
5692
|
#pointerHandler;
|
|
5693
|
+
/** Set by ngOnInit, which creates the handlers */
|
|
5694
|
+
#initialized = false;
|
|
5457
5695
|
/** Cached constraint flag from source droppable */
|
|
5458
5696
|
#constrainToContainer = false;
|
|
5459
5697
|
/** Element whose rect is used for clamping (scroll container or droppable itself) */
|
|
@@ -5516,6 +5754,7 @@ class DraggableDirective {
|
|
|
5516
5754
|
onDragEnd: (cancelled) => this.#endDrag(cancelled),
|
|
5517
5755
|
onPendingChange: (pending) => this.#setPending(pending),
|
|
5518
5756
|
isDragging: () => this.isDragging(),
|
|
5757
|
+
isOtherDragActive: () => this.#dragState.isDragging() && !this.isDragging(),
|
|
5519
5758
|
},
|
|
5520
5759
|
getContext: () => ({
|
|
5521
5760
|
element: this.#elementRef.nativeElement,
|
|
@@ -5526,8 +5765,14 @@ class DraggableDirective {
|
|
|
5526
5765
|
dragDelay: this.dragDelay(),
|
|
5527
5766
|
}),
|
|
5528
5767
|
});
|
|
5768
|
+
this.#initialized = true;
|
|
5529
5769
|
}
|
|
5530
5770
|
ngOnDestroy() {
|
|
5771
|
+
// Destroyed before its first change detection: there are no handlers yet, and its inputs
|
|
5772
|
+
// have no values (reading a bound ID would throw).
|
|
5773
|
+
if (!this.#initialized) {
|
|
5774
|
+
return;
|
|
5775
|
+
}
|
|
5531
5776
|
// If destroyed mid-drag, cancel to avoid stale global state / ongoing RAF loops.
|
|
5532
5777
|
if (this.isDragging()) {
|
|
5533
5778
|
this.#endDrag(true);
|
|
@@ -5549,20 +5794,49 @@ class DraggableDirective {
|
|
|
5549
5794
|
if (this.disabled()) {
|
|
5550
5795
|
return;
|
|
5551
5796
|
}
|
|
5797
|
+
// Space on a control inside the item types into it or clicks it, just as a press on
|
|
5798
|
+
// one never starts a pointer drag.
|
|
5799
|
+
if (this.#isFromNestedControl(event)) {
|
|
5800
|
+
return;
|
|
5801
|
+
}
|
|
5552
5802
|
event.preventDefault();
|
|
5553
|
-
event.stopPropagation(); // Prevent document listener from receiving this event
|
|
5554
5803
|
// If we're in a keyboard drag, Space drops the item
|
|
5555
5804
|
if (this.#keyboardHandler.isActive()) {
|
|
5805
|
+
event.stopPropagation(); // Prevent document listener from receiving this event
|
|
5556
5806
|
this.#keyboardHandler.complete();
|
|
5557
5807
|
return;
|
|
5558
5808
|
}
|
|
5559
|
-
//
|
|
5560
|
-
|
|
5809
|
+
// Another drag is in progress: don't replace it. The key goes on to the document, where a
|
|
5810
|
+
// keyboard drag of another item drops with it.
|
|
5811
|
+
if (this.#dragState.isDragging()) {
|
|
5561
5812
|
return;
|
|
5562
5813
|
}
|
|
5563
|
-
// Start keyboard drag
|
|
5814
|
+
// Start keyboard drag. The document listener it adds must not receive this event.
|
|
5815
|
+
event.stopPropagation();
|
|
5564
5816
|
this.#keyboardHandler.activate();
|
|
5565
5817
|
}
|
|
5818
|
+
/**
|
|
5819
|
+
* Whether the event comes from a control (or from inside a `no-drag` element) nested inside this
|
|
5820
|
+
* draggable. Neither the draggable itself nor a control around it counts, so a
|
|
5821
|
+
* `<button vdndDraggable>` still picks up with Space, and neither does a control that is the
|
|
5822
|
+
* drag handle, such as a button handle. Controls inside the handle do count.
|
|
5823
|
+
*/
|
|
5824
|
+
#isFromNestedControl(event) {
|
|
5825
|
+
const host = this.#elementRef.nativeElement;
|
|
5826
|
+
const target = event.target;
|
|
5827
|
+
if (!(target instanceof Element) || target === host) {
|
|
5828
|
+
return false;
|
|
5829
|
+
}
|
|
5830
|
+
if (findNoDragElement(target, host) !== null) {
|
|
5831
|
+
return true;
|
|
5832
|
+
}
|
|
5833
|
+
const control = target.closest(INTERACTIVE_ELEMENT_SELECTOR);
|
|
5834
|
+
if (control === null || control === host || !host.contains(control)) {
|
|
5835
|
+
return false;
|
|
5836
|
+
}
|
|
5837
|
+
const handleSelector = this.dragHandle();
|
|
5838
|
+
return !handleSelector || !control.matches(handleSelector);
|
|
5839
|
+
}
|
|
5566
5840
|
/**
|
|
5567
5841
|
* Handle Enter key (alternative to Space for dropping during keyboard drag).
|
|
5568
5842
|
*/
|
|
@@ -5701,49 +5975,21 @@ class DraggableDirective {
|
|
|
5701
5975
|
return 0;
|
|
5702
5976
|
}
|
|
5703
5977
|
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
|
-
}
|
|
5978
|
+
// The same measurement that places the placeholder: it knows each container's scroll
|
|
5979
|
+
// element and subtracts space reserved above the rows (contentOffset, page headers).
|
|
5980
|
+
const geometry = this.#dragIndexCalculator.getScrollGeometry(droppableElement, rect.height);
|
|
5981
|
+
const relativeY = rect.top - geometry.rect.top + geometry.scrollTop;
|
|
5734
5982
|
// Try to use registered strategy for accurate offset-based lookup
|
|
5735
5983
|
const droppableId = this.#positionCalculator.getDroppableId(droppableElement);
|
|
5736
5984
|
if (droppableId) {
|
|
5737
5985
|
const strategy = this.#dragIndexCalculator.getStrategyForDroppable(droppableId);
|
|
5738
5986
|
if (strategy) {
|
|
5739
|
-
const relativeY = rect.top - containerRect.top + scrollTop;
|
|
5740
5987
|
return strategy.findIndexAtOffset(relativeY);
|
|
5741
5988
|
}
|
|
5742
5989
|
}
|
|
5743
5990
|
// Preserve the geometry fallback for a virtual container whose strategy is unavailable.
|
|
5744
|
-
if (
|
|
5991
|
+
if (geometry.isVirtual) {
|
|
5745
5992
|
const itemHeight = rect.height || 50;
|
|
5746
|
-
const relativeY = rect.top - containerRect.top + scrollTop;
|
|
5747
5993
|
return Math.round(relativeY / itemHeight);
|
|
5748
5994
|
}
|
|
5749
5995
|
// Non-virtual fallback: derive the logical index from preceding draggable siblings.
|
|
@@ -6160,6 +6406,7 @@ class VirtualForDirective {
|
|
|
6160
6406
|
#scrollContainer = inject(VDND_SCROLL_CONTAINER);
|
|
6161
6407
|
#injector = inject(Injector);
|
|
6162
6408
|
#ngZone = inject(NgZone);
|
|
6409
|
+
#isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
6163
6410
|
#dragState = inject(DragStateService);
|
|
6164
6411
|
#dragIndexCalculator = inject(DragIndexCalculatorService);
|
|
6165
6412
|
/**
|
|
@@ -6168,6 +6415,8 @@ class VirtualForDirective {
|
|
|
6168
6415
|
* so we skip individual absolute positioning.
|
|
6169
6416
|
*/
|
|
6170
6417
|
#viewport = inject(VDND_VIRTUAL_VIEWPORT, { optional: true });
|
|
6418
|
+
/** A vdnd-virtual-viewport, whose rows start contentOffset px down (see #rowsScrollTop) */
|
|
6419
|
+
#offsetRowsViewport = inject(VDND_OFFSET_ROWS_VIEWPORT, { optional: true });
|
|
6171
6420
|
/**
|
|
6172
6421
|
* Optional parent droppable directive.
|
|
6173
6422
|
* When present, droppableId is inherited automatically.
|
|
@@ -6189,7 +6438,7 @@ class VirtualForDirective {
|
|
|
6189
6438
|
#placeholderInDom = false;
|
|
6190
6439
|
/** ResizeObserver for dynamic height measurement */
|
|
6191
6440
|
#resizeObserver = null;
|
|
6192
|
-
/** Map from observed HTMLElement to its trackBy key */
|
|
6441
|
+
/** Map from observed HTMLElement to its trackBy key (reset with each new observer) */
|
|
6193
6442
|
#observedElements = new WeakMap();
|
|
6194
6443
|
// ========== Inputs ==========
|
|
6195
6444
|
/** The array of items to iterate over */
|
|
@@ -6266,8 +6515,21 @@ class VirtualForDirective {
|
|
|
6266
6515
|
const strategy = this.#strategy();
|
|
6267
6516
|
// Read version to subscribe to dynamic height changes
|
|
6268
6517
|
strategy.version();
|
|
6269
|
-
return strategy.getFirstVisibleIndex(this.#
|
|
6518
|
+
return strategy.getFirstVisibleIndex(this.#rowsScrollTop());
|
|
6270
6519
|
}, ...(ngDevMode ? [{ debugName: "#firstVisibleIndex" }] : []));
|
|
6520
|
+
/**
|
|
6521
|
+
* How far the rows are scrolled. A vdnd-virtual-viewport's rows start contentOffset px down its
|
|
6522
|
+
* scroll area, but its scrollTop() is the raw position (scrollBy() relies on it), so subtract the
|
|
6523
|
+
* offset here. vdnd-virtual-content's scrollTop() already excludes its offset.
|
|
6524
|
+
*/
|
|
6525
|
+
#rowsScrollTop() {
|
|
6526
|
+
const scrollTop = this.#scrollContainer.scrollTop();
|
|
6527
|
+
const viewport = this.#viewport;
|
|
6528
|
+
if (viewport === null || viewport !== this.#offsetRowsViewport) {
|
|
6529
|
+
return scrollTop;
|
|
6530
|
+
}
|
|
6531
|
+
return Math.max(0, scrollTop - viewport.contentOffset());
|
|
6532
|
+
}
|
|
6271
6533
|
/** Number of visible items */
|
|
6272
6534
|
#visibleCount = computed(() => {
|
|
6273
6535
|
const height = this.#scrollContainer.containerHeight();
|
|
@@ -6345,12 +6607,33 @@ class VirtualForDirective {
|
|
|
6345
6607
|
strategy.setExcludedIndex(null);
|
|
6346
6608
|
}
|
|
6347
6609
|
});
|
|
6610
|
+
// Measure item heights in dynamic height mode: the directive's own input, or the viewport's
|
|
6611
|
+
// strategy when inherited. Both can change at runtime, and the strategy is replaced when they
|
|
6612
|
+
// do, so this creates, replaces or removes the observer. A new observer reports every
|
|
6613
|
+
// rendered item, so a new strategy gets their heights.
|
|
6614
|
+
effect((onCleanup) => {
|
|
6615
|
+
// Read the strategy first so the effect tracks its replacement even when the input is on
|
|
6616
|
+
const strategy = this.#strategy();
|
|
6617
|
+
const measure = this.vdndVirtualForDynamicItemHeight() || strategy instanceof DynamicHeightStrategy;
|
|
6618
|
+
// No ResizeObserver during server rendering
|
|
6619
|
+
if (!measure || !this.#isBrowser) {
|
|
6620
|
+
return;
|
|
6621
|
+
}
|
|
6622
|
+
untracked(() => this.#startMeasuring());
|
|
6623
|
+
onCleanup(() => this.#stopMeasuring());
|
|
6624
|
+
});
|
|
6348
6625
|
// React to changes and update views
|
|
6349
6626
|
effect(() => {
|
|
6350
6627
|
this.#updateViews();
|
|
6351
6628
|
});
|
|
6352
6629
|
}
|
|
6353
6630
|
ngOnInit() {
|
|
6631
|
+
// Server rendering: there is no ResizeObserver, and elements added outside the template
|
|
6632
|
+
// (spacer, placeholder) would not match the client's DOM during hydration. The browser adds
|
|
6633
|
+
// the spacer on init, so the server markup lacks only the list's scroll height until then.
|
|
6634
|
+
if (!this.#isBrowser) {
|
|
6635
|
+
return;
|
|
6636
|
+
}
|
|
6354
6637
|
// Only create spacer when NOT inside a viewport component
|
|
6355
6638
|
// (viewport provides its own spacer and wrapper positioning)
|
|
6356
6639
|
if (!this.#useViewportPositioning) {
|
|
@@ -6358,12 +6641,6 @@ class VirtualForDirective {
|
|
|
6358
6641
|
}
|
|
6359
6642
|
// Create placeholder element for drag operations
|
|
6360
6643
|
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
6644
|
}
|
|
6368
6645
|
ngOnDestroy() {
|
|
6369
6646
|
this.#viewPool.forEach((view) => view.destroy());
|
|
@@ -6432,6 +6709,19 @@ class VirtualForDirective {
|
|
|
6432
6709
|
spacer.style.height = `${strategy.getTotalHeight(total)}px`;
|
|
6433
6710
|
}, { injector: this.#injector });
|
|
6434
6711
|
}
|
|
6712
|
+
/** Create the ResizeObserver and observe the items already rendered. */
|
|
6713
|
+
#startMeasuring() {
|
|
6714
|
+
this.#setupResizeObserver();
|
|
6715
|
+
for (const [key, view] of this.#activeViews) {
|
|
6716
|
+
this.#observeViewElements(view, key);
|
|
6717
|
+
}
|
|
6718
|
+
}
|
|
6719
|
+
/** Disconnect the ResizeObserver and forget what it observed. */
|
|
6720
|
+
#stopMeasuring() {
|
|
6721
|
+
this.#resizeObserver?.disconnect();
|
|
6722
|
+
this.#resizeObserver = null;
|
|
6723
|
+
this.#observedElements = new WeakMap();
|
|
6724
|
+
}
|
|
6435
6725
|
/**
|
|
6436
6726
|
* Set up ResizeObserver for dynamic height measurement.
|
|
6437
6727
|
*/
|
|
@@ -6867,9 +7157,9 @@ function moveItem(event, lists) {
|
|
|
6867
7157
|
reorderItems(event, sourceList);
|
|
6868
7158
|
return;
|
|
6869
7159
|
}
|
|
6870
|
-
// Cross-list move
|
|
6871
|
-
|
|
6872
|
-
const item =
|
|
7160
|
+
// Cross-list move. Read untracked, as in reorderItems: an effect that applies the drop must
|
|
7161
|
+
// not depend on the lists it changes.
|
|
7162
|
+
const item = untracked(sourceList)[sourceIndex];
|
|
6873
7163
|
if (item === undefined) {
|
|
6874
7164
|
if (isDevMode()) {
|
|
6875
7165
|
console.warn(`[ngx-virtual-dnd] [moveItem] Could not find item at index ${sourceIndex}`);
|
|
@@ -6911,6 +7201,14 @@ function reorderItems(event, list) {
|
|
|
6911
7201
|
if (sourceIndex === destIndex) {
|
|
6912
7202
|
return;
|
|
6913
7203
|
}
|
|
7204
|
+
// The list may have changed during the drag; never splice in a missing item as undefined.
|
|
7205
|
+
// Read untracked: from an effect, a tracked read would make the effect re-run on this reorder.
|
|
7206
|
+
if (untracked(list)[sourceIndex] === undefined) {
|
|
7207
|
+
if (isDevMode()) {
|
|
7208
|
+
console.warn(`[ngx-virtual-dnd] [reorderItems] Could not find item at index ${sourceIndex}`);
|
|
7209
|
+
}
|
|
7210
|
+
return;
|
|
7211
|
+
}
|
|
6914
7212
|
list.update((items) => {
|
|
6915
7213
|
const newItems = [...items];
|
|
6916
7214
|
const [removed] = newItems.splice(sourceIndex, 1);
|