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.
- package/fesm2022/ngx-virtual-dnd.mjs +571 -221
- package/fesm2022/ngx-virtual-dnd.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-virtual-dnd.d.ts +70 -11
|
@@ -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, 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
|
|
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' });
|
|
@@ -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
|
-
*
|
|
719
|
-
*
|
|
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
|
|
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,
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
|
855
|
+
return null;
|
|
742
856
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
|
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
|
-
#
|
|
1228
|
+
#scheduler = inject(DragSchedulerService);
|
|
974
1229
|
/** Currently registered scrollable containers */
|
|
975
1230
|
#scrollableContainers = new Map();
|
|
976
|
-
/**
|
|
977
|
-
#
|
|
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
|
-
*
|
|
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.#
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
#
|
|
1305
|
+
#participantTick() {
|
|
1053
1306
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
#
|
|
1440
|
+
#stylePropsToCopy = [
|
|
1201
1441
|
'background',
|
|
1202
|
-
'
|
|
1203
|
-
'
|
|
1442
|
+
'background-color',
|
|
1443
|
+
'background-image',
|
|
1204
1444
|
'border',
|
|
1205
|
-
'
|
|
1206
|
-
'
|
|
1445
|
+
'border-radius',
|
|
1446
|
+
'box-shadow',
|
|
1207
1447
|
'color',
|
|
1208
1448
|
'font',
|
|
1209
|
-
'
|
|
1210
|
-
'
|
|
1211
|
-
'
|
|
1212
|
-
'
|
|
1449
|
+
'font-family',
|
|
1450
|
+
'font-size',
|
|
1451
|
+
'font-weight',
|
|
1452
|
+
'line-height',
|
|
1213
1453
|
'padding',
|
|
1214
1454
|
'margin',
|
|
1215
1455
|
'display',
|
|
1216
|
-
'
|
|
1217
|
-
'
|
|
1218
|
-
'
|
|
1456
|
+
'flex-direction',
|
|
1457
|
+
'align-items',
|
|
1458
|
+
'justify-content',
|
|
1219
1459
|
'gap',
|
|
1220
|
-
'
|
|
1221
|
-
'
|
|
1460
|
+
'text-align',
|
|
1461
|
+
'text-decoration',
|
|
1222
1462
|
'width',
|
|
1223
1463
|
'height',
|
|
1224
|
-
'
|
|
1225
|
-
'
|
|
1226
|
-
'
|
|
1227
|
-
'
|
|
1464
|
+
'min-width',
|
|
1465
|
+
'min-height',
|
|
1466
|
+
'max-width',
|
|
1467
|
+
'max-height',
|
|
1228
1468
|
'overflow',
|
|
1229
1469
|
'opacity',
|
|
1230
1470
|
'transform',
|
|
1231
|
-
'
|
|
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.#
|
|
1255
|
-
const value = computed.getPropertyValue(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
4149
|
-
|
|
4150
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
4671
|
-
|
|
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
|
-
//
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
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
|
*/
|