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
- // Temporarily hide the dragged element to "see through" it
727
- const originalPointerEvents = draggedElement.style.pointerEvents;
728
- draggedElement.style.pointerEvents = 'none';
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
- // Always restore pointer events
738
- draggedElement.style.pointerEvents = originalPointerEvents;
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
- // Temporarily hide the dragged element
751
- const originalPointerEvents = draggedElement.style.pointerEvents;
752
- draggedElement.style.pointerEvents = 'none';
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
- draggedElement.style.pointerEvents = originalPointerEvents;
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
- return {
846
- top: position.y - containerRect.top <= threshold,
847
- bottom: containerRect.bottom - position.y <= threshold,
848
- left: position.x - containerRect.left <= threshold,
849
- right: containerRect.right - position.x <= threshold,
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.#scrollState = {
1017
- containerId: null,
1018
- direction: { x: 0, y: 0 },
1019
- speed: 0,
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 = { x: 0, y: 0 };
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 = { containerId: id, direction, speed };
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
- containerId: null,
1095
- direction: { x: 0, y: 0 },
1096
- speed: 0,
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
- // Get container and measurements - handle both embedded virtual-scroll and page-level scroll
1539
- const virtualScroll = droppableElement.querySelector('vdnd-virtual-scroll');
1540
- const virtualContent = droppableElement.matches('vdnd-virtual-content')
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
- // Standard virtual scroll component - scroll container is the virtual scroll element
1548
- container = virtualScroll;
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
- // Page-level scroll: find scrollable parent and get adjusted scroll
1554
- const scrollableParent = virtualContent.closest('.vdnd-scrollable');
1555
- if (scrollableParent) {
1556
- container = scrollableParent;
1557
- // Use scroll container rect + content offset to avoid stale virtualContent rects.
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
- // Fallback: use droppable element directly
1572
- container = droppableElement;
1573
- rect = container.getBoundingClientRect();
1574
- currentScrollTop = container.scrollTop;
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 for clamping
1638
- const totalItems = this.#getTotalItemCount(droppableElement, isSameList, draggedItemHeight);
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;