ngx-virtual-dnd 3.1.0 → 3.1.2

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, DestroyRef, 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' });
@@ -697,11 +709,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
697
709
  }]
698
710
  }], ctorParameters: () => [] });
699
711
 
712
+ /**
713
+ * Query by an exact attribute value without interpolating consumer data into a CSS selector.
714
+ *
715
+ * CSS attribute selectors need string-specific escaping that differs from `CSS.escape()`.
716
+ * Filtering candidates by attribute presence avoids selector syntax errors for IDs containing
717
+ * quotes, brackets, backslashes, or other selector-sensitive characters.
718
+ */
719
+ function queryByAttribute(root, attributeName, attributeValue) {
720
+ const candidates = root.querySelectorAll(`[${attributeName}]`);
721
+ for (const candidate of candidates) {
722
+ if (candidate.getAttribute(attributeName) === attributeValue) {
723
+ return candidate;
724
+ }
725
+ }
726
+ return null;
727
+ }
728
+ function queryAllByAttribute(root, attributeName, attributeValue) {
729
+ return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
730
+ }
731
+
700
732
  /**
701
733
  * Service for calculating drop positions and finding elements at cursor positions.
702
734
  * This is the core algorithm that makes virtual scroll + drag-and-drop work together.
703
735
  */
704
736
  class PositionCalculatorService {
737
+ #ngZone = inject(NgZone);
705
738
  /** Data attribute used to identify droppable elements */
706
739
  #DROPPABLE_ID_ATTR = 'data-droppable-id';
707
740
  /** Data attribute used to identify droppable groups */
@@ -712,39 +745,144 @@ class PositionCalculatorService {
712
745
  #MAX_PARENT_TRAVERSAL = 15;
713
746
  /** Reusable result object for getNearEdge (avoids per-frame allocation) */
714
747
  #nearEdgeResult = { top: false, bottom: false, left: false, right: false };
748
+ /** Active drag session rect snapshot, or null when no drag is in progress. */
749
+ #session = null;
750
+ /**
751
+ * Begin a drag session: snapshot the candidate droppables for `groupName` and
752
+ * their rects once, then watch for viewport changes (scroll/resize) that would
753
+ * invalidate those rects. While a session is active, `findDroppableAtPoint`
754
+ * runs as pure geometry against the cached rects instead of `elementFromPoint`.
755
+ *
756
+ * Safe to call repeatedly — a new call replaces any previous session.
757
+ */
758
+ beginDragSession(groupName) {
759
+ this.endDragSession();
760
+ const candidates = this.#queryDroppables(groupName);
761
+ const onViewportChange = () => {
762
+ if (this.#session) {
763
+ this.#session.dirty = true;
764
+ }
765
+ };
766
+ this.#session = {
767
+ groupName,
768
+ candidates,
769
+ rects: candidates.map((el) => el.getBoundingClientRect()),
770
+ dirty: false,
771
+ onViewportChange,
772
+ };
773
+ // Capture-phase scroll catches scrolling on any ancestor scroller (scroll does
774
+ // not bubble); resize covers viewport changes. Both only mark rects dirty —
775
+ // the actual re-read is deferred to the next hit-test.
776
+ if (typeof window !== 'undefined') {
777
+ this.#ngZone.runOutsideAngular(() => {
778
+ window.addEventListener('scroll', onViewportChange, { capture: true, passive: true });
779
+ window.addEventListener('resize', onViewportChange, { passive: true });
780
+ });
781
+ }
782
+ }
783
+ /**
784
+ * End the current drag session and detach viewport listeners.
785
+ * Safe to call when no session is active.
786
+ */
787
+ endDragSession() {
788
+ const session = this.#session;
789
+ if (!session) {
790
+ return;
791
+ }
792
+ this.#session = null;
793
+ if (typeof window !== 'undefined') {
794
+ window.removeEventListener('scroll', session.onViewportChange, { capture: true });
795
+ window.removeEventListener('resize', session.onViewportChange);
796
+ }
797
+ }
798
+ /**
799
+ * Mark the cached droppable rects as stale so they are re-read on the next
800
+ * hit-test. Called explicitly from the autoscroll recalculation path, where the
801
+ * scroll event has not yet fired but the container has already moved.
802
+ */
803
+ invalidateDroppableRects() {
804
+ if (this.#session) {
805
+ this.#session.dirty = true;
806
+ }
807
+ }
715
808
  /**
716
809
  * Find the droppable element at a given point.
717
810
  *
718
- * This works by temporarily hiding the dragged element, then using
719
- * document.elementFromPoint to find what's underneath the cursor.
811
+ * Uses pure geometric hit-testing against snapshotted droppable rects. When a
812
+ * drag session is active (see {@link beginDragSession}) the rects are cached and
813
+ * reused across frames; otherwise a one-shot DOM query is performed. The
814
+ * `draggedElement` parameter is retained for API compatibility but no longer
815
+ * needs to be hidden — geometry does not depend on cursor occlusion.
720
816
  *
721
817
  * @param x - Cursor X coordinate
722
818
  * @param y - Cursor Y coordinate
723
- * @param draggedElement - The element being dragged (will be temporarily hidden)
819
+ * @param _draggedElement - The element being dragged (unused; kept for compatibility)
724
820
  * @param groupName - The drag-and-drop group name to filter by
725
821
  * @returns The droppable element, or null if none found
726
822
  */
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';
823
+ findDroppableAtPoint(x, y, _draggedElement, groupName) {
824
+ const session = this.#session;
825
+ if (session && session.groupName === groupName) {
826
+ if (session.dirty) {
827
+ for (let i = 0; i < session.candidates.length; i++) {
828
+ session.rects[i] = session.candidates[i].getBoundingClientRect();
829
+ }
830
+ session.dirty = false;
831
+ }
832
+ return this.#hitTest(x, y, session.candidates, session.rects);
735
833
  }
736
- try {
737
- const elementAtPoint = document.elementFromPoint(x, y);
738
- if (!elementAtPoint) {
739
- return null;
834
+ // No active session: one-shot geometric query (still avoids elementFromPoint).
835
+ const candidates = this.#queryDroppables(groupName);
836
+ const rects = candidates.map((el) => el.getBoundingClientRect());
837
+ return this.#hitTest(x, y, candidates, rects);
838
+ }
839
+ /**
840
+ * Look up a droppable element by its ID.
841
+ *
842
+ * When a drag session is active, searches the cached candidate list (O(n), avoids a DOM
843
+ * query). Falls back to `document.querySelector` when no session is active.
844
+ *
845
+ * Intended for the autoscroll scroll-only fast path, where the active droppable is already
846
+ * known and only the placeholder index needs recalculation.
847
+ */
848
+ getDroppableById(id) {
849
+ if (this.#session) {
850
+ for (const candidate of this.#session.candidates) {
851
+ if (candidate.getAttribute(this.#DROPPABLE_ID_ATTR) === id) {
852
+ return candidate;
853
+ }
740
854
  }
741
- return this.getDroppableParent(elementAtPoint, groupName);
855
+ return null;
742
856
  }
743
- finally {
744
- if (!isHidden) {
745
- draggedElement.style.pointerEvents = originalPointerEvents;
857
+ if (typeof document === 'undefined') {
858
+ return null;
859
+ }
860
+ return queryByAttribute(document, this.#DROPPABLE_ID_ATTR, id);
861
+ }
862
+ /**
863
+ * Query all droppable elements belonging to a group, in document order.
864
+ */
865
+ #queryDroppables(groupName) {
866
+ if (typeof document === 'undefined') {
867
+ return [];
868
+ }
869
+ return queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
870
+ }
871
+ /**
872
+ * Geometric hit-test: return the last candidate (in document order) whose rect
873
+ * contains the point. "Last in document order" reproduces the painter's-order
874
+ * tie-break that `elementFromPoint` provided for free — a nested or later
875
+ * overlapping droppable is painted on top of an earlier/ancestor one.
876
+ */
877
+ #hitTest(x, y, candidates, rects) {
878
+ let match = null;
879
+ for (let i = 0; i < candidates.length; i++) {
880
+ const r = rects[i];
881
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
882
+ match = candidates[i];
746
883
  }
747
884
  }
885
+ return match;
748
886
  }
749
887
  /**
750
888
  * Find the draggable element at a given point.
@@ -881,7 +1019,7 @@ class PositionCalculatorService {
881
1019
  */
882
1020
  findAdjacentDroppable(currentDroppableId, direction, groupName) {
883
1021
  // Find all droppables in the same group
884
- const allDroppables = document.querySelectorAll(`[${this.#DROPPABLE_GROUP_ATTR}="${groupName}"]`);
1022
+ const allDroppables = queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
885
1023
  if (allDroppables.length <= 1) {
886
1024
  return null;
887
1025
  }
@@ -956,6 +1094,123 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
956
1094
  }]
957
1095
  }] });
