ngx-virtual-dnd 3.1.0 → 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,
|
|
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
|
|
474
|
-
*
|
|
475
|
-
*
|
|
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 =
|
|
504
|
+
activeDroppableId = this.#activeDroppableId.asReadonly();
|
|
500
505
|
/** ID of the item the placeholder should appear before */
|
|
501
|
-
placeholderId =
|
|
506
|
+
placeholderId = this.#placeholderId.asReadonly();
|
|
502
507
|
/** Index where the placeholder should be inserted */
|
|
503
|
-
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
|
-
//
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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.#
|
|
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.#
|
|
604
|
-
|
|
605
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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 =
|
|
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.#
|
|
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.#
|
|
685
|
-
|
|
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
|
-
*
|
|
719
|
-
*
|
|
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
|
|
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,
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
|
835
|
+
return null;
|
|
742
836
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
#
|
|
1210
|
+
#scheduler = inject(DragSchedulerService);
|
|
974
1211
|
/** Currently registered scrollable containers */
|
|
975
1212
|
#scrollableContainers = new Map();
|
|
976
|
-
/**
|
|
977
|
-
#
|
|
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
|
-
*
|
|
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.#
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
#
|
|
1287
|
+
#participantTick() {
|
|
1053
1288
|
const cursor = this.#cursorOverride ?? this.#dragState.cursorPosition();
|
|
1054
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
#
|
|
1422
|
+
#stylePropsToCopy = [
|
|
1201
1423
|
'background',
|
|
1202
|
-
'
|
|
1203
|
-
'
|
|
1424
|
+
'background-color',
|
|
1425
|
+
'background-image',
|
|
1204
1426
|
'border',
|
|
1205
|
-
'
|
|
1206
|
-
'
|
|
1427
|
+
'border-radius',
|
|
1428
|
+
'box-shadow',
|
|
1207
1429
|
'color',
|
|
1208
1430
|
'font',
|
|
1209
|
-
'
|
|
1210
|
-
'
|
|
1211
|
-
'
|
|
1212
|
-
'
|
|
1431
|
+
'font-family',
|
|
1432
|
+
'font-size',
|
|
1433
|
+
'font-weight',
|
|
1434
|
+
'line-height',
|
|
1213
1435
|
'padding',
|
|
1214
1436
|
'margin',
|
|
1215
1437
|
'display',
|
|
1216
|
-
'
|
|
1217
|
-
'
|
|
1218
|
-
'
|
|
1438
|
+
'flex-direction',
|
|
1439
|
+
'align-items',
|
|
1440
|
+
'justify-content',
|
|
1219
1441
|
'gap',
|
|
1220
|
-
'
|
|
1221
|
-
'
|
|
1442
|
+
'text-align',
|
|
1443
|
+
'text-decoration',
|
|
1222
1444
|
'width',
|
|
1223
1445
|
'height',
|
|
1224
|
-
'
|
|
1225
|
-
'
|
|
1226
|
-
'
|
|
1227
|
-
'
|
|
1446
|
+
'min-width',
|
|
1447
|
+
'min-height',
|
|
1448
|
+
'max-width',
|
|
1449
|
+
'max-height',
|
|
1228
1450
|
'overflow',
|
|
1229
1451
|
'opacity',
|
|
1230
1452
|
'transform',
|
|
1231
|
-
'
|
|
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.#
|
|
1255
|
-
const value = computed.getPropertyValue(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
4149
|
-
|
|
4150
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
4671
|
-
|
|
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
|
-
//
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
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
|
*/
|