ngx-virtual-dnd 3.0.1 → 3.1.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,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, InjectionToken, computed, effect, Injectable, isDevMode, inject, NgZone, input, ChangeDetectionStrategy, Component, ElementRef, Injector, afterNextRender, viewChild, Directive, output, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
2
+ import { signal, InjectionToken, computed, effect, Injectable, inject, NgZone, isDevMode, input, ChangeDetectionStrategy, Component, ElementRef, Injector, afterNextRender, viewChild, Directive, output, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
3
3
  import { NgTemplateOutlet } from '@angular/common';
4
4
 
5
5
  /**
@@ -457,9 +457,6 @@ const INITIAL_CORE_STATE = {
457
457
  draggedItem: null,
458
458
  sourceDroppableId: null,
459
459
  sourceIndex: null,
460
- activeDroppableId: null,
461
- placeholderId: null,
462
- placeholderIndex: null,
463
460
  grabOffset: null,
464
461
  initialPosition: null,
465
462
  lockAxis: null,
@@ -469,10 +466,12 @@ const INITIAL_CORE_STATE = {
469
466
  * Central service for managing drag-and-drop state.
470
467
  * Uses signals for reactive state management.
471
468
  *
472
- * Architecture: High-frequency fields (`cursorPosition`, `keyboardTargetIndex`)
473
- * are stored in dedicated signals, separate from the core state. This prevents
474
- * 60fps cursor updates from triggering re-evaluation of the 10+ computeds
475
- * that only depend on rarely-changing properties (isDragging, draggedItem, etc.).
469
+ * Architecture: High-frequency fields (`cursorPosition`, `keyboardTargetIndex`,
470
+ * `activeDroppableId`, `placeholderId`, `placeholderIndex`) are stored in dedicated
471
+ * signals, separate from the core state. This prevents 60fps cursor and
472
+ * placeholder updates from rebuilding the core state object or re-evaluating the
473
+ * 8+ computeds that only depend on rarely-changing properties (isDragging,
474
+ * draggedItem, etc.).
476
475
  */
477
476
  class DragStateService {
478
477
  /** Core state signal — contains all rarely-changing properties */
@@ -481,6 +480,12 @@ class DragStateService {
481
480
  #cursorPosition = signal(null, ...(ngDevMode ? [{ debugName: "#cursorPosition" }] : []));
482
481
  /** High-frequency keyboard target index — updated on each arrow key press */
483
482
  #keyboardTargetIndex = signal(null, ...(ngDevMode ? [{ debugName: "#keyboardTargetIndex" }] : []));
483
+ /** ID of the droppable currently being hovered over — changes on droppable crossings */
484
+ #activeDroppableId = signal(null, ...(ngDevMode ? [{ debugName: "#activeDroppableId" }] : []));
485
+ /** ID of the item the placeholder should appear before */
486
+ #placeholderId = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderId" }] : []));
487
+ /** Index where the placeholder should be inserted — updated on each placeholder move */
488
+ #placeholderIndex = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderIndex" }] : []));
484
489
  /** Flag indicating if the last drag was cancelled (not dropped) */
485
490
  #wasCancelled = signal(false, ...(ngDevMode ? [{ debugName: "#wasCancelled" }] : []));
486
491
  /** Whether the last drag was cancelled (for droppable to check before emitting drop) */
@@ -496,11 +501,11 @@ class DragStateService {
496
501
  /** Original index of the dragged item in the source list */
497
502
  sourceIndex = computed(() => this.#state().sourceIndex, ...(ngDevMode ? [{ debugName: "sourceIndex" }] : []));
498
503
  /** ID of the droppable currently being hovered over */
499
- activeDroppableId = computed(() => this.#state().activeDroppableId, ...(ngDevMode ? [{ debugName: "activeDroppableId" }] : []));
504
+ activeDroppableId = this.#activeDroppableId.asReadonly();
500
505
  /** ID of the item the placeholder should appear before */
501
- placeholderId = computed(() => this.#state().placeholderId, ...(ngDevMode ? [{ debugName: "placeholderId" }] : []));
506
+ placeholderId = this.#placeholderId.asReadonly();
502
507
  /** Index where the placeholder should be inserted */
503
- placeholderIndex = computed(() => this.#state().placeholderIndex, ...(ngDevMode ? [{ debugName: "placeholderIndex" }] : []));
508
+ placeholderIndex = this.#placeholderIndex.asReadonly();
504
509
  /** Current cursor position */
505
510
  cursorPosition = this.#cursorPosition.asReadonly();
506
511
  /** Offset from cursor to element top-left (for maintaining grab position) */
@@ -545,14 +550,14 @@ class DragStateService {
545
550
  this.#wasCancelled.set(false);
546
551
  this.#cursorPosition.set(cursorPosition ?? null);
547
552
  this.#keyboardTargetIndex.set(isKeyboardDrag ? (sourceIndex ?? 0) : null);
553
+ this.#activeDroppableId.set(activeDroppableId ?? null);
554
+ this.#placeholderId.set(placeholderId ?? null);
555
+ this.#placeholderIndex.set(placeholderIndex ?? null);
548
556
  this.#state.set({
549
557
  isDragging: true,
550
558
  draggedItem: item,
551
559
  sourceDroppableId: item.droppableId,
552
560
  sourceIndex: sourceIndex ?? null,
553
- activeDroppableId: activeDroppableId ?? null,
554
- placeholderId: placeholderId ?? null,
555
- placeholderIndex: placeholderIndex ?? null,
556
561
  grabOffset: grabOffset ?? null,
557
562
  initialPosition: axisLockPosition ?? cursorPosition ?? null,
558
563
  lockAxis: lockAxis ?? null,
@@ -568,18 +573,12 @@ class DragStateService {
568
573
  }
569
574
  // Write cursor to dedicated high-frequency signal
570
575
  this.#cursorPosition.set(update.cursorPosition);
571
- // Only update core state if droppable/placeholder actually changed
572
- const current = this.#state();
573
- if (current.activeDroppableId !== update.activeDroppableId ||
574
- current.placeholderId !== update.placeholderId ||
575
- current.placeholderIndex !== update.placeholderIndex) {
576
- this.#state.update((state) => ({
577
- ...state,
578
- activeDroppableId: update.activeDroppableId,
579
- placeholderId: update.placeholderId,
580
- placeholderIndex: update.placeholderIndex,
581
- }));
582
- }
576
+ // Each target lives in its own signal, so a placeholder-only frame never
577
+ // rebuilds the core state object or re-fires the rarely-changing computeds.
578
+ // Signals already no-op on identical values, so unconditional sets are safe.
579
+ this.#activeDroppableId.set(update.activeDroppableId);
580
+ this.#placeholderId.set(update.placeholderId);
581
+ this.#placeholderIndex.set(update.placeholderIndex);
583
582
  }
584
583
  /**
585
584
  * Update just the active droppable.
@@ -588,10 +587,7 @@ class DragStateService {
588
587
  if (!this.#state().isDragging) {
589
588
  return;
590
589
  }
591
- this.#state.update((state) => ({
592
- ...state,
593
- activeDroppableId: droppableId,
594
- }));
590
+ this.#activeDroppableId.set(droppableId);
595
591
  }
596
592
  /**
597
593
  * Update just the placeholder position.
@@ -600,18 +596,28 @@ class DragStateService {
600
596
  if (!this.#state().isDragging) {
601
597
  return;
602
598
  }
603
- this.#state.update((state) => ({
604
- ...state,
605
- placeholderId,
606
- }));
599
+ this.#placeholderId.set(placeholderId);
600
+ }
601
+ /**
602
+ * Update only the placeholder signals without touching cursor position or active droppable.
603
+ *
604
+ * Used by the autoscroll scroll-only fast path: when a scroll fires, the cursor has not
605
+ * moved, so only the placeholder index needs recalculation. Skipping the cursor and
606
+ * active-droppable writes avoids spurious re-evaluation of computeds that read those signals.
607
+ */
608
+ updateScrollOnlyPlaceholder(placeholderId, placeholderIndex) {
609
+ if (!this.#state().isDragging) {
610
+ return;
611
+ }
612
+ this.#placeholderId.set(placeholderId);
613
+ this.#placeholderIndex.set(placeholderIndex);
607
614
  }
608
615
  /**
609
616
  * End the drag operation and reset state (normal drop).
610
617
  */
611
618
  endDrag() {
612
619
  this.#wasCancelled.set(false);
613
- this.#cursorPosition.set(null);
614
- this.#keyboardTargetIndex.set(null);
620
+ this.#resetHighFrequencySignals();
615
621
  this.#state.set(INITIAL_CORE_STATE);
616
622
  }
617
623
  /**
@@ -619,15 +625,24 @@ class DragStateService {
619
625
  */
620
626
  cancelDrag() {
621
627
  this.#wasCancelled.set(true);
628
+ this.#resetHighFrequencySignals();
629
+ this.#state.set(INITIAL_CORE_STATE);
630
+ }
631
+ /**
632
+ * Reset the dedicated high-frequency signals to their initial null state.
633
+ */
634
+ #resetHighFrequencySignals() {
622
635
  this.#cursorPosition.set(null);
623
636
  this.#keyboardTargetIndex.set(null);
624
- this.#state.set(INITIAL_CORE_STATE);
637
+ this.#activeDroppableId.set(null);
638
+ this.#placeholderId.set(null);
639
+ this.#placeholderIndex.set(null);
625
640
  }
626
641
  /**
627
642
  * Check if a specific droppable is currently active.
628
643
  */
629
644
  isDroppableActive(droppableId) {
630
- return this.#state().activeDroppableId === droppableId;
645
+ return this.#activeDroppableId() === droppableId;
631
646
  }
632
647
  /**
633
648
  * Get the current state snapshot (for event creation).
@@ -635,6 +650,9 @@ class DragStateService {
635
650
  getStateSnapshot() {
636
651
  return {
637
652
  ...this.#state(),
653
+ activeDroppableId: this.#activeDroppableId(),
654
+ placeholderId: this.#placeholderId(),
655
+ placeholderIndex: this.#placeholderIndex(),
638
656
  cursorPosition: this.#cursorPosition(),
639
657
  keyboardTargetIndex: this.#keyboardTargetIndex(),
640
658
  };
@@ -651,7 +669,7 @@ class DragStateService {
651
669
  // Same-list adjustment: if target is at or after source, add 1
652
670
  // This accounts for the hidden item shifting everything up visually
653
671
  const sourceDroppableId = state.draggedItem?.droppableId;
654
- const activeDroppableId = state.activeDroppableId;
672
+ const activeDroppableId = this.#activeDroppableId();
655
673
  const isSameList = sourceDroppableId === activeDroppableId;
656
674
  const sourceIndex = state.sourceIndex ?? -1;
657
675
  let placeholderIndex = targetIndex;
@@ -659,10 +677,7 @@ class DragStateService {
659
677
  placeholderIndex = targetIndex + 1;
660
678
  }
661
679
  this.#keyboardTargetIndex.set(targetIndex);
662
- this.#state.update((s) => ({
663
- ...s,
664
- placeholderIndex,
665
- }));
680
+ this.#placeholderIndex.set(placeholderIndex);
666
681
  }
667
682
  /**
668
683
  * Update the active droppable during keyboard navigation (for cross-list moves).
@@ -681,11 +696,8 @@ class DragStateService {
681
696
  placeholderIndex = targetIndex + 1;
682
697
  }
683
698
  this.#keyboardTargetIndex.set(targetIndex);
684
- this.#state.update((s) => ({
685
- ...s,
686
- activeDroppableId: droppableId,
687
- placeholderIndex,
688
- }));
699
+ this.#activeDroppableId.set(droppableId);
700
+ this.#placeholderIndex.set(placeholderIndex);
689
701
  }
690
702
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DragStateService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
691
703
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DragStateService, providedIn: 'root' });
@@ -702,6 +714,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
702
714
  * This is the core algorithm that makes virtual scroll + drag-and-drop work together.
703
715
  */
704
716
  class PositionCalculatorService {
717
+ #ngZone = inject(NgZone);
705
718
  /** Data attribute used to identify droppable elements */
706
719
  #DROPPABLE_ID_ATTR = 'data-droppable-id';
707
720
  /** Data attribute used to identify droppable groups */
@@ -712,39 +725,146 @@ class PositionCalculatorService {
712
725
  #MAX_PARENT_TRAVERSAL = 15;
713
726
  /** Reusable result object for getNearEdge (avoids per-frame allocation) */
714
727
  #nearEdgeResult = { top: false, bottom: false, left: false, right: false };
728
+ /** Active drag session rect snapshot, or null when no drag is in progress. */
729
+ #session = null;
730
+ /**
731
+ * Begin a drag session: snapshot the candidate droppables for `groupName` and
732
+ * their rects once, then watch for viewport changes (scroll/resize) that would
733
+ * invalidate those rects. While a session is active, `findDroppableAtPoint`
734
+ * runs as pure geometry against the cached rects instead of `elementFromPoint`.
735
+ *
736
+ * Safe to call repeatedly — a new call replaces any previous session.
737
+ */
738
+ beginDragSession(groupName) {
739
+ this.endDragSession();
740
+ const candidates = this.#queryDroppables(groupName);
741
+ const onViewportChange = () => {
742
+ if (this.#session) {
743
+ this.#session.dirty = true;
744
+ }
745
+ };
746
+ this.#session = {
747
+ groupName,
748
+ candidates,
749
+ rects: candidates.map((el) => el.getBoundingClientRect()),
750
+ dirty: false,
751
+ onViewportChange,
752
+ };
753
+ // Capture-phase scroll catches scrolling on any ancestor scroller (scroll does
754
+ // not bubble); resize covers viewport changes. Both only mark rects dirty —
755
+ // the actual re-read is deferred to the next hit-test.
756
+ if (typeof window !== 'undefined') {
757
+ this.#ngZone.runOutsideAngular(() => {
758
+ window.addEventListener('scroll', onViewportChange, { capture: true, passive: true });
759
+ window.addEventListener('resize', onViewportChange, { passive: true });
760
+ });
761
+ }
762
+ }
763
+ /**
764
+ * End the current drag session and detach viewport listeners.
765
+ * Safe to call when no session is active.
766
+ */
767
+ endDragSession() {
768
+ const session = this.#session;
769
+ if (!session) {
770
+ return;
771
+ }
772
+ this.#session = null;
773
+ if (typeof window !== 'undefined') {
774
+ window.removeEventListener('scroll', session.onViewportChange, { capture: true });
775
+ window.removeEventListener('resize', session.onViewportChange);
776
+ }
777
+ }
778
+ /**
779
+ * Mark the cached droppable rects as stale so they are re-read on the next
780
+ * hit-test. Called explicitly from the autoscroll recalculation path, where the
781
+ * scroll event has not yet fired but the container has already moved.
782
+ */
783
+ invalidateDroppableRects() {
784
+ if (this.#session) {
785
+ this.#session.dirty = true;
786
+ }
787
+ }
715
788
  /**
716
789
  * Find the droppable element at a given point.
717
790
  *
718
- * This works by temporarily hiding the dragged element, then using
719
- * document.elementFromPoint to find what's underneath the cursor.
791
+ * Uses pure geometric hit-testing against snapshotted droppable rects. When a
792
+ * drag session is active (see {@link beginDragSession}) the rects are cached and
793
+ * reused across frames; otherwise a one-shot DOM query is performed. The
794
+ * `draggedElement` parameter is retained for API compatibility but no longer
795
+ * needs to be hidden — geometry does not depend on cursor occlusion.
720
796
  *
721
797
  * @param x - Cursor X coordinate
722
798
  * @param y - Cursor Y coordinate
723
- * @param draggedElement - The element being dragged (will be temporarily hidden)
799
+ * @param _draggedElement - The element being dragged (unused; kept for compatibility)
724
800
  * @param groupName - The drag-and-drop group name to filter by
725
801
  * @returns The droppable element, or null if none found
726
802
  */
727
- findDroppableAtPoint(x, y, draggedElement, groupName) {
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';
803
+ findDroppableAtPoint(x, y, _draggedElement, groupName) {
804
+ const session = this.#session;
805
+ if (session && session.groupName === groupName) {
806
+ if (session.dirty) {
807
+ for (let i = 0; i < session.candidates.length; i++) {
808
+ session.rects[i] = session.candidates[i].getBoundingClientRect();
809
+ }
810
+ session.dirty = false;
811
+ }
812
+ return this.#hitTest(x, y, session.candidates, session.rects);
735
813
  }
736
- try {
737
- const elementAtPoint = document.elementFromPoint(x, y);
738
- if (!elementAtPoint) {
739
- return null;
814
+ // No active session: one-shot geometric query (still avoids elementFromPoint).
815
+ const candidates = this.#queryDroppables(groupName);
816
+ const rects = candidates.map((el) => el.getBoundingClientRect());
817
+ return this.#hitTest(x, y, candidates, rects);
818
+ }
819
+ /**
820
+ * Look up a droppable element by its ID.
821
+ *
822
+ * When a drag session is active, searches the cached candidate list (O(n), avoids a DOM
823
+ * query). Falls back to `document.querySelector` when no session is active.
824
+ *
825
+ * Intended for the autoscroll scroll-only fast path, where the active droppable is already
826
+ * known and only the placeholder index needs recalculation.
827
+ */
828
+ getDroppableById(id) {
829
+ if (this.#session) {
830
+ for (const candidate of this.#session.candidates) {
831
+ if (candidate.getAttribute(this.#DROPPABLE_ID_ATTR) === id) {
832
+ return candidate;
833
+ }
740
834
  }
741
- return this.getDroppableParent(elementAtPoint, groupName);
835
+ return null;
742
836
  }
743
- finally {
744
- if (!isHidden) {
745
- draggedElement.style.pointerEvents = originalPointerEvents;
837
+ if (typeof document === 'undefined') {
838
+ return null;
839
+ }
840
+ const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id;
841
+ return document.querySelector(`[${this.#DROPPABLE_ID_ATTR}="${escaped}"]`);
842
+ }
843
+ /**
844
+ * Query all droppable elements belonging to a group, in document order.
845
+ */
846
+ #queryDroppables(groupName) {
847
+ if (typeof document === 'undefined') {
848
+ return [];
849
+ }
850
+ const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(groupName) : groupName;
851
+ return Array.from(document.querySelectorAll(`[${this.#DROPPABLE_GROUP_ATTR}="${escaped}"]`));
852
+ }
853
+ /**
854
+ * Geometric hit-test: return the last candidate (in document order) whose rect
855
+ * contains the point. "Last in document order" reproduces the painter's-order
856
+ * tie-break that `elementFromPoint` provided for free — a nested or later
857
+ * overlapping droppable is painted on top of an earlier/ancestor one.
858
+ */
859
+ #hitTest(x, y, candidates, rects) {
860
+ let match = null;
861
+ for (let i = 0; i < candidates.length; i++) {
862
+ const r = rects[i];
863
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
864
+ match = candidates[i];
746
865
  }
747
866
  }
867
+ return match;
748
868
  }
749
869
  /**
750
870
  * Find the draggable element at a given point.
@@ -956,6 +1076,123 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
956
1076
  }]
957
1077
  }] });
958
1078
 
1079
+ /**
1080
+ * Single RAF loop that coordinates all drag-frame work in ordered phases:
1081
+ *
1082
+ * 1. Participant phase (READ+WRITE): registered participants run first.
1083
+ * AutoScrollService registers here to perform edge-scroll + immediate
1084
+ * placeholder recalculation in the same synchronous frame — preserving
1085
+ * the Safari synchronous-callback constraint.
1086
+ * 2. Main compute phase (COMPUTE+WRITE): onTick is called with the latest
1087
+ * cursor. Only runs when the cursor has changed (cursorDirty=true), so
1088
+ * autoscroll-only frames (cursor stationary) incur zero hit-test work.
1089
+ *
1090
+ * PointerDragHandler calls `queueCursorUpdate()` instead of managing its own
1091
+ * RAF per pointermove. This coalesces all moves within a frame into one update
1092
+ * and ensures pointer-move work always runs after participant work.
1093
+ */
1094
+ class DragSchedulerService {
1095
+ #ngZone = inject(NgZone);
1096
+ #rafId = null;
1097
+ #onTick = null;
1098
+ /** Latest cursor queued from pointer move events. */
1099
+ #pendingCursor = null;
1100
+ /** True when a new cursor was queued since the last frame. */
1101
+ #cursorDirty = false;
1102
+ /** Tick participants called before the main compute phase each frame. */
1103
+ #participants = [];
1104
+ /**
1105
+ * Start the scheduler RAF loop.
1106
+ * No-op if already running.
1107
+ */
1108
+ start(onTick) {
1109
+ if (this.#rafId !== null) {
1110
+ return;
1111
+ }
1112
+ this.#onTick = onTick;
1113
+ this.#ngZone.runOutsideAngular(() => {
1114
+ this.#scheduleNextTick();
1115
+ });
1116
+ }
1117
+ /**
1118
+ * Stop the scheduler and cancel the pending RAF.
1119
+ * Safe to call when not running.
1120
+ */
1121
+ stop() {
1122
+ if (this.#rafId !== null) {
1123
+ cancelAnimationFrame(this.#rafId);
1124
+ this.#rafId = null;
1125
+ }
1126
+ // Null out onTick so that any in-flight RAF callback (race between cancel
1127
+ // and the browser delivering the frame) is a no-op.
1128
+ this.#onTick = null;
1129
+ this.#pendingCursor = null;
1130
+ this.#cursorDirty = false;
1131
+ }
1132
+ /**
1133
+ * Queue a cursor update from a pointer move event.
1134
+ * Multiple calls between frames are coalesced — only the latest matters.
1135
+ */
1136
+ queueCursorUpdate(position) {
1137
+ this.#pendingCursor = position;
1138
+ this.#cursorDirty = true;
1139
+ }
1140
+ /**
1141
+ * Register a function to be called at the start of each frame, before the main tick.
1142
+ * AutoScrollService registers here to perform edge-scroll within the same frame.
1143
+ * No-op if the same function reference is already registered.
1144
+ */
1145
+ addParticipant(fn) {
1146
+ if (!this.#participants.includes(fn)) {
1147
+ this.#participants.push(fn);
1148
+ }
1149
+ }
1150
+ /**
1151
+ * Unregister a previously added participant.
1152
+ * No-op if the function was not registered.
1153
+ */
1154
+ removeParticipant(fn) {
1155
+ const idx = this.#participants.indexOf(fn);
1156
+ if (idx !== -1) {
1157
+ this.#participants.splice(idx, 1);
1158
+ }
1159
+ }
1160
+ #scheduleNextTick() {
1161
+ this.#rafId = requestAnimationFrame(() => this.#tick());
1162
+ }
1163
+ #tick() {
1164
+ this.#rafId = null;
1165
+ // Guard against the race where stop() was called but the browser already
1166
+ // queued the callback before cancelAnimationFrame() could prevent delivery.
1167
+ if (!this.#onTick) {
1168
+ return;
1169
+ }
1170
+ // Snapshot and reset dirty flag before participants run so that any cursor
1171
+ // update they trigger (theoretically possible in future extensions) is
1172
+ // picked up in the NEXT frame, not folded into this one's compute phase.
1173
+ const cursorDirty = this.#cursorDirty;
1174
+ this.#cursorDirty = false;
1175
+ // Phase 1 — participants (autoscroll scroll + synchronous placeholder update)
1176
+ for (const participant of this.#participants) {
1177
+ participant();
1178
+ }
1179
+ // Phase 2 — main compute (cursor-based hit-test + signal write)
1180
+ this.#onTick(this.#pendingCursor, cursorDirty);
1181
+ // Reschedule only if still active (stop() may have been called by onTick).
1182
+ if (this.#onTick !== null) {
1183
+ this.#scheduleNextTick();
1184
+ }
1185
+ }
1186
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DragSchedulerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1187
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DragSchedulerService, providedIn: 'root' });
1188
+ }
1189
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DragSchedulerService, decorators: [{
1190
+ type: Injectable,
1191
+ args: [{
1192
+ providedIn: 'root',
1193
+ }]
1194
+ }] });
1195
+
959
1196
  /**
960
1197
  * Default auto-scroll configuration.
961
1198
  */