958
1096
 
1097
+ /**
1098
+ * Single RAF loop that coordinates all drag-frame work in ordered phases:
1099
+ *
1100
+ * 1. Participant phase (READ+WRITE): registered participants run first.
1101
+ * AutoScrollService registers here to perform edge-scroll + immediate
1102
+ * placeholder recalculation in the same synchronous frame — preserving
1103
+ * the Safari synchronous-callback constraint.
1104
+ * 2. Main compute phase (COMPUTE+WRITE): onTick is called with the latest
1105
+ * cursor. Only runs when the cursor has changed (cursorDirty=true), so
1106
+ * autoscroll-only frames (cursor stationary) incur zero hit-test work.
1107
+ *
1108
+ * PointerDragHandler calls `queueCursorUpdate()` instead of managing its own
1109
+ * RAF per pointermove. This coalesces all moves within a frame into one update
1110
+ * and ensures pointer-move work always runs after participant work.
1111
+ */
1112
+ class DragSchedulerService {
1113
+ #ngZone = inject(NgZone);
1114
+ #rafId = null;
1115
+ #onTick = null;
1116
+ /** Latest cursor queued from pointer move events. */
1117
+ #pendingCursor = null;
1118
+ /** True when a new cursor was queued since the last frame. */
1119
+ #cursorDirty = false;
1120
+ /** Tick participants called before the main compute phase each frame. */
1121
+ #participants = [];
1122
+ /**
1123
+ * Start the scheduler RAF loop.
1124
+ * No-op if already running.
1125
+ */
1126
+ start(onTick) {
1127
+ if (this.#rafId !== null) {
1128
+ return;
1129
+ }
1130
+ this.#onTick = onTick;
1131
+ this.#ngZone.runOutsideAngular(() => {
1132
+ this.#scheduleNextTick();
1133
+ });
1134
+ }
1135
+ /**
1136
+ * Stop the scheduler and cancel the pending RAF.
1137
+ * Safe to call when not running.
1138
+ */
1139
+ stop() {
1140
+ if (this.#rafId !== null) {
1141
+ cancelAnimationFrame(this.#rafId);
1142
+ this.#rafId = null;
1143
+ }
1144
+ // Null out onTick so that any in-flight RAF callback (race between cancel
1145
+ // and the browser delivering the frame) is a no-op.
1146
+ this.#onTick = null;
1147
+ this.#pendingCursor = null;
1148
+ this.#cursorDirty = false;
1149
+ }
1150
+ /**
1151
+ * Queue a cursor update from a pointer move event.
1152
+ * Multiple calls between frames are coalesced — only the latest matters.
1153
+ */
1154
+ queueCursorUpdate(position) {
1155
+ this.#pendingCursor = position;
1156
+ this.#cursorDirty = true;
1157
+ }
1158
+ /**
1159
+ * Register a function to be called at the start of each frame, before the main tick.
1160
+ * AutoScrollService registers here to perform edge-scroll within the same frame.
1161
+ * No-op if the same function reference is already registered.
1162
+ */
1163
+ addParticipant(fn) {
1164
+ if (!this.#participants.includes(fn)) {
1165
+ this.#participants.push(fn);
1166
+ }
1167
+ }
1168
+ /**
1169
+ * Unregister a previously added participant.
1170
+ * No-op if the function was not registered.
1171
+ */
1172
+ removeParticipant(fn) {
1173
+ const idx = this.#participants.indexOf(fn);
1174
+ if (idx !== -1) {
1175
+ this.#participants.splice(idx, 1);
1176
+ }
1177
+ }
1178
+ #scheduleNextTick() {
1179
+ this.#rafId = requestAnimationFrame(() => this.#tick());
1180
+ }
1181
+ #tick() {
1182
+ this.#rafId = null;
1183
+ // Guard against the race where stop() was called but the browser already
1184
+ // queued the callback before cancelAnimationFrame() could prevent delivery.
1185
+ if (!this.#onTick) {
1186
+ return;
1187
+ }
1188
+ // Snapshot and reset dirty flag before participants run so that any cursor
1189
+ // update they trigger (theoretically possible in future extensions) is
1190
+ // picked up in the NEXT frame, not folded into this one's compute phase.
1191
+ const cursorDirty = this.#cursorDirty;
1192
+ this.#cursorDirty = false;
1193
+ // Phase 1 — participants (autoscroll scroll + synchronous placeholder update)
1194
+ for (const participant of this.#participants) {
1195
+ participant();
1196
+ }
1197
+ // Phase 2 — main compute (cursor-based hit-test + signal write)
1198
+ this.#onTick(this.#pendingCursor, cursorDirty);
1199
+ // Reschedule only if still active (stop() may have been called by onTick).
1200
+ if (this.#onTick !== null) {
1201
+ this.#scheduleNextTick();
1202
+ }
1203
+ }
1204
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DragSchedulerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1205
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DragSchedulerService, providedIn: 'root' });
1206
+ }
1207
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DragSchedulerService, decorators: [{
1208
+ type: Injectable,
1209
+ args: [{
1210
+ providedIn: 'root',
1211
+ }]
1212
+ }] });
1213
+
959
1214
  /**
960
1215
  * Default auto-scroll configuration.
961
1216
  */
