ngx-virtual-dnd 3.0.0 → 3.0.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.
|
@@ -710,6 +710,8 @@ class PositionCalculatorService {
|
|
|
710
710
|
#DRAGGABLE_ID_ATTR = 'data-draggable-id';
|
|
711
711
|
/** Maximum DOM levels to traverse when looking for parent elements */
|
|
712
712
|
#MAX_PARENT_TRAVERSAL = 15;
|
|
713
|
+
/** Reusable result object for getNearEdge (avoids per-frame allocation) */
|
|
714
|
+
#nearEdgeResult = { top: false, bottom: false, left: false, right: false };
|
|
713
715
|
/**
|
|
714
716
|
* Find the droppable element at a given point.
|
|
715
717
|
*
|
|
@@ -723,9 +725,14 @@ class PositionCalculatorService {
|
|
|
723
725
|
* @returns The droppable element, or null if none found
|
|
724
726
|
*/
|
|
725
727
|
findDroppableAtPoint(x, y, draggedElement, groupName) {
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
draggedElement.
|
|
728
|
+
// Skip pointerEvents toggle when element is already hidden (display: none during active drag).
|
|
729
|
+
// offsetParent is null for hidden elements — toggling style would force a style recalculation.
|
|
730
|
+
const isHidden = draggedElement.offsetParent === null;
|
|
731
|
+
let originalPointerEvents;
|
|
732
|
+
if (!isHidden) {
|
|
733
|
+
originalPointerEvents = draggedElement.style.pointerEvents;
|
|
734
|
+
draggedElement.style.pointerEvents = 'none';
|
|
735
|
+
}
|
|
729
736
|
try {
|
|
730
737
|
const elementAtPoint = document.elementFromPoint(x, y);
|
|
731
738
|
if (!elementAtPoint) {
|
|
@@ -734,8 +741,9 @@ class PositionCalculatorService {
|
|
|
734
741
|
return this.getDroppableParent(elementAtPoint, groupName);
|
|
735
742
|
}
|
|
736
743
|
finally {
|
|
737
|
-
|
|
738
|
-
|
|
744
|
+
if (!isHidden) {
|
|
745
|
+
draggedElement.style.pointerEvents = originalPointerEvents;
|
|
746
|
+
}
|
|
739
747
|
}
|
|
740
748
|
}
|
|
741
749
|
/**
|
|
@@ -747,9 +755,12 @@ class PositionCalculatorService {
|
|
|
747
755
|
* @returns The draggable element, or null if none found
|
|
748
756
|
*/
|
|
749
757
|
findDraggableAtPoint(x, y, draggedElement) {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
758
|
+
const isHidden = draggedElement.offsetParent === null;
|
|
759
|
+
let originalPointerEvents;
|
|
760
|
+
if (!isHidden) {
|
|
761
|
+
originalPointerEvents = draggedElement.style.pointerEvents;
|
|
762
|
+
draggedElement.style.pointerEvents = 'none';
|
|
763
|
+
}
|
|
753
764
|
try {
|
|
754
765
|
const elementAtPoint = document.elementFromPoint(x, y);
|
|
755
766
|
if (!elementAtPoint) {
|
|
@@ -758,7 +769,9 @@ class PositionCalculatorService {
|
|
|
758
769
|
return this.getDraggableParent(elementAtPoint);
|
|
759
770
|
}
|
|
760
771
|
finally {
|
|
761
|
-
|
|
772
|
+
if (!isHidden) {
|
|
773
|
+
draggedElement.style.pointerEvents = originalPointerEvents;
|
|
774
|
+
}
|
|
762
775
|
}
|
|
763
776
|
}
|
|
764
777
|
/**
|
|
@@ -842,12 +855,11 @@ class PositionCalculatorService {
|
|
|
842
855
|
* @returns Object indicating which edges are near
|
|
843
856
|
*/
|
|
844
857
|
getNearEdge(position, containerRect, threshold) {
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
};
|
|
858
|
+
this.#nearEdgeResult.top = position.y - containerRect.top <= threshold;
|
|
859
|
+
this.#nearEdgeResult.bottom = containerRect.bottom - position.y <= threshold;
|
|
860
|
+
this.#nearEdgeResult.left = position.x - containerRect.left <= threshold;
|
|
861
|
+
this.#nearEdgeResult.right = containerRect.right - position.x <= threshold;
|
|
862
|
+
return this.#nearEdgeResult;
|
|
851
863
|
}
|
|
852
864
|
/**
|
|
853
865
|
* Determine if the cursor is inside a container.
|
|
@@ -967,6 +979,11 @@ class AutoScrollService {
|
|
|
967
979
|
#onScrollCallback = null;
|
|
968
980
|
/** Optional cursor position override for edge detection (bypasses DragState read) */
|
|
969
981
|
#cursorOverride = null;
|
|
982
|
+
/** Last tick cursor position for stationary detection */
|
|
983
|
+
#lastTickCursorX = NaN;
|
|
984
|
+
#lastTickCursorY = NaN;
|
|
985
|
+
/** Reusable direction object for tick loop (avoids per-frame allocation) */
|
|
986
|
+
#tickDirection = { x: 0, y: 0 };
|
|
970
987
|
/** Current scroll state */
|
|
971
988
|
#scrollState = {
|
|
972
989
|
containerId: null,
|
|
@@ -1013,11 +1030,12 @@ class AutoScrollService {
|
|
|
1013
1030
|
}
|
|
1014
1031
|
this.#onScrollCallback = null;
|
|
1015
1032
|
this.#cursorOverride = null;
|
|
1016
|
-
this.#
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1033
|
+
this.#lastTickCursorX = NaN;
|
|
1034
|
+
this.#lastTickCursorY = NaN;
|
|
1035
|
+
this.#scrollState.containerId = null;
|
|
1036
|
+
this.#scrollState.direction.x = 0;
|
|
1037
|
+
this.#scrollState.direction.y = 0;
|
|
1038
|
+
this.#scrollState.speed = 0;
|
|
1021
1039
|
}
|
|
1022
1040
|
/**
|
|
1023
1041
|
* Override the cursor position used for edge detection.
|
|
@@ -1044,6 +1062,15 @@ class AutoScrollService {
|
|
|
1044
1062
|
this.#animationFrameId = requestAnimationFrame(() => this.#tick());
|
|
1045
1063
|
return;
|
|
1046
1064
|
}
|
|
1065
|
+
// Skip container iteration when cursor hasn't moved and no scrolling is active
|
|
1066
|
+
if (cursor.x === this.#lastTickCursorX &&
|
|
1067
|
+
cursor.y === this.#lastTickCursorY &&
|
|
1068
|
+
!this.isScrolling()) {
|
|
1069
|
+
this.#animationFrameId = requestAnimationFrame(() => this.#tick());
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
this.#lastTickCursorX = cursor.x;
|
|
1073
|
+
this.#lastTickCursorY = cursor.y;
|
|
1047
1074
|
let scrollPerformed = false;
|
|
1048
1075
|
// Check each container
|
|
1049
1076
|
for (const [id, { element, config }] of this.#scrollableContainers) {
|
|
@@ -1055,8 +1082,10 @@ class AutoScrollService {
|
|
|
1055
1082
|
}
|
|
1056
1083
|
// Check edges
|
|
1057
1084
|
const nearEdge = this.#positionCalculator.getNearEdge(cursor, rect, config.threshold);
|
|
1058
|
-
// Calculate scroll direction and speed
|
|
1059
|
-
const direction =
|
|
1085
|
+
// Calculate scroll direction and speed (reuse object to avoid per-frame allocation)
|
|
1086
|
+
const direction = this.#tickDirection;
|
|
1087
|
+
direction.x = 0;
|
|
1088
|
+
direction.y = 0;
|
|
1060
1089
|
let maxDistance = 0;
|
|
1061
1090
|
if (nearEdge.top) {
|
|
1062
1091
|
direction.y = -1;
|
|
@@ -1082,7 +1111,10 @@ class AutoScrollService {
|
|
|
1082
1111
|
const distanceRatio = maxDistance / config.threshold;
|
|
1083
1112
|
speed = Math.min(config.maxSpeed, Math.max(1, config.maxSpeed * distanceRatio));
|
|
1084
1113
|
}
|
|
1085
|
-
this.#scrollState =
|
|
1114
|
+
this.#scrollState.containerId = id;
|
|
1115
|
+
this.#scrollState.direction.x = direction.x;
|
|
1116
|
+
this.#scrollState.direction.y = direction.y;
|
|
1117
|
+
this.#scrollState.speed = speed;
|
|
1086
1118
|
this.#performScroll(element, direction, speed);
|
|
1087
1119
|
scrollPerformed = true;
|
|
1088
1120
|
break;
|
|
@@ -1090,11 +1122,10 @@ class AutoScrollService {
|
|
|
1090
1122
|
}
|
|
1091
1123
|
// Reset scroll state if no scrolling was performed
|
|
1092
1124
|
if (!scrollPerformed) {
|
|
1093
|
-
this.#scrollState =
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
};
|
|
1125
|
+
this.#scrollState.containerId = null;
|
|
1126
|
+
this.#scrollState.direction.x = 0;
|
|
1127
|
+
this.#scrollState.direction.y = 0;
|
|
1128
|
+
this.#scrollState.speed = 0;
|
|
1098
1129
|
}
|
|
1099
1130
|
this.#animationFrameId = requestAnimationFrame(() => this.#tick());
|
|
1100
1131
|
}
|
|
@@ -1510,6 +1541,8 @@ class DragIndexCalculatorService {
|
|
|
1510
1541
|
#positionCalculator = inject(PositionCalculatorService);
|
|
1511
1542
|
/** Registered strategies by droppable ID */
|
|
1512
1543
|
#strategies = new Map();
|
|
1544
|
+
/** Cached droppable metadata to avoid repeated DOM queries during drag */
|
|
1545
|
+
#droppableCache = new Map();
|
|
1513
1546
|
/**
|
|
1514
1547
|
* Register a virtual scroll strategy for a droppable.
|
|
1515
1548
|
* Used by virtual scroll components to provide dynamic height lookups.
|
|
@@ -1530,60 +1563,83 @@ class DragIndexCalculatorService {
|
|
|
1530
1563
|
getStrategyForDroppable(droppableId) {
|
|
1531
1564
|
return this.#strategies.get(droppableId);
|
|
1532
1565
|
}
|
|
1566
|
+
clearCache() {
|
|
1567
|
+
this.#droppableCache.clear();
|
|
1568
|
+
}
|
|
1569
|
+
#resolveDroppable(droppableElement, draggedItemHeight) {
|
|
1570
|
+
const cached = this.#droppableCache.get(droppableElement);
|
|
1571
|
+
if (cached)
|
|
1572
|
+
return cached;
|
|
1573
|
+
const virtualScrollElement = droppableElement.querySelector('vdnd-virtual-scroll');
|
|
1574
|
+
const virtualContentElement = (droppableElement.matches('vdnd-virtual-content')
|
|
1575
|
+
? droppableElement
|
|
1576
|
+
: droppableElement.closest('vdnd-virtual-content'));
|
|
1577
|
+
let containerType;
|
|
1578
|
+
let scrollContainer;
|
|
1579
|
+
let scrollableParent = null;
|
|
1580
|
+
if (virtualScrollElement) {
|
|
1581
|
+
containerType = 'virtualScroll';
|
|
1582
|
+
scrollContainer = virtualScrollElement;
|
|
1583
|
+
}
|
|
1584
|
+
else if (virtualContentElement) {
|
|
1585
|
+
containerType = 'virtualContent';
|
|
1586
|
+
scrollableParent = virtualContentElement.closest('.vdnd-scrollable');
|
|
1587
|
+
scrollContainer = scrollableParent ?? virtualContentElement;
|
|
1588
|
+
}
|
|
1589
|
+
else {
|
|
1590
|
+
containerType = 'fallback';
|
|
1591
|
+
scrollContainer = droppableElement;
|
|
1592
|
+
}
|
|
1593
|
+
const droppableId = this.#positionCalculator.getDroppableId(droppableElement);
|
|
1594
|
+
const strategy = (droppableId ? this.#strategies.get(droppableId) : null) ?? null;
|
|
1595
|
+
const configuredHeight = virtualScrollElement?.getAttribute('data-item-height') ??
|
|
1596
|
+
virtualContentElement?.getAttribute('data-item-height');
|
|
1597
|
+
const parsedHeight = configuredHeight ? parseFloat(configuredHeight) : Number.NaN;
|
|
1598
|
+
const itemHeight = Number.isFinite(parsedHeight)
|
|
1599
|
+
? parsedHeight
|
|
1600
|
+
: this.#getDraggedItemHeightFallback(draggedItemHeight, 50);
|
|
1601
|
+
const isConstrainedToContainer = droppableElement.hasAttribute('data-constrain-to-container');
|
|
1602
|
+
const entry = {
|
|
1603
|
+
droppableId,
|
|
1604
|
+
containerType,
|
|
1605
|
+
scrollContainer,
|
|
1606
|
+
virtualScrollElement,
|
|
1607
|
+
virtualContentElement,
|
|
1608
|
+
scrollableParent,
|
|
1609
|
+
itemHeight,
|
|
1610
|
+
isConstrainedToContainer,
|
|
1611
|
+
strategy,
|
|
1612
|
+
};
|
|
1613
|
+
this.#droppableCache.set(droppableElement, entry);
|
|
1614
|
+
return entry;
|
|
1615
|
+
}
|
|
1533
1616
|
getTotalItemCount(args) {
|
|
1534
1617
|
return this.#getTotalItemCount(args.droppableElement, args.isSameList, args.draggedItemHeight);
|
|
1535
1618
|
}
|
|
1536
1619
|
calculatePlaceholderIndex(args) {
|
|
1537
1620
|
const { droppableElement, position, grabOffset, draggedItemHeight, sourceDroppableId, sourceIndex, } = args;
|
|
1538
|
-
//
|
|
1539
|
-
const
|
|
1540
|
-
|
|
1541
|
-
? droppableElement
|
|
1542
|
-
: droppableElement.closest('vdnd-virtual-content');
|
|
1543
|
-
let container;
|
|
1621
|
+
// Resolve cached metadata (DOM queries run once per droppable per drag session)
|
|
1622
|
+
const cache = this.#resolveDroppable(droppableElement, draggedItemHeight);
|
|
1623
|
+
// Live reads: getBoundingClientRect() and scrollTop change during scroll
|
|
1544
1624
|
let currentScrollTop;
|
|
1545
1625
|
let rect;
|
|
1546
|
-
if (virtualScroll) {
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
rect = container.getBoundingClientRect();
|
|
1550
|
-
currentScrollTop = container.scrollTop;
|
|
1626
|
+
if (cache.containerType === 'virtualScroll') {
|
|
1627
|
+
rect = cache.scrollContainer.getBoundingClientRect();
|
|
1628
|
+
currentScrollTop = cache.scrollContainer.scrollTop;
|
|
1551
1629
|
}
|
|
1552
|
-
else if (virtualContent) {
|
|
1553
|
-
|
|
1554
|
-
const
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
rect = container.getBoundingClientRect();
|
|
1559
|
-
const contentOffsetAttr = virtualContent.getAttribute('data-content-offset');
|
|
1560
|
-
const contentOffset = contentOffsetAttr ? parseFloat(contentOffsetAttr) : 0;
|
|
1561
|
-
const offsetValue = Number.isFinite(contentOffset) ? contentOffset : 0;
|
|
1562
|
-
currentScrollTop = container.scrollTop - offsetValue;
|
|
1563
|
-
}
|
|
1564
|
-
else {
|
|
1565
|
-
container = virtualContent;
|
|
1566
|
-
rect = container.getBoundingClientRect();
|
|
1567
|
-
currentScrollTop = 0;
|
|
1568
|
-
}
|
|
1630
|
+
else if (cache.containerType === 'virtualContent' && cache.scrollableParent) {
|
|
1631
|
+
rect = cache.scrollContainer.getBoundingClientRect();
|
|
1632
|
+
const contentOffsetAttr = cache.virtualContentElement.getAttribute('data-content-offset');
|
|
1633
|
+
const contentOffset = contentOffsetAttr ? parseFloat(contentOffsetAttr) : 0;
|
|
1634
|
+
const offsetValue = Number.isFinite(contentOffset) ? contentOffset : 0;
|
|
1635
|
+
currentScrollTop = cache.scrollContainer.scrollTop - offsetValue;
|
|
1569
1636
|
}
|
|
1570
1637
|
else {
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
}
|
|
1576
|
-
// Check if a registered strategy exists for this droppable
|
|
1577
|
-
const currentDroppableId = this.#positionCalculator.getDroppableId(droppableElement);
|
|
1578
|
-
const strategy = (currentDroppableId ? this.#strategies.get(currentDroppableId) : null) ?? null;
|
|
1579
|
-
// Prefer configured item height from virtual scroll/content over actual element height.
|
|
1580
|
-
// This prevents drift when actual element height differs from grid spacing.
|
|
1581
|
-
const configuredHeight = virtualScroll?.getAttribute('data-item-height') ??
|
|
1582
|
-
virtualContent?.getAttribute('data-item-height');
|
|
1583
|
-
const parsedHeight = configuredHeight ? parseFloat(configuredHeight) : Number.NaN;
|
|
1584
|
-
const itemHeight = Number.isFinite(parsedHeight)
|
|
1585
|
-
? parsedHeight
|
|
1586
|
-
: this.#getDraggedItemHeightFallback(draggedItemHeight, 50);
|
|
1638
|
+
rect = cache.scrollContainer.getBoundingClientRect();
|
|
1639
|
+
currentScrollTop =
|
|
1640
|
+
cache.containerType === 'virtualContent' ? 0 : cache.scrollContainer.scrollTop;
|
|
1641
|
+
}
|
|
1642
|
+
const { strategy, itemHeight, droppableId: currentDroppableId, isConstrainedToContainer, } = cache;
|
|
1587
1643
|
// Calculate preview center position mathematically
|
|
1588
1644
|
// The preview is positioned at: cursorPosition - grabOffset (see drag-preview.component.ts)
|
|
1589
1645
|
// So preview center = cursorPosition.y - grabOffset.y + previewHeight/2
|
|
@@ -1593,7 +1649,6 @@ class DragIndexCalculatorService {
|
|
|
1593
1649
|
const previewTopY = position.y - (grabOffset?.y ?? 0);
|
|
1594
1650
|
const previewBottomY = previewTopY + previewHeight;
|
|
1595
1651
|
const previewCenterY = previewTopY + previewHeight / 2;
|
|
1596
|
-
const isConstrainedToContainer = droppableElement.hasAttribute('data-constrain-to-container');
|
|
1597
1652
|
// Capped center probe: use preview center but limit how deep the probe reaches.
|
|
1598
1653
|
// The cap prevents a tall preview (e.g. 120px among 60px items) from overshooting
|
|
1599
1654
|
// multiple positions — the center would land 2+ items away, but the cap keeps it
|
|
@@ -1634,8 +1689,15 @@ class DragIndexCalculatorService {
|
|
|
1634
1689
|
placeholderIndex = visualIndex + 1;
|
|
1635
1690
|
}
|
|
1636
1691
|
}
|
|
1637
|
-
// Get total items
|
|
1638
|
-
|
|
1692
|
+
// Get total items using cached strategy to avoid duplicate DOM queries
|
|
1693
|
+
let totalItems;
|
|
1694
|
+
const cachedItemCount = strategy?.getItemCount();
|
|
1695
|
+
if (cachedItemCount !== undefined && Number.isFinite(cachedItemCount)) {
|
|
1696
|
+
totalItems = Math.max(0, cachedItemCount);
|
|
1697
|
+
}
|
|
1698
|
+
else {
|
|
1699
|
+
totalItems = this.#getTotalItemCount(droppableElement, isSameList, draggedItemHeight);
|
|
1700
|
+
}
|
|
1639
1701
|
// Edge detection: allow dropping at the END of the list when cursor is near bottom edge.
|
|
1640
1702
|
// Due to max scroll limits, the math alone can't reach totalItems when the list is longer
|
|
1641
1703
|
// than the viewport. If cursor is in the bottom portion of the container and we're at
|
|
@@ -3800,6 +3862,8 @@ class KeyboardDragHandler {
|
|
|
3800
3862
|
const destinationIndex = this.#deps.dragState.placeholderIndex();
|
|
3801
3863
|
// Remove document listener
|
|
3802
3864
|
this.#cleanupDocumentListener();
|
|
3865
|
+
// Clear droppable metadata cache from this drag session
|
|
3866
|
+
this.#deps.dragIndexCalculator.clearCache();
|
|
3803
3867
|
// Emit drag end event
|
|
3804
3868
|
this.#deps.callbacks.onDragEnd({
|
|
3805
3869
|
draggableId: ctx.draggableId,
|
|
@@ -3821,6 +3885,8 @@ class KeyboardDragHandler {
|
|
|
3821
3885
|
const sourceIndex = this.#deps.dragState.sourceIndex() ?? 0;
|
|
3822
3886
|
// Remove document listener
|
|
3823
3887
|
this.#cleanupDocumentListener();
|
|
3888
|
+
// Clear droppable metadata cache from this drag session
|
|
3889
|
+
this.#deps.dragIndexCalculator.clearCache();
|
|
3824
3890
|
// Emit drag end event
|
|
3825
3891
|
this.#deps.callbacks.onDragEnd({
|
|
3826
3892
|
draggableId: ctx.draggableId,
|
|
@@ -4604,6 +4670,8 @@ class DraggableDirective {
|
|
|
4604
4670
|
// No ngZone.run() needed - signals work outside zone and effects react automatically
|
|
4605
4671
|
// Stop auto-scroll monitoring
|
|
4606
4672
|
this.#autoScroll.stopMonitoring();
|
|
4673
|
+
// Clear droppable metadata cache from this drag session
|
|
4674
|
+
this.#dragIndexCalculator.clearCache();
|
|
4607
4675
|
// Reset cached constraint state
|
|
4608
4676
|
this.#constrainToContainer = false;
|
|
4609
4677
|
this.#constraintElement = null;
|