@@ -970,11 +1207,11 @@ const DEFAULT_CONFIG = {
970
1207
  class AutoScrollService {
971
1208
  #dragState = inject(DragStateService);
972
1209
  #positionCalculator = inject(PositionCalculatorService);
973
- #ngZone = inject(NgZone);
1210
+ #scheduler = inject(DragSchedulerService);
974
1211
  /** Currently registered scrollable containers */
975
1212
  #scrollableContainers = new Map();
976
- /** Active animation frame ID */
977
- #animationFrameId = null;
1213
+ /** Bound reference to #participantTick for stable add/remove with the scheduler. */
1214
+ #boundParticipantTick = () => this.#participantTick();
978
1215
  /** Callback to invoke when scrolling occurs (for placeholder recalculation) */
979
1216
  #onScrollCallback = null;
980
1217
  /** Optional cursor position override for edge detection (bypasses DragState read) */
@@ -1007,27 +1244,21 @@ class AutoScrollService {
1007
1244
  }
1008
1245
  /**
1009
1246
  * Start monitoring for auto-scroll.
1010
- * Call this when a drag starts.
1247
+ * Registers this service as a tick participant in DragSchedulerService.
1248
+ * The scheduler drives the RAF loop; this service handles scroll logic each frame.
1011
1249
  * @param onScroll Optional callback to invoke when scrolling occurs (for placeholder recalculation)
1012
1250
  */
1013
1251
  startMonitoring(onScroll) {
1014
- if (this.#animationFrameId !== null) {
1015
- return;
1016
- }
1017
1252
  this.#onScrollCallback = onScroll ?? null;
1018
- this.#ngZone.runOutsideAngular(() => {
1019
- this.#tick();
1020
- });
1253
+ this.#scheduler.addParticipant(this.#boundParticipantTick);
1021
1254
  }