@@ -970,11 +1225,11 @@ const DEFAULT_CONFIG = {
970
1225
  class AutoScrollService {
971
1226
  #dragState = inject(DragStateService);
972
1227
  #positionCalculator = inject(PositionCalculatorService);
973
- #ngZone = inject(NgZone);
1228
+ #scheduler = inject(DragSchedulerService);
974
1229
  /** Currently registered scrollable containers */
975
1230
  #scrollableContainers = new Map();
976
- /** Active animation frame ID */
977
- #animationFrameId = null;
1231
+ /** Bound reference to #participantTick for stable add/remove with the scheduler. */
1232
+ #boundParticipantTick = () => this.#participantTick();
978
1233
  /** Callback to invoke when scrolling occurs (for placeholder recalculation) */
979
1234
  #onScrollCallback = null;
980
1235
  /** Optional cursor position override for edge detection (bypasses DragState read) */
@@ -1007,27 +1262,21 @@ class AutoScrollService {
1007
1262
  }
1008
1263
  /**
1009
1264
  * Start monitoring for auto-scroll.
1010
- * Call this when a drag starts.
1265
+ * Registers this service as a tick participant in DragSchedulerService.
1266
+ * The scheduler drives the RAF loop; this service handles scroll logic each frame.
1011
1267
  * @param onScroll Optional callback to invoke when scrolling occurs (for placeholder recalculation)
1012
1268
  */
1013
1269
  startMonitoring(onScroll) {
1014
- if (this.#animationFrameId !== null) {
1015
- return;
1016
- }
1017
1270
  this.#onScrollCallback = onScroll ?? null;
1018
- this.#ngZone.runOutsideAngular(() => {
1019
- this.#tick();
1020
- });
1271
+ this.#scheduler.addParticipant(this.#boundParticipantTick);
1021
1272
  }
1022
1273
  /**
1023
1274
  * Stop monitoring for auto-scroll.
1275
+ * Removes this service from the scheduler's participant list.
1024
1276
  * Call this when a drag ends.
1025
1277
  */
1026
1278
  stopMonitoring() {
1027
- if (this.#animationFrameId !== null) {
1028
- cancelAnimationFrame(this.#animationFrameId);
1029
- this.#animationFrameId = null;
1030
- }
1279
+ this.#scheduler.removeParticipant(this.#boundParticipantTick);
1031
1280
  this.#onScrollCallback = null;
1032
1281
  this.#cursorOverride = null;
1033
1282
  this.#lastTickCursorX = NaN;
@@ -1047,42 +1296,35 @@ class AutoScrollService {
1047
1296
  this.#cursorOverride = position;
1048
1297
  }
1049
1298
  /**
1050
- * Animation tick - check cursor position and scroll if needed.
1299
+ * Participant tick — called by DragSchedulerService each RAF frame.
1300
+ *
1301
+ * Runs the edge-scroll check and, if a scroll is performed, synchronously
1302
+ * invokes #onScrollCallback (the placeholder recalculator) in the same frame.
1303
+ * No RAF scheduling here — the scheduler owns the loop.
1051
1304
  */
1052
- #tick() {
1305
+ #participantTick() {
1053
1306
  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
1307
+ // No cursor yet — nothing to scroll toward.
1061
1308
  if (!cursor) {
1062
- this.#animationFrameId = requestAnimationFrame(() => this.#tick());
1063
1309
  return;
1064
1310
  }
1065
- // Skip container iteration when cursor hasn't moved and no scrolling is active
1311
+ // Skip the container iteration when cursor hasn't moved and scroll is idle.
1066
1312
  if (cursor.x === this.#lastTickCursorX &&
1067
1313
  cursor.y === this.#lastTickCursorY &&
1068
1314
  !this.isScrolling()) {
1069
- this.#animationFrameId = requestAnimationFrame(() => this.#tick());
1070
1315
  return;
1071
1316
  }
1072
1317
  this.#lastTickCursorX = cursor.x;
1073
1318
  this.#lastTickCursorY = cursor.y;
1074
1319
  let scrollPerformed = false;
1075
- // Check each container
1076
1320
  for (const [id, { element, config }] of this.#scrollableContainers) {
1077
1321
  const rect = element.getBoundingClientRect();
1078
- // Check if cursor is inside this container
1079
1322
  const isInside = this.#positionCalculator.isInsideContainer(cursor, rect);
1080
1323
  if (!isInside) {
1081
1324
  continue;
1082
1325
  }
1083
- // Check edges
1084
1326
  const nearEdge = this.#positionCalculator.getNearEdge(cursor, rect, config.threshold);
1085
- // Calculate scroll direction and speed (reuse object to avoid per-frame allocation)
1327
+ // Reuse the per-frame direction object to avoid allocation.
1086
1328
  const direction = this.#tickDirection;
1087
1329
  direction.x = 0;
1088
1330
  direction.y = 0;
@@ -1103,9 +1345,7 @@ class AutoScrollService {
1103
1345
  direction.x = 1;
1104
1346
  maxDistance = Math.max(maxDistance, config.threshold - (rect.right - cursor.x));
1105
1347
  }
1106
- // If near an edge, start scrolling
1107
1348
  if (direction.x !== 0 || direction.y !== 0) {
1108
- // Calculate speed (accelerate based on distance from edge)
1109
1349
  let speed = config.maxSpeed;
1110
1350
  if (config.accelerate) {
1111
1351
  const distanceRatio = maxDistance / config.threshold;
@@ -1120,14 +1360,12 @@ class AutoScrollService {
1120
1360
  break;
1121
1361
  }
1122
1362
  }
1123
- // Reset scroll state if no scrolling was performed
1124
1363
  if (!scrollPerformed) {
1125
1364
  this.#scrollState.containerId = null;
1126
1365
  this.#scrollState.direction.x = 0;
1127
1366
  this.#scrollState.direction.y = 0;
1128
1367
  this.#scrollState.speed = 0;
1129
1368
  }
1130
- this.#animationFrameId = requestAnimationFrame(() => this.#tick());
1131
1369
  }
1132
1370
  /**
1133
1371
  * Perform the actual scroll operation.
@@ -1194,41 +1432,43 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
1194
1432
  */
1195
1433
  class ElementCloneService {
1196
1434
  /**
1197
- * CSS properties to copy from source to clone.
1198
- * These are the visual properties that affect appearance.
1435
+ * CSS properties to copy from source to clone, pre-stored in kebab-case.
1436
+ * These are the visual properties that affect appearance. Keeping them
1437
+ * kebab-case avoids a camelCase→kebab conversion on every property of every
1438
+ * node during the recursive clone walk (a drag-start hot path).
1199
1439
  */
1200
- #stylesToCopy = [
1440
+ #stylePropsToCopy = [
1201
1441
  'background',
1202
- 'backgroundColor',
1203
- 'backgroundImage',
1442
+ 'background-color',
1443
+ 'background-image',
1204
1444
  'border',
1205
- 'borderRadius',
1206
- 'boxShadow',
1445
+ 'border-radius',
1446
+ 'box-shadow',
1207
1447
  'color',
1208
1448
  'font',
1209
- 'fontFamily',
1210
- 'fontSize',
1211
- 'fontWeight',
1212
- 'lineHeight',
1449
+ 'font-family',
1450
+ 'font-size',
1451
+ 'font-weight',
1452
+ 'line-height',
1213
1453
  'padding',
1214
1454
  'margin',
1215
1455
  'display',
1216
- 'flexDirection',
1217
- 'alignItems',
1218
- 'justifyContent',
1456
+ 'flex-direction',
1457
+ 'align-items',
1458
+ 'justify-content',
1219
1459
  'gap',
1220
- 'textAlign',
1221
- 'textDecoration',
1460
+ 'text-align',
1461
+ 'text-decoration',
1222
1462
  'width',
1223
1463
  'height',
1224
- 'minWidth',
1225
- 'minHeight',
1226
- 'maxWidth',
1227
- 'maxHeight',
1464
+ 'min-width',
1465
+ 'min-height',
1466
+ 'max-width',
1467
+ 'max-height',
1228
1468
  'overflow',
1229
1469
  'opacity',
1230
1470
  'transform',
1231
- 'boxSizing',
1471
+ 'box-sizing',
1232
1472
  ];
1233
1473
  /**
1234
1474
  * Clone an element with its computed styles.
@@ -1250,11 +1490,11 @@ class ElementCloneService {
1250
1490
  */
1251
1491
  #applyComputedStyles(source, target) {
1252
1492
  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));
1493
+ // Copy essential visual properties (keys are already kebab-case)
1494
+ for (const prop of this.#stylePropsToCopy) {
1495
+ const value = computed.getPropertyValue(prop);
1256
1496
  if (value) {
1257
- target.style.setProperty(this.#camelToKebab(prop), value);
1497
+ target.style.setProperty(prop, value);
1258
1498
  }
1259
1499
  }
1260
1500
  // Disable animations and transitions on clone
@@ -1372,12 +1612,6 @@ class ElementCloneService {
1372
1612
  clone.classList.remove('vdnd-draggable-dragging');
1373
1613
  clone.classList.remove('vdnd-draggable-disabled');
1374
1614
  }
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
1615
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ElementCloneService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1382
1616
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ElementCloneService, providedIn: 'root' });
1383
1617
  }