1022
1255
  /**
1023
1256
  * Stop monitoring for auto-scroll.
1257
+ * Removes this service from the scheduler's participant list.
1024
1258
  * Call this when a drag ends.
1025
1259
  */
1026
1260
  stopMonitoring() {
1027
- if (this.#animationFrameId !== null) {
1028
- cancelAnimationFrame(this.#animationFrameId);
1029
- this.#animationFrameId = null;
1030
- }
1261
+ this.#scheduler.removeParticipant(this.#boundParticipantTick);
1031
1262
  this.#onScrollCallback = null;
1032
1263
  this.#cursorOverride = null;
1033
1264
  this.#lastTickCursorX = NaN;
@@ -1047,42 +1278,35 @@ class AutoScrollService {
1047
1278
  this.#cursorOverride = position;
1048
1279
  }
1049
1280
  /**
1050
- * Animation tick - check cursor position and scroll if needed.
1281
+ * Participant tick — called by DragSchedulerService each RAF frame.
1282
+ *
1283
+ * Runs the edge-scroll check and, if a scroll is performed, synchronously
1284
+ * invokes #onScrollCallback (the placeholder recalculator) in the same frame.
1285
+ * No RAF scheduling here — the scheduler owns the loop.
1051
1286
  */
1052
- #tick() {
1287
+ #participantTick() {
1053
1288
  const cursor = this.#cursorOverride ?? this.#dragState.cursorPosition();
1054
- const isDragging = this.#dragState.isDragging();
1055
- // Stop monitoring if drag ended
1056
- if (!isDragging) {
1057
- this.stopMonitoring();
1058
- return;
1059
- }
1060
- // Skip this frame if no cursor position yet, but continue monitoring
1289
+ // No cursor yet — nothing to scroll toward.
1061
1290
  if (!cursor) {
1062
- this.#animationFrameId = requestAnimationFrame(() => this.#tick());
1063
1291
  return;
1064
1292
  }
1065
- // Skip container iteration when cursor hasn't moved and no scrolling is active
1293
+ // Skip the container iteration when cursor hasn't moved and scroll is idle.
1066
1294
  if (cursor.x === this.#lastTickCursorX &&
1067
1295
  cursor.y === this.#lastTickCursorY &&
1068
1296
  !this.isScrolling()) {
1069
- this.#animationFrameId = requestAnimationFrame(() => this.#tick());
1070
1297
  return;
1071
1298
  }
1072
1299
  this.#lastTickCursorX = cursor.x;
1073
1300
  this.#lastTickCursorY = cursor.y;
1074
1301
  let scrollPerformed = false;
1075
- // Check each container
1076
1302
  for (const [id, { element, config }] of this.#scrollableContainers) {
1077
1303
  const rect = element.getBoundingClientRect();
1078
- // Check if cursor is inside this container
1079
1304
  const isInside = this.#positionCalculator.isInsideContainer(cursor, rect);
1080
1305
  if (!isInside) {
1081
1306
  continue;
1082
1307
  }
1083
- // Check edges
1084
1308
  const nearEdge = this.#positionCalculator.getNearEdge(cursor, rect, config.threshold);
1085
- // Calculate scroll direction and speed (reuse object to avoid per-frame allocation)
1309
+ // Reuse the per-frame direction object to avoid allocation.
1086
1310
  const direction = this.#tickDirection;
1087
1311
  direction.x = 0;
1088
1312
  direction.y = 0;
@@ -1103,9 +1327,7 @@ class AutoScrollService {
1103
1327
  direction.x = 1;
1104
1328
  maxDistance = Math.max(maxDistance, config.threshold - (rect.right - cursor.x));
1105
1329
  }
1106
- // If near an edge, start scrolling
1107
1330
  if (direction.x !== 0 || direction.y !== 0) {
1108
- // Calculate speed (accelerate based on distance from edge)
1109
1331
  let speed = config.maxSpeed;
1110
1332
  if (config.accelerate) {
1111
1333
  const distanceRatio = maxDistance / config.threshold;
@@ -1120,14 +1342,12 @@ class AutoScrollService {
1120
1342
  break;
1121
1343
  }
1122
1344
  }
1123
- // Reset scroll state if no scrolling was performed
1124
1345
  if (!scrollPerformed) {
1125
1346
  this.#scrollState.containerId = null;
1126
1347
  this.#scrollState.direction.x = 0;
1127
1348
  this.#scrollState.direction.y = 0;
1128
1349
  this.#scrollState.speed = 0;
1129
1350
  }
1130
- this.#animationFrameId = requestAnimationFrame(() => this.#tick());
1131
1351
  }
1132
1352
  /**
1133
1353
  * Perform the actual scroll operation.
@@ -1194,41 +1414,43 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
1194
1414
  */
1195
1415
  class ElementCloneService {
1196
1416
  /**
1197
- * CSS properties to copy from source to clone.
1198
- * These are the visual properties that affect appearance.
1417
+ * CSS properties to copy from source to clone, pre-stored in kebab-case.
1418
+ * These are the visual properties that affect appearance. Keeping them
1419
+ * kebab-case avoids a camelCase→kebab conversion on every property of every
1420
+ * node during the recursive clone walk (a drag-start hot path).
1199
1421
  */
1200
- #stylesToCopy = [
1422
+ #stylePropsToCopy = [
1201
1423
  'background',
1202
- 'backgroundColor',
1203
- 'backgroundImage',
1424
+ 'background-color',
1425
+ 'background-image',
1204
1426
  'border',
1205
- 'borderRadius',
1206
- 'boxShadow',
1427
+ 'border-radius',
1428
+ 'box-shadow',
1207
1429
  'color',
1208
1430
  'font',
1209
- 'fontFamily',
1210
- 'fontSize',
1211
- 'fontWeight',
1212
- 'lineHeight',
1431
+ 'font-family',
1432
+ 'font-size',
1433
+ 'font-weight',
1434
+ 'line-height',
1213
1435
  'padding',
1214
1436
  'margin',
1215
1437
  'display',
1216
- 'flexDirection',
1217
- 'alignItems',
1218
- 'justifyContent',
1438
+ 'flex-direction',
1439
+ 'align-items',
1440
+ 'justify-content',
1219
1441
  'gap',
1220
- 'textAlign',
1221
- 'textDecoration',
1442
+ 'text-align',
1443
+ 'text-decoration',
1222
1444
  'width',
1223
1445
  'height',
1224
- 'minWidth',
1225
- 'minHeight',
1226
- 'maxWidth',
1227
- 'maxHeight',
1446
+ 'min-width',
1447
+ 'min-height',
1448
+ 'max-width',
1449
+ 'max-height',
1228
1450
  'overflow',
1229
1451
  'opacity',
1230
1452
  'transform',
1231
- 'boxSizing',
1453
+ 'box-sizing',
1232
1454
  ];
1233
1455
  /**
1234
1456
  * Clone an element with its computed styles.
@@ -1250,11 +1472,11 @@ class ElementCloneService {
1250
1472
  */
1251
1473
  #applyComputedStyles(source, target) {
1252
1474
  const computed = window.getComputedStyle(source);
1253
- // Copy essential visual properties
1254
- for (const prop of this.#stylesToCopy) {
1255
- const value = computed.getPropertyValue(this.#camelToKebab(prop));
1475
+ // Copy essential visual properties (keys are already kebab-case)
1476
+ for (const prop of this.#stylePropsToCopy) {
1477
+ const value = computed.getPropertyValue(prop);
1256
1478
  if (value) {
1257
- target.style.setProperty(this.#camelToKebab(prop), value);
1479
+ target.style.setProperty(prop, value);
1258
1480
  }
1259
1481
  }
1260
1482
  // Disable animations and transitions on clone
@@ -1372,12 +1594,6 @@ class ElementCloneService {
1372
1594
  clone.classList.remove('vdnd-draggable-dragging');
1373
1595
  clone.classList.remove('vdnd-draggable-disabled');
1374
1596
  }
1375
- /**
1376
- * Convert camelCase to kebab-case for CSS properties.
1377
- */
1378
- #camelToKebab(str) {
1379
- return str.replace(/([A-Z])/g, '-$1').toLowerCase();
1380
- }
1381
1597
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ElementCloneService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1382
1598
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ElementCloneService, providedIn: 'root' });
1383
1599
  }
@@ -1508,6 +1724,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
1508
1724
  */
1509
1725
  class OverlayContainerService {
1510
1726
  #containerElement = null;
1727
+ /** Number of mounted drag previews currently rendering via a custom template. */
1728
+ #templatePreviewCount = signal(0, ...(ngDevMode ? [{ debugName: "#templatePreviewCount" }] : []));
1729
+ /**
1730
+ * Whether at least one mounted drag preview renders via a custom template.
1731
+ *
1732
+ * When true, DraggableDirective/KeyboardDragHandler skip the expensive
1733
+ * drag-start element clone: the clone would never be shown because the
1734
+ * template takes precedence (see DragPreviewComponent).
1735
+ */
1736
+ hasTemplatePreview = computed(() => this.#templatePreviewCount() > 0, ...(ngDevMode ? [{ debugName: "hasTemplatePreview" }] : []));
1737
+ /**
1738
+ * Register (`active = true`) or unregister (`active = false`) a preview that
1739
+ * renders via a custom template. Calls must be balanced per preview instance.
1740
+ */
1741
+ setTemplatePreviewActive(active) {
1742
+ this.#templatePreviewCount.update((count) => Math.max(0, count + (active ? 1 : -1)));
1743
+ }
1511
1744
  /**
1512
1745
  * Returns the shared overlay container element, lazily creating it on first access.
1513
1746
  * Returns `null` in non-browser environments (SSR).
@@ -2575,6 +2808,8 @@ class DragPreviewComponent {
2575
2808
  cursorOffset = input({ x: 8, y: 8 }, ...(ngDevMode ? [{ debugName: "cursorOffset" }] : []));
2576
2809
  /** Reference to the clone container element (cannot use ES private with viewChild) */
2577
2810
  cloneContainer = viewChild('cloneContainer', ...(ngDevMode ? [{ debugName: "cloneContainer" }] : []));
2811
+ /** Whether this preview's template registration is currently counted (kept balanced). */
2812
+ #templateRegistered = false;
2578
2813
  /** The cloned element from drag state (used when no custom template is provided) */
2579
2814
  clonedElement = computed(() => {
2580
2815
  if (this.previewTemplate()) {
@@ -2591,6 +2826,19 @@ class DragPreviewComponent {
2591
2826
  container.appendChild(this.#elementRef.nativeElement);
2592
2827
  }
2593
2828
  });
2829
+ // Publish whether this preview renders via a custom template so the drag
2830
+ // directives can skip the drag-start element clone when it won't be shown.
2831
+ effect(() => {
2832
+ const hasTemplate = !!this.previewTemplate();
2833
+ if (hasTemplate && !this.#templateRegistered) {
2834
+ this.#overlayContainer.setTemplatePreviewActive(true);
2835
+ this.#templateRegistered = true;
2836
+ }
2837
+ else if (!hasTemplate && this.#templateRegistered) {
2838
+ this.#overlayContainer.setTemplatePreviewActive(false);
2839
+ this.#templateRegistered = false;
2840
+ }
2841
+ });
2594
2842
  // Effect to insert the cloned element into the container
2595
2843
  effect(() => {
2596
2844
  const container = this.cloneContainer()?.nativeElement;
@@ -2607,6 +2855,10 @@ class DragPreviewComponent {
2607
2855
  });
2608
2856
  }
2609
2857
  ngOnDestroy() {
2858
+ if (this.#templateRegistered) {
2859
+ this.#overlayContainer.setTemplatePreviewActive(false);
2860
+ this.#templateRegistered = false;
2861
+ }
2610
2862
  this.#elementRef.nativeElement.remove();
2611
2863
  }
2612
2864
  /** Whether the preview is visible */
@@ -3789,8 +4041,11 @@ class KeyboardDragHandler {
3789
4041
  isSameList: false,
3790
4042
  draggedItemHeight: rect.height,
3791
4043
  });
3792
- // Clone element BEFORE updating drag state
3793
- const clonedElement = this.#deps.elementClone.cloneElement(element);
4044
+ // Clone element BEFORE updating drag state.
4045
+ // Template-first: skip the clone when a template-based preview is mounted.
4046
+ const clonedElement = this.#deps.overlayContainer.hasTemplatePreview()
4047
+ ? undefined
4048
+ : this.#deps.elementClone.cloneElement(element);
3794
4049
  // Start keyboard drag
3795
4050
  this.#deps.keyboardDrag.startKeyboardDrag({
3796
4051
  draggableId: ctx.draggableId,
@@ -3989,8 +4244,6 @@ class PointerDragHandler {
3989
4244
  #boundPointerMove = null;
3990
4245
  #boundPointerUp = null;
3991
4246
  #boundKeyDown = null;
3992
- /** Request animation frame ID for drag updates */
3993
- #rafId = null;
3994
4247
  /** Timer ID for drag delay */
3995
4248
  #delayTimerId = null;
3996
4249
  /** Whether the delay has been satisfied (user held long enough) */
@@ -4077,7 +4330,7 @@ class PointerDragHandler {
4077
4330
  });
4078
4331
  }
4079
4332
  /**
4080
- * Remove listeners + cancel timers + cancel RAF.
4333
+ * Remove listeners + cancel timers.
4081
4334
  * Called after pointer up or when cancelling.
4082
4335
  */
4083
4336
  cleanup() {
@@ -4085,10 +4338,6 @@ class PointerDragHandler {
4085
4338
  this.#startPosition = null;
4086
4339
  this.#deps.callbacks.onPendingChange(false); // Clear pending state on cleanup
4087
4340
  this.#cancelDelayTimer();
4088
- if (this.#rafId !== null) {
4089
- cancelAnimationFrame(this.#rafId);
4090
- this.#rafId = null;
4091
- }
4092
4341
  // Remove event listeners
4093
4342
  if (this.#boundPointerMove) {
4094
4343
  document.removeEventListener('mousemove', this.#boundPointerMove);
@@ -4145,14 +4394,9 @@ class PointerDragHandler {
4145
4394
  if (!this.#deps.callbacks.isDragging()) {
4146
4395
  return;
4147
4396
  }
4148
- // Throttle drag updates with requestAnimationFrame
4149
- if (this.#rafId !== null) {
4150
- cancelAnimationFrame(this.#rafId);
4151
- }
4152
- this.#rafId = requestAnimationFrame(() => {
4153
- this.#deps.callbacks.onDragMove(position);
4154
- this.#rafId = null;
4155
- });
4397
+ // Notify the directive of the new position — it queues this into the scheduler
4398
+ // (DragSchedulerService) which coalesces multiple moves per frame into one RAF tick.
4399
+ this.#deps.callbacks.onDragMove(position);
4156
4400
  }
4157
4401
  /**
4158
4402
  * Handle pointer up (mouse or touch).
@@ -4231,6 +4475,8 @@ class DraggableDirective {
4231
4475
  #elementClone = inject(ElementCloneService);
4232
4476
  #keyboardDrag = inject(KeyboardDragService);
4233
4477
  #dragIndexCalculator = inject(DragIndexCalculatorService);
4478
+ #overlayContainer = inject(OverlayContainerService);
4479
+ #scheduler = inject(DragSchedulerService);
4234
4480
  #ngZone = inject(NgZone);
4235
4481
  #envInjector = inject(EnvironmentInjector);
4236
4482
  #parentGroup = inject(VDND_GROUP_TOKEN, { optional: true });
@@ -4314,6 +4560,7 @@ class DraggableDirective {
4314
4560
  positionCalculator: this.#positionCalculator,
4315
4561
  dragIndexCalculator: this.#dragIndexCalculator,
4316
4562
  elementClone: this.#elementClone,
4563
+ overlayContainer: this.#overlayContainer,
4317
4564
  ngZone: this.#ngZone,
4318
4565
  envInjector: this.#envInjector,
4319
4566
  callbacks: {
@@ -4334,8 +4581,10 @@ class DraggableDirective {
4334
4581
  callbacks: {
4335
4582
  onDragStart: (position) => this.#startDrag(position),
4336
4583
  onDragMove: (position) => {
4584
+ // Record raw position synchronously (needed for autoscroll cursor override)
4585
+ // then queue into the scheduler's RAF loop for coalesced frame-rate processing.
4337
4586
  this.#lastRawPosition = position;
4338
- this.#updateDrag(position);
4587
+ this.#scheduler.queueCursorUpdate(position);
4339
4588
  },
4340
4589
  onDragEnd: (cancelled) => this.#endDrag(cancelled),
4341
4590
  onPendingChange: (pending) => this.#setPending(pending),
@@ -4435,8 +4684,12 @@ class DraggableDirective {
4435
4684
  x: startPos.x - rect.left,
4436
4685
  y: startPos.y - rect.top,
4437
4686
  };
4438
- // Clone element BEFORE updating drag state (which triggers display:none via host binding)
4439
- const clonedElement = this.#elementClone.cloneElement(element);
4687
+ // Clone element BEFORE updating drag state (which triggers display:none via host binding).
4688
+ // Template-first: skip the costly getComputedStyle deep-walk when a template-based
4689
+ // preview is mounted, since that clone would never be rendered.
4690
+ const clonedElement = this.#overlayContainer.hasTemplatePreview()
4691
+ ? undefined
4692
+ : this.#elementClone.cloneElement(element);
4440
4693
  const parentDroppableId = this.#getParentDroppableId();
4441
4694
  // Find droppable and calculate initial placeholder position
4442
4695
  // This fixes the UI glitch by ensuring placeholder is set before the element is hidden
@@ -4446,6 +4699,9 @@ class DraggableDirective {
4446
4699
  this.#pointerHandler.cleanup();
4447
4700
  return;
4448
4701
  }
4702
+ // Snapshot droppable rects for this drag session so subsequent hit-testing is
4703
+ // pure geometry (no elementFromPoint layout flush per pointermove).
4704
+ this.#positionCalculator.beginDragSession(groupName);
4449
4705
  const droppableElement = this.#positionCalculator.findDroppableAtPoint(position.x, position.y, element, groupName);
4450
4706
  // Cache constraint flag and element for clamping during drag
4451
4707
  this.#constrainToContainer =
@@ -4487,7 +4743,14 @@ class DraggableDirective {
4487
4743
  width: rect.width,
4488
4744
  data: this.vdndDraggableData(),
4489
4745
  }, position, grabOffset, lockAxis, activeDroppableId, initialPlaceholderId, initialPlaceholderIndex, sourceIndex, undefined, lockAxis ? startPos : undefined);
4490
- // Start auto-scroll monitoring with a callback to recalculate placeholder
4746
+ // Start the scheduler RAF loop (drives pointer-move updates + autoscroll participant).
4747
+ // onTick is called each frame: when cursor is dirty, run full #updateDrag.
4748
+ this.#scheduler.start((cursor, cursorDirty) => {
4749
+ if (cursorDirty && cursor && this.isDragging()) {
4750
+ this.#updateDrag(cursor);
4751
+ }
4752
+ });
4753
+ // Start auto-scroll monitoring — registers as a scheduler participant.
4491
4754
  this.#autoScroll.startMonitoring(() => this.#recalculatePlaceholder());
4492
4755
  // Emit drag start event
4493
4756
  this.dragStart.emit({
@@ -4554,13 +4817,42 @@ class DraggableDirective {
4554
4817
  }
4555
4818
  /**
4556
4819
  * Recalculate placeholder position (called during auto-scroll).
4820
+ *
4821
+ * Scroll-only fast path: the cursor has not moved — only `scrollTop` changed.
4822
+ * When the active droppable is already known, skip hit-testing entirely and
4823
+ * recalculate only the placeholder index within that droppable. This avoids the
4824
+ * `findDroppableAtPoint` geometry scan + the `invalidateDroppableRects` DOM round-trip
4825
+ * on every autoscroll frame while still recomputing the correct insertion index.
4826
+ *
4827
+ * Falls back to the full `#updateDrag` pipeline when no droppable is active (e.g.
4828
+ * cursor drifted outside all droppables before the scroll tick fired).
4557
4829
  */
4558
4830
  #recalculatePlaceholder() {
4559
4831
  const cursorPosition = this.#dragState.cursorPosition();
4560
4832
  if (!cursorPosition || !this.isDragging()) {
4561
4833
  return;
4562
4834
  }
4563
- // Recalculate placeholder based on current scroll position
4835
+ const activeDroppableId = this.#dragState.activeDroppableId();
4836
+ if (activeDroppableId) {
4837
+ const droppableElement = this.#positionCalculator.getDroppableById(activeDroppableId);
4838
+ if (droppableElement) {
4839
+ const draggedItemHeight = this.#dragState.draggedItem()?.height ?? 50;
4840
+ const indexResult = this.#dragIndexCalculator.calculatePlaceholderIndex({
4841
+ droppableElement,
4842
+ position: cursorPosition,
4843
+ previousPosition: cursorPosition,
4844
+ grabOffset: this.#dragState.grabOffset(),
4845
+ draggedItemHeight,
4846
+ sourceDroppableId: this.#dragState.sourceDroppableId(),
4847
+ sourceIndex: this.#dragState.sourceIndex(),
4848
+ });
4849
+ this.#dragState.updateScrollOnlyPlaceholder(indexResult.placeholderId, indexResult.index);
4850
+ return;
4851
+ }
4852
+ }
4853
+ // Fallback: full recalculation when no active droppable is known.
4854
+ // Mark rects dirty first since autoscroll moved the container before the scroll event fires.
4855
+ this.#positionCalculator.invalidateDroppableRects();
4564
4856
  this.#updateDrag(cursorPosition);
4565
4857
  }
4566
4858
  /**
@@ -4667,9 +4959,11 @@ class DraggableDirective {
4667
4959
  * End the drag operation.
4668
4960
  */
4669
4961
  #endDrag(cancelled) {
4670
- // No ngZone.run() needed - signals work outside zone and effects react automatically
4671
- // Stop auto-scroll monitoring
4962
+ // Stop the scheduler RAF loop first, then remove the autoscroll participant.
4963
+ this.#scheduler.stop();
4672
4964
  this.#autoScroll.stopMonitoring();
4965
+ // Tear down the geometric hit-testing snapshot + its viewport listeners
4966
+ this.#positionCalculator.endDragSession();
4673
4967
  // Clear droppable metadata cache from this drag session
4674
4968
  this.#dragIndexCalculator.clearCache();
4675
4969
  // Reset cached constraint state
@@ -5272,18 +5566,20 @@ class VirtualForDirective {
5272
5566
  const { items, start, end, showPlaceholder, placeholderIndex, shouldKeepDragged, draggedIndex, } = params;
5273
5567
  const trackByFn = this.vdndVirtualForTrackBy();
5274
5568
  const itemsToRender = [];
5569
+ // Track placeholder insertion with a flag instead of re-scanning itemsToRender
5570
+ // with `.some()` on every iteration (which made the build loop O(n²)).
5571
+ let placeholderInserted = false;
5275
5572
  // Build render list for visible range
5276
5573
  for (let i = start; i <= end && i < items.length; i++) {
5277
5574
  // Insert placeholder before item at placeholderIndex
5278
- if (showPlaceholder &&
5279
- placeholderIndex === i &&
5280
- !itemsToRender.some((r) => r.type === 'placeholder')) {
5575
+ if (showPlaceholder && placeholderIndex === i && !placeholderInserted) {
5281
5576
  itemsToRender.push({
5282
5577
  type: 'placeholder',
5283
5578
  key: '__placeholder__',
5284
5579
  context: null,
5285
5580
  visualIndex: placeholderIndex,
5286
5581
  });
5582
+ placeholderInserted = true;
5287
5583
  }
5288
5584
  const item = items[i];
5289
5585
  itemsToRender.push({
@@ -5300,9 +5596,7 @@ class VirtualForDirective {
5300
5596
  });
5301
5597
  }
5302
5598
  // Add placeholder at end if needed
5303
- if (showPlaceholder &&
5304
- placeholderIndex >= items.length &&
5305
- !itemsToRender.some((r) => r.type === 'placeholder')) {
5599
+ if (showPlaceholder && placeholderIndex >= items.length && !placeholderInserted) {
5306
5600
  itemsToRender.push({
5307
5601
  type: 'placeholder',
5308
5602
  key: '__placeholder__',
@@ -5472,14 +5766,11 @@ class VirtualForDirective {
5472
5766
  const container = this.#viewContainer.element.nativeElement.parentElement;
5473
5767
  if (!container)
5474
5768
  return;
5475
- // Find children excluding spacers and placeholder itself
5476
- const children = Array.from(container.children).filter((el) => {
5477
- const element = el;
5478
- return (!element.classList.contains('vdnd-drag-placeholder') &&
5479
- !element.classList.contains('vdnd-virtual-for-spacer') &&
5480
- !element.classList.contains('vdnd-content-spacer'));
5481
- });
5482
- const insertBeforeEl = children[placeholderDomPosition] ?? null;
5769
+ // The placeholder goes immediately before the item view at placeholderDomPosition
5770
+ // (the reconcile pass counts item views only, so this index maps directly to a
5771
+ // ViewContainerRef view). Reading the reference node from that view avoids
5772
+ // re-querying and filtering container.children on every placeholder move.
5773
+ const insertBeforeEl = this.#getViewReferenceNode(placeholderDomPosition);
5483
5774
  if (!this.#placeholderInDom) {
5484
5775
  // First insertion
5485
5776
  if (insertBeforeEl) {
@@ -5503,6 +5794,26 @@ class VirtualForDirective {
5503
5794
  }
5504
5795
  }
5505
5796
  }
5797
+ /**
5798
+ * Resolve the DOM node the placeholder should be inserted before, given the
5799
+ * item-view index produced by the reconcile pass. Returns null when the index
5800
+ * is past the last view (placeholder belongs at the end → append).
5801
+ */
5802
+ #getViewReferenceNode(viewIndex) {
5803
+ if (viewIndex < 0 || viewIndex >= this.#viewContainer.length) {
5804
+ return null;
5805
+ }
5806
+ const view = this.#viewContainer.get(viewIndex);
5807
+ if (!view) {
5808
+ return null;
5809
+ }
5810
+ for (const node of view.rootNodes) {
5811
+ if (node instanceof HTMLElement) {
5812
+ return node;
5813
+ }
5814
+ }
5815
+ return null;
5816
+ }
5506
5817
  /**
5507
5818
  * Trim the view pool to prevent memory bloat, keeping a reasonable buffer.
5508
5819
  */