@@ -1508,6 +1742,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
1508
1742
  */
1509
1743
  class OverlayContainerService {
1510
1744
  #containerElement = null;
1745
+ /** Number of mounted drag previews currently rendering via a custom template. */
1746
+ #templatePreviewCount = signal(0, ...(ngDevMode ? [{ debugName: "#templatePreviewCount" }] : []));
1747
+ /**
1748
+ * Whether at least one mounted drag preview renders via a custom template.
1749
+ *
1750
+ * When true, DraggableDirective/KeyboardDragHandler skip the expensive
1751
+ * drag-start element clone: the clone would never be shown because the
1752
+ * template takes precedence (see DragPreviewComponent).
1753
+ */
1754
+ hasTemplatePreview = computed(() => this.#templatePreviewCount() > 0, ...(ngDevMode ? [{ debugName: "hasTemplatePreview" }] : []));
1755
+ /**
1756
+ * Register (`active = true`) or unregister (`active = false`) a preview that
1757
+ * renders via a custom template. Calls must be balanced per preview instance.
1758
+ */
1759
+ setTemplatePreviewActive(active) {
1760
+ this.#templatePreviewCount.update((count) => Math.max(0, count + (active ? 1 : -1)));
1761
+ }
1511
1762
  /**
1512
1763
  * Returns the shared overlay container element, lazily creating it on first access.
1513
1764
  * Returns `null` in non-browser environments (SSR).
@@ -1912,6 +2163,34 @@ function bindResizeObserverHeightSignal(options) {
1912
2163
  };
1913
2164
  }
1914
2165
 
2166
+ function createAutoScrollRegistration(options) {
2167
+ const destroyRef = inject(DestroyRef);
2168
+ let registeredId = null;
2169
+ const unregisterCurrent = () => {
2170
+ if (registeredId === null) {
2171
+ return;
2172
+ }
2173
+ options.autoScrollService.unregisterContainer(registeredId);
2174
+ registeredId = null;
2175
+ };
2176
+ effect(() => {
2177
+ const enabled = options.enabled();
2178
+ const id = options.getId();
2179
+ const config = options.config();
2180
+ const canRegister = options.canRegister?.() ?? true;
2181
+ if (!enabled || !canRegister) {
2182
+ unregisterCurrent();
2183
+ return;
2184
+ }
2185
+ if (registeredId !== null && registeredId !== id) {
2186
+ unregisterCurrent();
2187
+ }
2188
+ options.autoScrollService.registerContainer(id, options.getElement(), config);
2189
+ registeredId = id;
2190
+ });
2191
+ destroyRef.onDestroy(unregisterCurrent);
2192
+ }
2193
+
1915
2194
  /**
1916
2195
  * A virtual scroll container that only renders visible items.
1917
2196
  *
@@ -2230,6 +2509,13 @@ class VirtualScrollContainerComponent {
2230
2509
  /** Track previous total height to detect if we were at bottom before drag ended */
2231
2510
  #previousTotalHeight = 0;
2232
2511
  constructor() {
2512
+ createAutoScrollRegistration({
2513
+ autoScrollService: this.#autoScrollService,
2514
+ getElement: () => this.#elementRef.nativeElement,
2515
+ getId: () => this.scrollContainerId() ?? this.#generatedScrollId,
2516
+ enabled: () => this.autoScrollEnabled(),
2517
+ config: () => this.autoScrollConfig(),
2518
+ });
2233
2519
  // Keep strategy item keys in sync
2234
2520
  effect(() => {
2235
2521
  const items = this.items();
@@ -2343,11 +2629,6 @@ class VirtualScrollContainerComponent {
2343
2629
  });
2344
2630
  }
2345
2631
  ngOnInit() {
2346
- // Register with auto-scroll service
2347
- if (this.autoScrollEnabled()) {
2348
- const id = this.scrollContainerId() ?? this.#generatedScrollId;
2349
- this.#autoScrollService.registerContainer(id, this.#elementRef.nativeElement, this.autoScrollConfig());
2350
- }
2351
2632
  // Set up ResizeObserver for dynamic height measurement
2352
2633
  if (this.dynamicItemHeight()) {
2353
2634
  this.#setupItemResizeObserver();
@@ -2382,9 +2663,6 @@ class VirtualScrollContainerComponent {
2382
2663
  }
2383
2664
  this.#observedElementByKey.clear();
2384
2665
  this.#itemResizeObserver?.disconnect();
2385
- // Unregister from auto-scroll service
2386
- const id = this.scrollContainerId() ?? this.#generatedScrollId;
2387
- this.#autoScrollService.unregisterContainer(id);
2388
2666
  }
2389
2667
  /**
2390
2668
  * Set up ResizeObserver for measuring individual item heights.
@@ -2429,8 +2707,8 @@ class VirtualScrollContainerComponent {
2429
2707
  if (entry.type !== 'item' || !entry.data)
2430
2708
  continue;
2431
2709
  const key = idFn(entry.data);
2432
- // Find the DOM element for this item by its data-draggable-id
2433
- const el = wrapper.querySelector(`[data-draggable-id="${key}"]`);
2710
+ // Find the DOM element for this item by its data-draggable-id.
2711
+ const el = queryByAttribute(wrapper, 'data-draggable-id', key);
2434
2712
  if (el) {
2435
2713
  nextObservedByKey.set(key, el);
2436
2714
  }
@@ -2575,6 +2853,8 @@ class DragPreviewComponent {
2575
2853
  cursorOffset = input({ x: 8, y: 8 }, ...(ngDevMode ? [{ debugName: "cursorOffset" }] : []));
2576
2854
  /** Reference to the clone container element (cannot use ES private with viewChild) */
2577
2855
  cloneContainer = viewChild('cloneContainer', ...(ngDevMode ? [{ debugName: "cloneContainer" }] : []));
2856
+ /** Whether this preview's template registration is currently counted (kept balanced). */
2857
+ #templateRegistered = false;
2578
2858
  /** The cloned element from drag state (used when no custom template is provided) */
2579
2859
  clonedElement = computed(() => {
2580
2860
  if (this.previewTemplate()) {
@@ -2591,6 +2871,19 @@ class DragPreviewComponent {
2591
2871
  container.appendChild(this.#elementRef.nativeElement);
2592
2872
  }
2593
2873
  });
2874
+ // Publish whether this preview renders via a custom template so the drag
2875
+ // directives can skip the drag-start element clone when it won't be shown.
2876
+ effect(() => {
2877
+ const hasTemplate = !!this.previewTemplate();
2878
+ if (hasTemplate && !this.#templateRegistered) {
2879
+ this.#overlayContainer.setTemplatePreviewActive(true);
2880
+ this.#templateRegistered = true;
2881
+ }
2882
+ else if (!hasTemplate && this.#templateRegistered) {
2883
+ this.#overlayContainer.setTemplatePreviewActive(false);
2884
+ this.#templateRegistered = false;
2885
+ }
2886
+ });
2594
2887
  // Effect to insert the cloned element into the container
2595
2888
  effect(() => {
2596
2889
  const container = this.cloneContainer()?.nativeElement;
@@ -2607,6 +2900,10 @@ class DragPreviewComponent {
2607
2900
  });
2608
2901
  }
2609
2902
  ngOnDestroy() {
2903
+ if (this.#templateRegistered) {
2904
+ this.#overlayContainer.setTemplatePreviewActive(false);
2905
+ this.#templateRegistered = false;
2906
+ }
2610
2907
  this.#elementRef.nativeElement.remove();
2611
2908
  }
2612
2909
  /** Whether the preview is visible */
@@ -2932,17 +3229,6 @@ class DroppableDirective {
2932
3229
  #wasActive = false;
2933
3230
  /** Cached state for handling drop (since state is cleared before effect fires) */
2934
3231
  #cachedDragState = null;
2935
- ngOnInit() {
2936
- // Without a group, this droppable can't participate in DnD (fail gracefully).
2937
- if (!this.effectiveGroup()) {
2938
- return;
2939
- }
2940
- // Register with auto-scroll service only if this element is actually scrollable
2941
- // (i.e., has overflow: auto/scroll and content taller than container)
2942
- if (this.autoScrollEnabled() && this.#isScrollable()) {
2943
- this.#autoScroll.registerContainer(this.vdndDroppable(), this.#elementRef.nativeElement, this.autoScrollConfig());
2944
- }
2945
- }
2946
3232
  /**
2947
3233
  * Check if this element is actually scrollable.
2948
3234
  */
@@ -2963,6 +3249,14 @@ class DroppableDirective {
2963
3249
  return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
2964
3250
  }
2965
3251
  constructor() {
3252
+ createAutoScrollRegistration({
3253
+ autoScrollService: this.#autoScroll,
3254
+ getElement: () => this.#elementRef.nativeElement,
3255
+ getId: () => this.vdndDroppable(),
3256
+ enabled: () => this.autoScrollEnabled(),
3257
+ config: () => this.autoScrollConfig(),
3258
+ canRegister: () => Boolean(this.effectiveGroup()) && this.#isScrollable(),
3259
+ });
2966
3260
  // React to state changes and handle drop events
2967
3261
  effect(() => {
2968
3262
  const active = this.isActive();
@@ -3043,7 +3337,7 @@ class DroppableDirective {
3043
3337
  */
3044
3338
  #getItemIndex(draggableId, droppableId) {
3045
3339
  // Find all draggables in the source droppable
3046
- const droppable = document.querySelector(`[data-droppable-id="${droppableId}"]`);
3340
+ const droppable = queryByAttribute(document, 'data-droppable-id', droppableId);
3047
3341
  if (!droppable) {
3048
3342
  return 0;
3049
3343
  }
@@ -3353,6 +3647,7 @@ class VirtualViewportComponent {
3353
3647
  contentTransform = computed(() => {
3354
3648
  const startIndex = this.#renderStartIndex();
3355
3649
  const s = this.#strategy();
3650
+ s.version();
3356
3651
  const offset = s.getOffsetForIndex(startIndex);
3357
3652
  return `translateY(${offset}px)`;
3358
3653
  }, ...(ngDevMode ? [{ debugName: "contentTransform" }] : []));
@@ -3385,15 +3680,22 @@ class VirtualViewportComponent {
3385
3680
  this.scrollTo({ top: newPosition });
3386
3681
  }
3387
3682
  // ========== Lifecycle ==========
3683
+ constructor() {
3684
+ createAutoScrollRegistration({
3685
+ autoScrollService: this.#autoScrollService,
3686
+ getElement: () => this.nativeElement,
3687
+ getId: () => this.scrollContainerId() ?? this.#generatedScrollId,
3688
+ enabled: () => this.autoScrollEnabled(),
3689
+ config: () => this.autoScrollConfig(),
3690
+ });
3691
+ }
3388
3692
  ngOnInit() {
3389
3693
  this.#setupScrollListener();
3390
3694
  this.#setupResizeObserver();
3391
- this.#registerAutoScroll();
3392
3695
  }
3393
3696
  ngOnDestroy() {
3394
3697
  this.#scrollCleanup?.();
3395
3698
  this.#resizeCleanup?.();
3396
- this.#unregisterAutoScroll();
3397
3699
  }
3398
3700
  // ========== Private Methods ==========
3399
3701
  /** Minimum scroll delta (px) to trigger signal update */
@@ -3414,16 +3716,6 @@ class VirtualViewportComponent {
3414
3716
  minDeltaPx: 1,
3415
3717
  });
3416
3718
  }
3417
- #registerAutoScroll() {
3418
- if (this.autoScrollEnabled()) {
3419
- const id = this.scrollContainerId() ?? this.#generatedScrollId;
3420
- this.#autoScrollService.registerContainer(id, this.nativeElement, this.autoScrollConfig());
3421
- }
3422
- }
3423
- #unregisterAutoScroll() {
3424
- const id = this.scrollContainerId() ?? this.#generatedScrollId;
3425
- this.#autoScrollService.unregisterContainer(id);
3426
- }
3427
3719
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualViewportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3428
3720
  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: [
3429
3721
  { provide: VDND_VIRTUAL_VIEWPORT, useExisting: VirtualViewportComponent },
@@ -3470,7 +3762,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3470
3762
  <ng-content></ng-content>
3471
3763
  </div>
3472
3764
  `, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-viewport-spacer{position:absolute;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-viewport-content{position:absolute;left:0;right:0;will-change:transform}\n"] }]
3473
- }], propDecorators: { itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], contentOffset: [{ type: i0.Input, args: [{ isSignal: true, alias: "contentOffset", required: false }] }], scrollContainerId: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollContainerId", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }] } });
3765
+ }], ctorParameters: () => [], propDecorators: { itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], contentOffset: [{ type: i0.Input, args: [{ isSignal: true, alias: "contentOffset", required: false }] }], scrollContainerId: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollContainerId", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }] } });
3474
3766
 
3475
3767
  /**
3476
3768
  * Marks an element as the header content for a `vdnd-virtual-content` component.
@@ -3623,6 +3915,7 @@ class VirtualContentComponent {
3623
3915
  contentTransform = computed(() => {
3624
3916
  const startIndex = this.#renderStartIndex();
3625
3917
  const s = this.#strategy();
3918
+ s.version();
3626
3919
  const offset = s.getOffsetForIndex(startIndex);
3627
3920
  return `translateY(${offset}px)`;
3628
3921
  }, ...(ngDevMode ? [{ debugName: "contentTransform" }] : []));
@@ -3789,8 +4082,11 @@ class KeyboardDragHandler {
3789
4082
  isSameList: false,
3790
4083
  draggedItemHeight: rect.height,
3791
4084
  });
3792
- // Clone element BEFORE updating drag state
3793
- const clonedElement = this.#deps.elementClone.cloneElement(element);
4085
+ // Clone element BEFORE updating drag state.
4086
+ // Template-first: skip the clone when a template-based preview is mounted.
4087
+ const clonedElement = this.#deps.overlayContainer.hasTemplatePreview()
4088
+ ? undefined
4089
+ : this.#deps.elementClone.cloneElement(element);
3794
4090
  // Start keyboard drag
3795
4091
  this.#deps.keyboardDrag.startKeyboardDrag({
3796
4092
  draggableId: ctx.draggableId,
@@ -3919,13 +4215,14 @@ class KeyboardDragHandler {
3919
4215
  // (the directive may be destroyed during cross-list moves)
3920
4216
  const destinationDroppableId = this.#deps.dragState.activeDroppableId();
3921
4217
  afterNextRender(() => {
3922
- const element = document.querySelector(`[data-draggable-id="${draggableId}"]`);
4218
+ const element = queryByAttribute(document, 'data-draggable-id', draggableId);
3923
4219
  if (element) {
3924
4220
  element.focus();
3925
4221
  }
3926
4222
  else if (destinationDroppableId) {
3927
4223
  // Fallback: focus the first draggable in the destination container
3928
- const firstDraggable = document.querySelector(`[data-droppable-id="${destinationDroppableId}"] [data-draggable-id]`);
4224
+ const destination = queryByAttribute(document, 'data-droppable-id', destinationDroppableId);
4225
+ const firstDraggable = destination?.querySelector('[data-draggable-id]');
3929
4226
  firstDraggable?.focus();
3930
4227
  }
3931
4228
  }, { injector: this.#deps.envInjector });
@@ -3989,8 +4286,6 @@ class PointerDragHandler {
3989
4286
  #boundPointerMove = null;
3990
4287
  #boundPointerUp = null;
3991
4288
  #boundKeyDown = null;
3992
- /** Request animation frame ID for drag updates */
3993
- #rafId = null;
3994
4289
  /** Timer ID for drag delay */
3995
4290
  #delayTimerId = null;
3996
4291
  /** Whether the delay has been satisfied (user held long enough) */
@@ -4077,7 +4372,7 @@ class PointerDragHandler {
4077
4372
  });
4078
4373
  }
4079
4374
  /**
4080
- * Remove listeners + cancel timers + cancel RAF.
4375
+ * Remove listeners + cancel timers.
4081
4376
  * Called after pointer up or when cancelling.
4082
4377
  */
4083
4378
  cleanup() {
@@ -4085,10 +4380,6 @@ class PointerDragHandler {
4085
4380
  this.#startPosition = null;
4086
4381
  this.#deps.callbacks.onPendingChange(false); // Clear pending state on cleanup
4087
4382
  this.#cancelDelayTimer();
4088
- if (this.#rafId !== null) {
4089
- cancelAnimationFrame(this.#rafId);
4090
- this.#rafId = null;
4091
- }
4092
4383
  // Remove event listeners
4093
4384
  if (this.#boundPointerMove) {
4094
4385
  document.removeEventListener('mousemove', this.#boundPointerMove);
@@ -4145,14 +4436,9 @@ class PointerDragHandler {
4145
4436
  if (!this.#deps.callbacks.isDragging()) {
4146
4437
  return;
4147
4438
  }
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
- });
4439
+ // Notify the directive of the new position — it queues this into the scheduler
4440
+ // (DragSchedulerService) which coalesces multiple moves per frame into one RAF tick.
4441
+ this.#deps.callbacks.onDragMove(position);
4156
4442
  }
4157
4443
  /**
4158
4444
  * Handle pointer up (mouse or touch).
@@ -4231,6 +4517,8 @@ class DraggableDirective {
4231
4517
  #elementClone = inject(ElementCloneService);
4232
4518
  #keyboardDrag = inject(KeyboardDragService);
4233
4519
  #dragIndexCalculator = inject(DragIndexCalculatorService);
4520
+ #overlayContainer = inject(OverlayContainerService);
4521
+ #scheduler = inject(DragSchedulerService);
4234
4522
  #ngZone = inject(NgZone);
4235
4523
  #envInjector = inject(EnvironmentInjector);
4236
4524
  #parentGroup = inject(VDND_GROUP_TOKEN, { optional: true });
@@ -4314,6 +4602,7 @@ class DraggableDirective {
4314
4602
  positionCalculator: this.#positionCalculator,
4315
4603
  dragIndexCalculator: this.#dragIndexCalculator,
4316
4604
  elementClone: this.#elementClone,
4605
+ overlayContainer: this.#overlayContainer,
4317
4606
  ngZone: this.#ngZone,
4318
4607
  envInjector: this.#envInjector,
4319
4608
  callbacks: {
@@ -4334,8 +4623,10 @@ class DraggableDirective {
4334
4623
  callbacks: {
4335
4624
  onDragStart: (position) => this.#startDrag(position),
4336
4625
  onDragMove: (position) => {
4626
+ // Record raw position synchronously (needed for autoscroll cursor override)
4627
+ // then queue into the scheduler's RAF loop for coalesced frame-rate processing.
4337
4628
  this.#lastRawPosition = position;
4338
- this.#updateDrag(position);
4629
+ this.#scheduler.queueCursorUpdate(position);
4339
4630
  },
4340
4631
  onDragEnd: (cancelled) => this.#endDrag(cancelled),
4341
4632
  onPendingChange: (pending) => this.#setPending(pending),
@@ -4435,8 +4726,12 @@ class DraggableDirective {
4435
4726
  x: startPos.x - rect.left,
4436
4727
  y: startPos.y - rect.top,
4437
4728
  };
4438
- // Clone element BEFORE updating drag state (which triggers display:none via host binding)
4439
- const clonedElement = this.#elementClone.cloneElement(element);
4729
+ // Clone element BEFORE updating drag state (which triggers display:none via host binding).
4730
+ // Template-first: skip the costly getComputedStyle deep-walk when a template-based
4731
+ // preview is mounted, since that clone would never be rendered.
4732
+ const clonedElement = this.#overlayContainer.hasTemplatePreview()
4733
+ ? undefined
4734
+ : this.#elementClone.cloneElement(element);
4440
4735
  const parentDroppableId = this.#getParentDroppableId();
4441
4736
  // Find droppable and calculate initial placeholder position
4442
4737
  // This fixes the UI glitch by ensuring placeholder is set before the element is hidden
@@ -4446,6 +4741,9 @@ class DraggableDirective {
4446
4741
  this.#pointerHandler.cleanup();
4447
4742
  return;
4448
4743
  }
4744
+ // Snapshot droppable rects for this drag session so subsequent hit-testing is
4745
+ // pure geometry (no elementFromPoint layout flush per pointermove).
4746
+ this.#positionCalculator.beginDragSession(groupName);
4449
4747
  const droppableElement = this.#positionCalculator.findDroppableAtPoint(position.x, position.y, element, groupName);
4450
4748
  // Cache constraint flag and element for clamping during drag
4451
4749
  this.#constrainToContainer =
@@ -4487,7 +4785,14 @@ class DraggableDirective {
4487
4785
  width: rect.width,
4488
4786
  data: this.vdndDraggableData(),
4489
4787
  }, position, grabOffset, lockAxis, activeDroppableId, initialPlaceholderId, initialPlaceholderIndex, sourceIndex, undefined, lockAxis ? startPos : undefined);
4490
- // Start auto-scroll monitoring with a callback to recalculate placeholder
4788
+ // Start the scheduler RAF loop (drives pointer-move updates + autoscroll participant).
4789
+ // onTick is called each frame: when cursor is dirty, run full #updateDrag.
4790
+ this.#scheduler.start((cursor, cursorDirty) => {
4791
+ if (cursorDirty && cursor && this.isDragging()) {
4792
+ this.#updateDrag(cursor);
4793
+ }
4794
+ });
4795
+ // Start auto-scroll monitoring — registers as a scheduler participant.
4491
4796
  this.#autoScroll.startMonitoring(() => this.#recalculatePlaceholder());
4492
4797
  // Emit drag start event
4493
4798
  this.dragStart.emit({
@@ -4554,13 +4859,42 @@ class DraggableDirective {
4554
4859
  }
4555
4860
  /**
4556
4861
  * Recalculate placeholder position (called during auto-scroll).
4862
+ *
4863
+ * Scroll-only fast path: the cursor has not moved — only `scrollTop` changed.
4864
+ * When the active droppable is already known, skip hit-testing entirely and
4865
+ * recalculate only the placeholder index within that droppable. This avoids the
4866
+ * `findDroppableAtPoint` geometry scan + the `invalidateDroppableRects` DOM round-trip
4867
+ * on every autoscroll frame while still recomputing the correct insertion index.
4868
+ *
4869
+ * Falls back to the full `#updateDrag` pipeline when no droppable is active (e.g.
4870
+ * cursor drifted outside all droppables before the scroll tick fired).
4557
4871
  */
4558
4872
  #recalculatePlaceholder() {
4559
4873
  const cursorPosition = this.#dragState.cursorPosition();
4560
4874
  if (!cursorPosition || !this.isDragging()) {
4561
4875
  return;
4562
4876
  }
4563
- // Recalculate placeholder based on current scroll position
4877
+ const activeDroppableId = this.#dragState.activeDroppableId();
4878
+ if (activeDroppableId) {
4879
+ const droppableElement = this.#positionCalculator.getDroppableById(activeDroppableId);
4880
+ if (droppableElement) {
4881
+ const draggedItemHeight = this.#dragState.draggedItem()?.height ?? 50;
4882
+ const indexResult = this.#dragIndexCalculator.calculatePlaceholderIndex({
4883
+ droppableElement,
4884
+ position: cursorPosition,
4885
+ previousPosition: cursorPosition,
4886
+ grabOffset: this.#dragState.grabOffset(),
4887
+ draggedItemHeight,
4888
+ sourceDroppableId: this.#dragState.sourceDroppableId(),
4889
+ sourceIndex: this.#dragState.sourceIndex(),
4890
+ });
4891
+ this.#dragState.updateScrollOnlyPlaceholder(indexResult.placeholderId, indexResult.index);
4892
+ return;
4893
+ }
4894
+ }
4895
+ // Fallback: full recalculation when no active droppable is known.
4896
+ // Mark rects dirty first since autoscroll moved the container before the scroll event fires.
4897
+ this.#positionCalculator.invalidateDroppableRects();
4564
4898
  this.#updateDrag(cursorPosition);
4565
4899
  }
4566
4900
  /**
@@ -4667,9 +5001,11 @@ class DraggableDirective {
4667
5001
  * End the drag operation.
4668
5002
  */
4669
5003
  #endDrag(cancelled) {
4670
- // No ngZone.run() needed - signals work outside zone and effects react automatically
4671
- // Stop auto-scroll monitoring
5004
+ // Stop the scheduler RAF loop first, then remove the autoscroll participant.
5005
+ this.#scheduler.stop();
4672
5006
  this.#autoScroll.stopMonitoring();
5007
+ // Tear down the geometric hit-testing snapshot + its viewport listeners
5008
+ this.#positionCalculator.endDragSession();
4673
5009
  // Clear droppable metadata cache from this drag session
4674
5010
  this.#dragIndexCalculator.clearCache();
4675
5011
  // Reset cached constraint state
@@ -4807,6 +5143,15 @@ class ScrollableDirective {
4807
5143
  /** Auto-scroll configuration */
4808
5144
  autoScrollConfig = input({}, ...(ngDevMode ? [{ debugName: "autoScrollConfig" }] : []));
4809
5145
  // ========== VdndScrollContainer Implementation ==========
5146
+ constructor() {
5147
+ createAutoScrollRegistration({
5148
+ autoScrollService: this.#autoScrollService,
5149
+ getElement: () => this.nativeElement,
5150
+ getId: () => this.scrollContainerId() ?? this.#generatedScrollId,
5151
+ enabled: () => this.autoScrollEnabled(),
5152
+ config: () => this.autoScrollConfig(),
5153
+ });
5154
+ }
4810
5155
  get nativeElement() {
4811
5156
  return this.#elementRef.nativeElement;
4812
5157
  }
@@ -4830,12 +5175,10 @@ class ScrollableDirective {
4830
5175
  ngOnInit() {
4831
5176
  this.#setupScrollListener();
4832
5177
  this.#setupResizeObserver();
4833
- this.#registerAutoScroll();
4834
5178
  }
4835
5179
  ngOnDestroy() {
4836
5180
  this.#scrollCleanup?.();
4837
5181
  this.#resizeCleanup?.();
4838
- this.#unregisterAutoScroll();
4839
5182
  }
4840
5183
  // ========== Private Methods ==========
4841
5184
  /** Minimum scroll delta (px) to trigger signal update */
@@ -4856,16 +5199,6 @@ class ScrollableDirective {
4856
5199
  minDeltaPx: 1,
4857
5200
  });
4858
5201
  }
4859
- #registerAutoScroll() {
4860
- if (this.autoScrollEnabled()) {
4861
- const id = this.scrollContainerId() ?? this.#generatedScrollId;
4862
- this.#autoScrollService.registerContainer(id, this.nativeElement, this.autoScrollConfig());
4863
- }
4864
- }
4865
- #unregisterAutoScroll() {
4866
- const id = this.scrollContainerId() ?? this.#generatedScrollId;
4867
- this.#autoScrollService.unregisterContainer(id);
4868
- }
4869
5202
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ScrollableDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
4870
5203
  static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: ScrollableDirective, isStandalone: true, selector: "[vdndScrollable]", inputs: { 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: { properties: { "style.overflow-anchor": "\"none\"" }, classAttribute: "vdnd-scrollable" }, providers: [{ provide: VDND_SCROLL_CONTAINER, useExisting: ScrollableDirective }], ngImport: i0 });
4871
5204
  }
@@ -4879,7 +5212,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4879
5212
  '[style.overflow-anchor]': '"none"',
4880
5213
  },
4881
5214
  }]
4882
- }], propDecorators: { scrollContainerId: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollContainerId", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }] } });
5215
+ }], ctorParameters: () => [], propDecorators: { scrollContainerId: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollContainerId", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }] } });
4883
5216
 
4884
5217
  /**
4885
5218
  * A structural directive for virtual scrolling within custom scroll containers.
@@ -5272,18 +5605,20 @@ class VirtualForDirective {
5272
5605
  const { items, start, end, showPlaceholder, placeholderIndex, shouldKeepDragged, draggedIndex, } = params;
5273
5606
  const trackByFn = this.vdndVirtualForTrackBy();
5274
5607
  const itemsToRender = [];
5608
+ // Track placeholder insertion with a flag instead of re-scanning itemsToRender
5609
+ // with `.some()` on every iteration (which made the build loop O(n²)).
5610
+ let placeholderInserted = false;
5275
5611
  // Build render list for visible range
5276
5612
  for (let i = start; i <= end && i < items.length; i++) {
5277
5613
  // Insert placeholder before item at placeholderIndex
5278
- if (showPlaceholder &&
5279
- placeholderIndex === i &&
5280
- !itemsToRender.some((r) => r.type === 'placeholder')) {
5614
+ if (showPlaceholder && placeholderIndex === i && !placeholderInserted) {
5281
5615
  itemsToRender.push({
5282
5616
  type: 'placeholder',
5283
5617
  key: '__placeholder__',
5284
5618
  context: null,
5285
5619
  visualIndex: placeholderIndex,
5286
5620
  });
5621
+ placeholderInserted = true;
5287
5622
  }
5288
5623
  const item = items[i];
5289
5624
  itemsToRender.push({
@@ -5300,9 +5635,7 @@ class VirtualForDirective {
5300
5635
  });
5301
5636
  }
5302
5637
  // Add placeholder at end if needed
5303
- if (showPlaceholder &&
5304
- placeholderIndex >= items.length &&
5305
- !itemsToRender.some((r) => r.type === 'placeholder')) {
5638
+ if (showPlaceholder && placeholderIndex >= items.length && !placeholderInserted) {
5306
5639
  itemsToRender.push({
5307
5640
  type: 'placeholder',
5308
5641
  key: '__placeholder__',
@@ -5472,14 +5805,11 @@ class VirtualForDirective {
5472
5805
  const container = this.#viewContainer.element.nativeElement.parentElement;
5473
5806
  if (!container)
5474
5807
  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;
5808
+ // The placeholder goes immediately before the item view at placeholderDomPosition
5809
+ // (the reconcile pass counts item views only, so this index maps directly to a
5810
+ // ViewContainerRef view). Reading the reference node from that view avoids
5811
+ // re-querying and filtering container.children on every placeholder move.
5812
+ const insertBeforeEl = this.#getViewReferenceNode(placeholderDomPosition);
5483
5813
  if (!this.#placeholderInDom) {
5484
5814
  // First insertion
5485
5815
  if (insertBeforeEl) {
@@ -5503,6 +5833,26 @@ class VirtualForDirective {
5503
5833
  }
5504
5834
  }
5505
5835
  }
5836
+ /**
5837
+ * Resolve the DOM node the placeholder should be inserted before, given the
5838
+ * item-view index produced by the reconcile pass. Returns null when the index
5839
+ * is past the last view (placeholder belongs at the end → append).
5840
+ */
5841
+ #getViewReferenceNode(viewIndex) {
5842
+ if (viewIndex < 0 || viewIndex >= this.#viewContainer.length) {
5843
+ return null;
5844
+ }
5845
+ const view = this.#viewContainer.get(viewIndex);
5846
+ if (!view) {
5847
+ return null;
5848
+ }
5849
+ for (const node of view.rootNodes) {
5850
+ if (node instanceof HTMLElement) {
5851
+ return node;
5852
+ }
5853
+ }
5854
+ return null;
5855
+ }
5506
5856
  /**
5507
5857
  * Trim the view pool to prevent memory bloat, keeping a reasonable buffer.
5508
5858
  */