ngx-virtual-dnd 3.1.1 → 3.1.3
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/README.md +30 -14
- package/fesm2022/ngx-virtual-dnd.mjs +568 -206
- package/fesm2022/ngx-virtual-dnd.mjs.map +1 -1
- package/package.json +4 -4
- package/types/ngx-virtual-dnd.d.ts +50 -8
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { signal, InjectionToken, computed, effect, Injectable, inject, NgZone,
|
|
2
|
+
import { signal, InjectionToken, computed, effect, Injectable, inject, NgZone, input, ChangeDetectionStrategy, Component, DestroyRef, ElementRef, Injector, afterNextRender, viewChild, Directive, isDevMode, output, untracked, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
|
|
3
3
|
import { NgTemplateOutlet } from '@angular/common';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -486,10 +486,14 @@ class DragStateService {
|
|
|
486
486
|
#placeholderId = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderId" }] : []));
|
|
487
487
|
/** Index where the placeholder should be inserted — updated on each placeholder move */
|
|
488
488
|
#placeholderIndex = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderIndex" }] : []));
|
|
489
|
+
/** Snapshot captured synchronously immediately before the last drag state reset. */
|
|
490
|
+
#endedDragState = signal(null, ...(ngDevMode ? [{ debugName: "#endedDragState" }] : []));
|
|
489
491
|
/** Flag indicating if the last drag was cancelled (not dropped) */
|
|
490
492
|
#wasCancelled = signal(false, ...(ngDevMode ? [{ debugName: "#wasCancelled" }] : []));
|
|
491
493
|
/** Whether the last drag was cancelled (for droppable to check before emitting drop) */
|
|
492
494
|
wasCancelled = this.#wasCancelled.asReadonly();
|
|
495
|
+
/** Snapshot captured synchronously before the last drag reset. */
|
|
496
|
+
endedDragState = this.#endedDragState.asReadonly();
|
|
493
497
|
/** Whether a drag operation is in progress */
|
|
494
498
|
isDragging = computed(() => this.#state().isDragging, ...(ngDevMode ? [{ debugName: "isDragging" }] : []));
|
|
495
499
|
/** The currently dragged item, or null */
|
|
@@ -546,7 +550,8 @@ class DragStateService {
|
|
|
546
550
|
* Start a drag operation.
|
|
547
551
|
*/
|
|
548
552
|
startDrag(item, cursorPosition, grabOffset, lockAxis, activeDroppableId, placeholderId, placeholderIndex, sourceIndex, isKeyboardDrag, axisLockPosition) {
|
|
549
|
-
// Reset
|
|
553
|
+
// Reset terminal drag metadata at start of new drag
|
|
554
|
+
this.#endedDragState.set(null);
|
|
550
555
|
this.#wasCancelled.set(false);
|
|
551
556
|
this.#cursorPosition.set(cursorPosition ?? null);
|
|
552
557
|
this.#keyboardTargetIndex.set(isKeyboardDrag ? (sourceIndex ?? 0) : null);
|
|
@@ -616,6 +621,7 @@ class DragStateService {
|
|
|
616
621
|
* End the drag operation and reset state (normal drop).
|
|
617
622
|
*/
|
|
618
623
|
endDrag() {
|
|
624
|
+
this.#endedDragState.set(this.getStateSnapshot());
|
|
619
625
|
this.#wasCancelled.set(false);
|
|
620
626
|
this.#resetHighFrequencySignals();
|
|
621
627
|
this.#state.set(INITIAL_CORE_STATE);
|
|
@@ -624,6 +630,7 @@ class DragStateService {
|
|
|
624
630
|
* Cancel the drag operation (escape key, disabled, etc.).
|
|
625
631
|
*/
|
|
626
632
|
cancelDrag() {
|
|
633
|
+
this.#endedDragState.set(this.getStateSnapshot());
|
|
627
634
|
this.#wasCancelled.set(true);
|
|
628
635
|
this.#resetHighFrequencySignals();
|
|
629
636
|
this.#state.set(INITIAL_CORE_STATE);
|
|
@@ -709,6 +716,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
709
716
|
}]
|
|
710
717
|
}], ctorParameters: () => [] });
|
|
711
718
|
|
|
719
|
+
/**
|
|
720
|
+
* Query by an exact attribute value without interpolating consumer data into a CSS selector.
|
|
721
|
+
*
|
|
722
|
+
* CSS attribute selectors need string-specific escaping that differs from `CSS.escape()`.
|
|
723
|
+
* Filtering candidates by attribute presence avoids selector syntax errors for IDs containing
|
|
724
|
+
* quotes, brackets, backslashes, or other selector-sensitive characters.
|
|
725
|
+
*/
|
|
726
|
+
function queryByAttribute(root, attributeName, attributeValue) {
|
|
727
|
+
const candidates = root.querySelectorAll(`[${attributeName}]`);
|
|
728
|
+
for (const candidate of candidates) {
|
|
729
|
+
if (candidate.getAttribute(attributeName) === attributeValue) {
|
|
730
|
+
return candidate;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
function queryAllByAttribute(root, attributeName, attributeValue) {
|
|
736
|
+
return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
|
|
737
|
+
}
|
|
738
|
+
|
|
712
739
|
/**
|
|
713
740
|
* Service for calculating drop positions and finding elements at cursor positions.
|
|
714
741
|
* This is the core algorithm that makes virtual scroll + drag-and-drop work together.
|
|
@@ -719,6 +746,8 @@ class PositionCalculatorService {
|
|
|
719
746
|
#DROPPABLE_ID_ATTR = 'data-droppable-id';
|
|
720
747
|
/** Data attribute used to identify droppable groups */
|
|
721
748
|
#DROPPABLE_GROUP_ATTR = 'data-droppable-group';
|
|
749
|
+
/** Data attribute reflecting a droppable's disabled state (present === disabled) */
|
|
750
|
+
#DROPPABLE_DISABLED_ATTR = 'data-droppable-disabled';
|
|
722
751
|
/** Data attribute used to identify draggable elements */
|
|
723
752
|
#DRAGGABLE_ID_ATTR = 'data-draggable-id';
|
|
724
753
|
/** Maximum DOM levels to traverse when looking for parent elements */
|
|
@@ -746,10 +775,15 @@ class PositionCalculatorService {
|
|
|
746
775
|
this.#session = {
|
|
747
776
|
groupName,
|
|
748
777
|
candidates,
|
|
749
|
-
rects: candidates.map((el) => el
|
|
778
|
+
rects: candidates.map((el) => this.#measureRect(el)),
|
|
750
779
|
dirty: false,
|
|
780
|
+
candidatesStale: false,
|
|
751
781
|
onViewportChange,
|
|
782
|
+
resizeObserver: this.#createResizeObserver(),
|
|
752
783
|
};
|
|
784
|
+
// A container-only reflow (an element resize that fires no window scroll/resize)
|
|
785
|
+
// would otherwise leave stale rects — observe the candidates so it marks them dirty.
|
|
786
|
+
this.#observeCandidates(this.#session);
|
|
753
787
|
// Capture-phase scroll catches scrolling on any ancestor scroller (scroll does
|
|
754
788
|
// not bubble); resize covers viewport changes. Both only mark rects dirty —
|
|
755
789
|
// the actual re-read is deferred to the next hit-test.
|
|
@@ -770,6 +804,7 @@ class PositionCalculatorService {
|
|
|
770
804
|
return;
|
|
771
805
|
}
|
|
772
806
|
this.#session = null;
|
|
807
|
+
session.resizeObserver?.disconnect();
|
|
773
808
|
if (typeof window !== 'undefined') {
|
|
774
809
|
window.removeEventListener('scroll', session.onViewportChange, { capture: true });
|
|
775
810
|
window.removeEventListener('resize', session.onViewportChange);
|
|
@@ -785,6 +820,39 @@ class PositionCalculatorService {
|
|
|
785
820
|
this.#session.dirty = true;
|
|
786
821
|
}
|
|
787
822
|
}
|
|
823
|
+
/**
|
|
824
|
+
* Re-query the candidate droppable *list* for the active session and re-read their
|
|
825
|
+
* rects. Unlike {@link invalidateDroppableRects} (which only refreshes rects), this
|
|
826
|
+
* picks up droppables added or removed since the snapshot was captured at drag start —
|
|
827
|
+
* the frozen candidate list is otherwise blind to mid-drag mounts/unmounts.
|
|
828
|
+
*
|
|
829
|
+
* Safe to call when no session is active (no-op).
|
|
830
|
+
*/
|
|
831
|
+
refreshCandidates() {
|
|
832
|
+
const session = this.#session;
|
|
833
|
+
if (!session) {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
session.candidates = this.#queryDroppables(session.groupName);
|
|
837
|
+
session.rects = session.candidates.map((el) => this.#measureRect(el));
|
|
838
|
+
session.dirty = false;
|
|
839
|
+
session.candidatesStale = false;
|
|
840
|
+
this.#observeCandidates(session);
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Signal that the set of droppables for `groupName` may have changed (a droppable
|
|
844
|
+
* registered or unregistered mid-drag). Defers the actual re-query to the next
|
|
845
|
+
* hit-test — where a removed element is already gone from the DOM and a newly added
|
|
846
|
+
* one has its data attributes applied — keeping this notification path cheap.
|
|
847
|
+
*
|
|
848
|
+
* Called by {@link DroppableDirective} lifecycle hooks. No-op when the active session
|
|
849
|
+
* belongs to a different group or no session is active.
|
|
850
|
+
*/
|
|
851
|
+
notifyCandidatesChanged(groupName) {
|
|
852
|
+
if (this.#session && this.#session.groupName === groupName) {
|
|
853
|
+
this.#session.candidatesStale = true;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
788
856
|
/**
|
|
789
857
|
* Find the droppable element at a given point.
|
|
790
858
|
*
|
|
@@ -803,9 +871,13 @@ class PositionCalculatorService {
|
|
|
803
871
|
findDroppableAtPoint(x, y, _draggedElement, groupName) {
|
|
804
872
|
const session = this.#session;
|
|
805
873
|
if (session && session.groupName === groupName) {
|
|
806
|
-
if (session.
|
|
874
|
+
if (session.candidatesStale) {
|
|
875
|
+
// A droppable was added/removed mid-drag: re-query the list (also refreshes rects).
|
|
876
|
+
this.refreshCandidates();
|
|
877
|
+
}
|
|
878
|
+
else if (session.dirty) {
|
|
807
879
|
for (let i = 0; i < session.candidates.length; i++) {
|
|
808
|
-
session.rects[i] = session.candidates[i]
|
|
880
|
+
session.rects[i] = this.#measureRect(session.candidates[i]);
|
|
809
881
|
}
|
|
810
882
|
session.dirty = false;
|
|
811
883
|
}
|
|
@@ -813,7 +885,7 @@ class PositionCalculatorService {
|
|
|
813
885
|
}
|
|
814
886
|
// No active session: one-shot geometric query (still avoids elementFromPoint).
|
|
815
887
|
const candidates = this.#queryDroppables(groupName);
|
|
816
|
-
const rects = candidates.map((el) => el
|
|
888
|
+
const rects = candidates.map((el) => this.#measureRect(el));
|
|
817
889
|
return this.#hitTest(x, y, candidates, rects);
|
|
818
890
|
}
|
|
819
891
|
/**
|
|
@@ -837,31 +909,104 @@ class PositionCalculatorService {
|
|
|
837
909
|
if (typeof document === 'undefined') {
|
|
838
910
|
return null;
|
|
839
911
|
}
|
|
840
|
-
|
|
841
|
-
return document.querySelector(`[${this.#DROPPABLE_ID_ATTR}="${escaped}"]`);
|
|
912
|
+
return queryByAttribute(document, this.#DROPPABLE_ID_ATTR, id);
|
|
842
913
|
}
|
|
843
914
|
/**
|
|
844
915
|
* Query all droppable elements belonging to a group, in document order.
|
|
916
|
+
*
|
|
917
|
+
* All group members are included — the disabled check happens per-frame in
|
|
918
|
+
* {@link #hitTest} rather than here, because a droppable can be disabled or
|
|
919
|
+
* re-enabled mid-drag (e.g. a consumer disabling incompatible targets from a
|
|
920
|
+
* `(dragStart)` handler, which fires AFTER the candidate snapshot is captured).
|
|
845
921
|
*/
|
|
846
922
|
#queryDroppables(groupName) {
|
|
847
923
|
if (typeof document === 'undefined') {
|
|
848
924
|
return [];
|
|
849
925
|
}
|
|
850
|
-
|
|
851
|
-
|
|
926
|
+
return queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Measure a candidate's hit-test rect, clipped to its nearest `.vdnd-scrollable`
|
|
930
|
+
* ancestor. Without clipping a droppable scrolled mostly out of a clipping container
|
|
931
|
+
* still hit-tests over its full unclipped rect (issue #23 case 3). The intersection is
|
|
932
|
+
* built as a plain DOMRect; an empty intersection yields a negative width/height so the
|
|
933
|
+
* `#hitTest` bounds check can never match it.
|
|
934
|
+
*/
|
|
935
|
+
#measureRect(el) {
|
|
936
|
+
const rect = el.getBoundingClientRect();
|
|
937
|
+
const scrollable = el.closest('.vdnd-scrollable');
|
|
938
|
+
if (!scrollable || scrollable === el) {
|
|
939
|
+
return rect;
|
|
940
|
+
}
|
|
941
|
+
const clip = scrollable.getBoundingClientRect();
|
|
942
|
+
const top = Math.max(rect.top, clip.top);
|
|
943
|
+
const left = Math.max(rect.left, clip.left);
|
|
944
|
+
const right = Math.min(rect.right, clip.right);
|
|
945
|
+
const bottom = Math.min(rect.bottom, clip.bottom);
|
|
946
|
+
return new DOMRect(left, top, right - left, bottom - top);
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Create a ResizeObserver (run outside Angular per CLAUDE.md) that marks the session's
|
|
950
|
+
* cached rects dirty when a candidate resizes. Returns null when ResizeObserver is
|
|
951
|
+
* unavailable (SSR / jsdom) so callers degrade gracefully.
|
|
952
|
+
*/
|
|
953
|
+
#createResizeObserver() {
|
|
954
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
return this.#ngZone.runOutsideAngular(() => new ResizeObserver(() => {
|
|
958
|
+
if (this.#session) {
|
|
959
|
+
this.#session.dirty = true;
|
|
960
|
+
}
|
|
961
|
+
}));
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Point the session's ResizeObserver at the current candidate list, dropping any
|
|
965
|
+
* previously observed elements. Called on session start and whenever the candidate
|
|
966
|
+
* list is refreshed.
|
|
967
|
+
*/
|
|
968
|
+
#observeCandidates(session) {
|
|
969
|
+
const observer = session.resizeObserver;
|
|
970
|
+
if (!observer) {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
this.#ngZone.runOutsideAngular(() => {
|
|
974
|
+
observer.disconnect();
|
|
975
|
+
for (const candidate of session.candidates) {
|
|
976
|
+
observer.observe(candidate);
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Whether a droppable element is currently disabled (reflected via the
|
|
982
|
+
* `data-droppable-disabled` attribute by `DroppableDirective`). Read live on each
|
|
983
|
+
* hit-test / navigation step so disabled↔enabled transitions during an active drag
|
|
984
|
+
* take effect immediately. Disabled droppables are excluded from all drag-time
|
|
985
|
+
* candidate sets — pointer hit-testing and keyboard cross-list navigation.
|
|
986
|
+
*/
|
|
987
|
+
#isDroppableDisabled(el) {
|
|
988
|
+
return el.hasAttribute(this.#DROPPABLE_DISABLED_ATTR);
|
|
852
989
|
}
|
|
853
990
|
/**
|
|
854
991
|
* Geometric hit-test: return the last candidate (in document order) whose rect
|
|
855
992
|
* contains the point. "Last in document order" reproduces the painter's-order
|
|
856
993
|
* tie-break that `elementFromPoint` provided for free — a nested or later
|
|
857
994
|
* overlapping droppable is painted on top of an earlier/ancestor one.
|
|
995
|
+
*
|
|
996
|
+
* Currently-disabled candidates are skipped here (not filtered from the snapshot) so
|
|
997
|
+
* that toggling `disabled` mid-drag takes effect on the very next frame. The check is
|
|
998
|
+
* a cheap `hasAttribute` read that does not force layout, keeping the loop hot.
|
|
858
999
|
*/
|
|
859
1000
|
#hitTest(x, y, candidates, rects) {
|
|
860
1001
|
let match = null;
|
|
861
1002
|
for (let i = 0; i < candidates.length; i++) {
|
|
1003
|
+
const candidate = candidates[i];
|
|
1004
|
+
if (this.#isDroppableDisabled(candidate)) {
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
862
1007
|
const r = rects[i];
|
|
863
1008
|
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
|
|
864
|
-
match =
|
|
1009
|
+
match = candidate;
|
|
865
1010
|
}
|
|
866
1011
|
}
|
|
867
1012
|
return match;
|
|
@@ -1000,12 +1145,15 @@ class PositionCalculatorService {
|
|
|
1000
1145
|
* @returns Object with droppable info, or null if none found
|
|
1001
1146
|
*/
|
|
1002
1147
|
findAdjacentDroppable(currentDroppableId, direction, groupName) {
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1148
|
+
// Include disabled droppables when establishing left-to-right order and locating the
|
|
1149
|
+
// current container — otherwise a container disabled mid-drag (its own index becomes
|
|
1150
|
+
// -1) would trap the drag with no reachable neighbour. Disabled droppables are skipped
|
|
1151
|
+
// as *targets* during the outward scan below instead.
|
|
1152
|
+
const allDroppables = queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
|
|
1005
1153
|
if (allDroppables.length <= 1) {
|
|
1006
1154
|
return null;
|
|
1007
1155
|
}
|
|
1008
|
-
// Get bounding rects and
|
|
1156
|
+
// Get bounding rects, IDs, and disabled state, sorted by X position
|
|
1009
1157
|
const droppableInfos = [];
|
|
1010
1158
|
allDroppables.forEach((el) => {
|
|
1011
1159
|
const htmlEl = el;
|
|
@@ -1015,56 +1163,41 @@ class PositionCalculatorService {
|
|
|
1015
1163
|
element: htmlEl,
|
|
1016
1164
|
id,
|
|
1017
1165
|
rect: htmlEl.getBoundingClientRect(),
|
|
1166
|
+
disabled: this.#isDroppableDisabled(htmlEl),
|
|
1018
1167
|
});
|
|
1019
1168
|
}
|
|
1020
1169
|
});
|
|
1021
1170
|
// Sort by X position (left to right)
|
|
1022
1171
|
droppableInfos.sort((a, b) => a.rect.left - b.rect.left);
|
|
1023
|
-
// Find current droppable index
|
|
1172
|
+
// Find current droppable index (among all, including a now-disabled current container)
|
|
1024
1173
|
const currentIndex = droppableInfos.findIndex((d) => d.id === currentDroppableId);
|
|
1025
1174
|
if (currentIndex === -1) {
|
|
1026
1175
|
return null;
|
|
1027
1176
|
}
|
|
1028
|
-
//
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1177
|
+
// Scan outward in the requested direction, skipping disabled droppables, until the
|
|
1178
|
+
// first enabled neighbour (or run off the end of the list).
|
|
1179
|
+
const step = direction === 'left' ? -1 : 1;
|
|
1180
|
+
for (let i = currentIndex + step; i >= 0 && i < droppableInfos.length; i += step) {
|
|
1181
|
+
const target = droppableInfos[i];
|
|
1182
|
+
if (target.disabled) {
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
return {
|
|
1186
|
+
element: target.element,
|
|
1187
|
+
id: target.id,
|
|
1188
|
+
};
|
|
1032
1189
|
}
|
|
1033
|
-
|
|
1034
|
-
// Get item count from the target droppable
|
|
1035
|
-
const itemCount = this.#getDroppableItemCount(target.element);
|
|
1036
|
-
return {
|
|
1037
|
-
element: target.element,
|
|
1038
|
-
id: target.id,
|
|
1039
|
-
itemCount,
|
|
1040
|
-
};
|
|
1190
|
+
return null;
|
|
1041
1191
|
}
|
|
1042
1192
|
/**
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1193
|
+
* Whether the droppable with the given ID is currently disabled. A droppable that cannot
|
|
1194
|
+
* be found is reported as disabled (treat missing as not a valid target). Used to
|
|
1195
|
+
* revalidate a keyboard drag's active target at drop time, since it may have been disabled
|
|
1196
|
+
* after the drag navigated into it.
|
|
1045
1197
|
*/
|
|
1046
|
-
|
|
1047
|
-
const
|
|
1048
|
-
|
|
1049
|
-
const configuredHeight = virtualScroll.getAttribute('data-item-height');
|
|
1050
|
-
if (!configuredHeight) {
|
|
1051
|
-
if (isDevMode()) {
|
|
1052
|
-
console.error('[ngx-virtual-dnd] vdnd-virtual-scroll requires data-item-height attribute ' +
|
|
1053
|
-
'for keyboard navigation. Cross-list keyboard drag will not work correctly.');
|
|
1054
|
-
}
|
|
1055
|
-
// Short-circuit: return 0 to prevent navigation to this droppable
|
|
1056
|
-
return 0;
|
|
1057
|
-
}
|
|
1058
|
-
// Prefer spacer height over scrollHeight for accuracy
|
|
1059
|
-
const spacer = virtualScroll.querySelector('.vdnd-virtual-scroll-spacer');
|
|
1060
|
-
const totalHeight = spacer
|
|
1061
|
-
? parseFloat(spacer.style.height) || 0
|
|
1062
|
-
: virtualScroll.scrollHeight;
|
|
1063
|
-
const itemHeight = parseInt(configuredHeight, 10);
|
|
1064
|
-
return Math.floor(totalHeight / itemHeight);
|
|
1065
|
-
}
|
|
1066
|
-
// Fallback for non-virtual scroll: DOM count is valid
|
|
1067
|
-
return droppableElement.querySelectorAll(`[${this.#DRAGGABLE_ID_ATTR}]`).length;
|
|
1198
|
+
isDroppableDisabledById(id) {
|
|
1199
|
+
const element = this.getDroppableById(id);
|
|
1200
|
+
return element === null || this.#isDroppableDisabled(element);
|
|
1068
1201
|
}
|
|
1069
1202
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: PositionCalculatorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1070
1203
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: PositionCalculatorService, providedIn: 'root' });
|
|
@@ -1201,6 +1334,9 @@ const DEFAULT_CONFIG = {
|
|
|
1201
1334
|
maxSpeed: 15,
|
|
1202
1335
|
accelerate: true,
|
|
1203
1336
|
};
|
|
1337
|
+
const BASE_FRAME_DURATION_MS = 1000 / 60;
|
|
1338
|
+
const MIN_SCROLL_FRAME_SCALE = 1;
|
|
1339
|
+
const MAX_SCROLL_FRAME_SCALE = 6;
|
|
1204
1340
|
/**
|
|
1205
1341
|
* Service that handles auto-scrolling when dragging near container edges.
|
|
1206
1342
|
*/
|
|
@@ -1210,6 +1346,13 @@ class AutoScrollService {
|
|
|
1210
1346
|
#scheduler = inject(DragSchedulerService);
|
|
1211
1347
|
/** Currently registered scrollable containers */
|
|
1212
1348
|
#scrollableContainers = new Map();
|
|
1349
|
+
/**
|
|
1350
|
+
* Candidate containers ordered innermost (deepest in the DOM) first, so a
|
|
1351
|
+
* nested scrollable wins over its ancestors under the cursor. Rebuilt lazily
|
|
1352
|
+
* whenever registrations change (containers rarely move mid-drag) and once per
|
|
1353
|
+
* drag (see startMonitoring), not per frame.
|
|
1354
|
+
*/
|
|
1355
|
+
#orderedContainers = null;
|
|
1213
1356
|
/** Bound reference to #participantTick for stable add/remove with the scheduler. */
|
|
1214
1357
|
#boundParticipantTick = () => this.#participantTick();
|
|
1215
1358
|
/** Callback to invoke when scrolling occurs (for placeholder recalculation) */
|
|
@@ -1219,6 +1362,12 @@ class AutoScrollService {
|
|
|
1219
1362
|
/** Last tick cursor position for stationary detection */
|
|
1220
1363
|
#lastTickCursorX = NaN;
|
|
1221
1364
|
#lastTickCursorY = NaN;
|
|
1365
|
+
/**
|
|
1366
|
+
* Last timestamp used to scale per-frame autoscroll distance by elapsed frame time.
|
|
1367
|
+
* Scale is intentionally floored at 1 to preserve the existing feel on 60fps+ displays while
|
|
1368
|
+
* compensating slower frames that would otherwise under-scroll.
|
|
1369
|
+
*/
|
|
1370
|
+
#lastScrollTimestamp = 0;
|
|
1222
1371
|
/** Reusable direction object for tick loop (avoids per-frame allocation) */
|
|
1223
1372
|
#tickDirection = { x: 0, y: 0 };
|
|
1224
1373
|
/** Current scroll state */
|
|
@@ -1235,12 +1384,61 @@ class AutoScrollService {
|
|
|
1235
1384
|
element,
|
|
1236
1385
|
config: { ...DEFAULT_CONFIG, ...config },
|
|
1237
1386
|
});
|
|
1387
|
+
this.#orderedContainers = null;
|
|
1238
1388
|
}
|
|
1239
1389
|
/**
|
|
1240
1390
|
* Unregister a scrollable container.
|
|
1241
1391
|
*/
|
|
1242
1392
|
unregisterContainer(id) {
|
|
1243
1393
|
this.#scrollableContainers.delete(id);
|
|
1394
|
+
this.#orderedContainers = null;
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Build (or reuse) the candidate list ordered innermost-first by DOM depth.
|
|
1398
|
+
*
|
|
1399
|
+
* Sorting by absolute DOM depth (descending) is a *total* order, unlike an
|
|
1400
|
+
* `element.contains()` comparator which returns 0 for unrelated pairs and so
|
|
1401
|
+
* violates transitivity — with ≥3 containers a nested pair separated by an
|
|
1402
|
+
* unrelated one in registration order could end up never compared, leaving the
|
|
1403
|
+
* ancestor ahead of its descendant. Equal-depth (including unrelated) containers
|
|
1404
|
+
* keep registration order via the stable sort, so unrelated lists behave as before.
|
|
1405
|
+
*/
|
|
1406
|
+
#getOrderedContainers() {
|
|
1407
|
+
if (this.#orderedContainers) {
|
|
1408
|
+
return this.#orderedContainers;
|
|
1409
|
+
}
|
|
1410
|
+
const entries = [];
|
|
1411
|
+
for (const [id, { element, config }] of this.#scrollableContainers) {
|
|
1412
|
+
// Registration is unconditional (directives can't expose DOM size as a signal, so
|
|
1413
|
+
// gating registration on it goes stale — see #27). Filter by live scroll geometry
|
|
1414
|
+
// here instead: this list is rebuilt per drag (startMonitoring nulls the cache) and
|
|
1415
|
+
// on register/unregister, so a container that grew scrollable after init is picked
|
|
1416
|
+
// up on the next drag, and one that shrank stops occupying a candidate slot.
|
|
1417
|
+
if (!this.#isScrollable(element)) {
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
entries.push({ id, element, config, depth: this.#domDepth(element) });
|
|
1421
|
+
}
|
|
1422
|
+
// Deepest first; stable sort keeps registration order on ties.
|
|
1423
|
+
entries.sort((a, b) => b.depth - a.depth);
|
|
1424
|
+
this.#orderedContainers = entries;
|
|
1425
|
+
return entries;
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Whether the element currently has room to scroll on either axis. Read fresh each
|
|
1429
|
+
* time the candidate list is (re)built — never cached — so it always reflects the
|
|
1430
|
+
* live layout at drag start rather than a stale value captured at registration.
|
|
1431
|
+
*/
|
|
1432
|
+
#isScrollable(element) {
|
|
1433
|
+
return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
|
|
1434
|
+
}
|
|
1435
|
+
/** Number of ancestor elements — the element's absolute depth in the DOM tree. */
|
|
1436
|
+
#domDepth(element) {
|
|
1437
|
+
let depth = 0;
|
|
1438
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
1439
|
+
depth++;
|
|
1440
|
+
}
|
|
1441
|
+
return depth;
|
|
1244
1442
|
}
|
|
1245
1443
|
/**
|
|
1246
1444
|
* Start monitoring for auto-scroll.
|
|
@@ -1250,6 +1448,10 @@ class AutoScrollService {
|
|
|
1250
1448
|
*/
|
|
1251
1449
|
startMonitoring(onScroll) {
|
|
1252
1450
|
this.#onScrollCallback = onScroll ?? null;
|
|
1451
|
+
// Recompute containment ordering fresh per drag: a registered container may have
|
|
1452
|
+
// been reparented (portal/DOM move) since the cache was built, which register/
|
|
1453
|
+
// unregister alone wouldn't catch.
|
|
1454
|
+
this.#orderedContainers = null;
|
|
1253
1455
|
this.#scheduler.addParticipant(this.#boundParticipantTick);
|
|
1254
1456
|
}
|
|
1255
1457
|
/**
|
|
@@ -1263,6 +1465,7 @@ class AutoScrollService {
|
|
|
1263
1465
|
this.#cursorOverride = null;
|
|
1264
1466
|
this.#lastTickCursorX = NaN;
|
|
1265
1467
|
this.#lastTickCursorY = NaN;
|
|
1468
|
+
this.#lastScrollTimestamp = 0;
|
|
1266
1469
|
this.#scrollState.containerId = null;
|
|
1267
1470
|
this.#scrollState.direction.x = 0;
|
|
1268
1471
|
this.#scrollState.direction.y = 0;
|
|
@@ -1299,7 +1502,12 @@ class AutoScrollService {
|
|
|
1299
1502
|
this.#lastTickCursorX = cursor.x;
|
|
1300
1503
|
this.#lastTickCursorY = cursor.y;
|
|
1301
1504
|
let scrollPerformed = false;
|
|
1302
|
-
|
|
1505
|
+
// Frame scale is shared across candidates within a tick; computed lazily on
|
|
1506
|
+
// the first scroll attempt so stationary/idle frames don't advance the clock.
|
|
1507
|
+
let frameScale = 0;
|
|
1508
|
+
// Candidates are ordered innermost-first: when the deepest container under the
|
|
1509
|
+
// cursor is exhausted at its boundary, the loop falls through to its ancestors.
|
|
1510
|
+
for (const { id, element, config } of this.#getOrderedContainers()) {
|
|
1303
1511
|
const rect = element.getBoundingClientRect();
|
|
1304
1512
|
const isInside = this.#positionCalculator.isInsideContainer(cursor, rect);
|
|
1305
1513
|
if (!isInside) {
|
|
@@ -1333,16 +1541,31 @@ class AutoScrollService {
|
|
|
1333
1541
|
const distanceRatio = maxDistance / config.threshold;
|
|
1334
1542
|
speed = Math.min(config.maxSpeed, Math.max(1, config.maxSpeed * distanceRatio));
|
|
1335
1543
|
}
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1544
|
+
if (frameScale === 0) {
|
|
1545
|
+
frameScale = this.#getFrameScale();
|
|
1546
|
+
}
|
|
1547
|
+
// #performScroll reports whether it actually moved the element. Only claim the
|
|
1548
|
+
// tick (and stop descending the candidate list) when a scroll really happened —
|
|
1549
|
+
// an exhausted container must not block its ancestors from scrolling.
|
|
1550
|
+
//
|
|
1551
|
+
// Axis independence is per-container: if this container scrolls on *either* axis
|
|
1552
|
+
// it claims the whole tick, so a corner where the inner container's Y is exhausted
|
|
1553
|
+
// but its X still moves will not additionally apply an ancestor's available Y that
|
|
1554
|
+
// same frame. This matches the issue's "break once a container scrolled" contract;
|
|
1555
|
+
// cross-container axis handoff is intentionally out of scope.
|
|
1556
|
+
const scrolled = this.#performScroll(element, direction, frameScale, speed);
|
|
1557
|
+
if (scrolled) {
|
|
1558
|
+
this.#scrollState.containerId = id;
|
|
1559
|
+
this.#scrollState.direction.x = direction.x;
|
|
1560
|
+
this.#scrollState.direction.y = direction.y;
|
|
1561
|
+
this.#scrollState.speed = speed;
|
|
1562
|
+
scrollPerformed = true;
|
|
1563
|
+
break;
|
|
1564
|
+
}
|
|
1343
1565
|
}
|
|
1344
1566
|
}
|
|
1345
1567
|
if (!scrollPerformed) {
|
|
1568
|
+
this.#lastScrollTimestamp = 0;
|
|
1346
1569
|
this.#scrollState.containerId = null;
|
|
1347
1570
|
this.#scrollState.direction.x = 0;
|
|
1348
1571
|
this.#scrollState.direction.y = 0;
|
|
@@ -1351,32 +1574,55 @@ class AutoScrollService {
|
|
|
1351
1574
|
}
|
|
1352
1575
|
/**
|
|
1353
1576
|
* Perform the actual scroll operation.
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1577
|
+
*
|
|
1578
|
+
* Each axis is evaluated independently: a boundary-exhausted axis is skipped
|
|
1579
|
+
* while the other axis still scrolls (e.g. at a corner where vertical is
|
|
1580
|
+
* maxed out but horizontal is available).
|
|
1581
|
+
*
|
|
1582
|
+
* @returns `true` when at least one axis actually scrolled, `false` when the
|
|
1583
|
+
* element was already at its boundary in every requested direction. The
|
|
1584
|
+
* caller uses this to fall through to ancestor containers.
|
|
1585
|
+
*/
|
|
1586
|
+
#performScroll(element, direction, frameScale, speed) {
|
|
1587
|
+
const scrollX = direction.x * speed * frameScale;
|
|
1588
|
+
const scrollY = direction.y * speed * frameScale;
|
|
1589
|
+
// Integer bounds used only as a cheap fast path to skip the assignment.
|
|
1590
|
+
// scrollHeight/clientHeight are rounded integers while scrollTop is fractional on
|
|
1591
|
+
// WebKit and any non-integer zoom/DPR, so this max can overstate the true one by
|
|
1592
|
+
// up to ~1px. It must NOT decide whether a scroll happened — the browser clamps the
|
|
1593
|
+
// assignment, so a before/after comparison is the only reliable truth signal.
|
|
1594
|
+
// Without it, an element pinned at its fractional max reports `scrolled = true`,
|
|
1595
|
+
// permanently claims the tick (blocking ancestor fall-through) and fires the callback
|
|
1596
|
+
// every frame against unchanged offsets (the WebKit placeholder-drift family).
|
|
1359
1597
|
const maxScrollY = element.scrollHeight - element.clientHeight;
|
|
1360
1598
|
const maxScrollX = element.scrollWidth - element.clientWidth;
|
|
1361
|
-
|
|
1362
|
-
return;
|
|
1363
|
-
}
|
|
1364
|
-
if (direction.y > 0 && element.scrollTop >= maxScrollY) {
|
|
1365
|
-
return;
|
|
1366
|
-
}
|
|
1367
|
-
if (direction.x < 0 && element.scrollLeft <= 0) {
|
|
1368
|
-
return;
|
|
1369
|
-
}
|
|
1370
|
-
if (direction.x > 0 && element.scrollLeft >= maxScrollX) {
|
|
1371
|
-
return;
|
|
1372
|
-
}
|
|
1599
|
+
let scrolled = false;
|
|
1373
1600
|
// Use direct property assignment for guaranteed synchronous scroll
|
|
1374
1601
|
// scrollBy() with 'instant' behavior may have async issues in Safari
|
|
1375
1602
|
if (scrollY !== 0) {
|
|
1376
|
-
element.scrollTop
|
|
1603
|
+
const canScrollUp = direction.y < 0 && element.scrollTop > 0;
|
|
1604
|
+
const canScrollDown = direction.y > 0 && element.scrollTop < maxScrollY;
|
|
1605
|
+
if (canScrollUp || canScrollDown) {
|
|
1606
|
+
const before = element.scrollTop;
|
|
1607
|
+
element.scrollTop += scrollY;
|
|
1608
|
+
if (element.scrollTop !== before) {
|
|
1609
|
+
scrolled = true;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1377
1612
|
}
|
|
1378
1613
|
if (scrollX !== 0) {
|
|
1379
|
-
element.scrollLeft
|
|
1614
|
+
const canScrollLeft = direction.x < 0 && element.scrollLeft > 0;
|
|
1615
|
+
const canScrollRight = direction.x > 0 && element.scrollLeft < maxScrollX;
|
|
1616
|
+
if (canScrollLeft || canScrollRight) {
|
|
1617
|
+
const before = element.scrollLeft;
|
|
1618
|
+
element.scrollLeft += scrollX;
|
|
1619
|
+
if (element.scrollLeft !== before) {
|
|
1620
|
+
scrolled = true;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
if (!scrolled) {
|
|
1625
|
+
return false;
|
|
1380
1626
|
}
|
|
1381
1627
|
// Notify callback IMMEDIATELY in the same frame (no RAF delay)
|
|
1382
1628
|
// Delaying via RAF causes cumulative drift during continuous autoscroll
|
|
@@ -1384,6 +1630,13 @@ class AutoScrollService {
|
|
|
1384
1630
|
// Note: No ngZone.run() needed here - the callback (DraggableDirective.#recalculatePlaceholder)
|
|
1385
1631
|
// already enters the zone when updating drag state.
|
|
1386
1632
|
this.#onScrollCallback?.();
|
|
1633
|
+
return true;
|
|
1634
|
+
}
|
|
1635
|
+
#getFrameScale() {
|
|
1636
|
+
const now = performance.now();
|
|
1637
|
+
const elapsedMs = this.#lastScrollTimestamp === 0 ? BASE_FRAME_DURATION_MS : now - this.#lastScrollTimestamp;
|
|
1638
|
+
this.#lastScrollTimestamp = now;
|
|
1639
|
+
return Math.min(Math.max(elapsedMs / BASE_FRAME_DURATION_MS, MIN_SCROLL_FRAME_SCALE), MAX_SCROLL_FRAME_SCALE);
|
|
1387
1640
|
}
|
|
1388
1641
|
/**
|
|
1389
1642
|
* Check if auto-scrolling is currently active.
|
|
@@ -2145,6 +2398,34 @@ function bindResizeObserverHeightSignal(options) {
|
|
|
2145
2398
|
};
|
|
2146
2399
|
}
|
|
2147
2400
|
|
|
2401
|
+
function createAutoScrollRegistration(options) {
|
|
2402
|
+
const destroyRef = inject(DestroyRef);
|
|
2403
|
+
let registeredId = null;
|
|
2404
|
+
const unregisterCurrent = () => {
|
|
2405
|
+
if (registeredId === null) {
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
options.autoScrollService.unregisterContainer(registeredId);
|
|
2409
|
+
registeredId = null;
|
|
2410
|
+
};
|
|
2411
|
+
effect(() => {
|
|
2412
|
+
const enabled = options.enabled();
|
|
2413
|
+
const id = options.getId();
|
|
2414
|
+
const config = options.config();
|
|
2415
|
+
const canRegister = options.canRegister?.() ?? true;
|
|
2416
|
+
if (!enabled || !canRegister) {
|
|
2417
|
+
unregisterCurrent();
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
if (registeredId !== null && registeredId !== id) {
|
|
2421
|
+
unregisterCurrent();
|
|
2422
|
+
}
|
|
2423
|
+
options.autoScrollService.registerContainer(id, options.getElement(), config);
|
|
2424
|
+
registeredId = id;
|
|
2425
|
+
});
|
|
2426
|
+
destroyRef.onDestroy(unregisterCurrent);
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2148
2429
|
/**
|
|
2149
2430
|
* A virtual scroll container that only renders visible items.
|
|
2150
2431
|
*
|
|
@@ -2463,6 +2744,13 @@ class VirtualScrollContainerComponent {
|
|
|
2463
2744
|
/** Track previous total height to detect if we were at bottom before drag ended */
|
|
2464
2745
|
#previousTotalHeight = 0;
|
|
2465
2746
|
constructor() {
|
|
2747
|
+
createAutoScrollRegistration({
|
|
2748
|
+
autoScrollService: this.#autoScrollService,
|
|
2749
|
+
getElement: () => this.#elementRef.nativeElement,
|
|
2750
|
+
getId: () => this.scrollContainerId() ?? this.#generatedScrollId,
|
|
2751
|
+
enabled: () => this.autoScrollEnabled(),
|
|
2752
|
+
config: () => this.autoScrollConfig(),
|
|
2753
|
+
});
|
|
2466
2754
|
// Keep strategy item keys in sync
|
|
2467
2755
|
effect(() => {
|
|
2468
2756
|
const items = this.items();
|
|
@@ -2576,11 +2864,6 @@ class VirtualScrollContainerComponent {
|
|
|
2576
2864
|
});
|
|
2577
2865
|
}
|
|
2578
2866
|
ngOnInit() {
|
|
2579
|
-
// Register with auto-scroll service
|
|
2580
|
-
if (this.autoScrollEnabled()) {
|
|
2581
|
-
const id = this.scrollContainerId() ?? this.#generatedScrollId;
|
|
2582
|
-
this.#autoScrollService.registerContainer(id, this.#elementRef.nativeElement, this.autoScrollConfig());
|
|
2583
|
-
}
|
|
2584
2867
|
// Set up ResizeObserver for dynamic height measurement
|
|
2585
2868
|
if (this.dynamicItemHeight()) {
|
|
2586
2869
|
this.#setupItemResizeObserver();
|
|
@@ -2615,9 +2898,6 @@ class VirtualScrollContainerComponent {
|
|
|
2615
2898
|
}
|
|
2616
2899
|
this.#observedElementByKey.clear();
|
|
2617
2900
|
this.#itemResizeObserver?.disconnect();
|
|
2618
|
-
// Unregister from auto-scroll service
|
|
2619
|
-
const id = this.scrollContainerId() ?? this.#generatedScrollId;
|
|
2620
|
-
this.#autoScrollService.unregisterContainer(id);
|
|
2621
2901
|
}
|
|
2622
2902
|
/**
|
|
2623
2903
|
* Set up ResizeObserver for measuring individual item heights.
|
|
@@ -2662,8 +2942,8 @@ class VirtualScrollContainerComponent {
|
|
|
2662
2942
|
if (entry.type !== 'item' || !entry.data)
|
|
2663
2943
|
continue;
|
|
2664
2944
|
const key = idFn(entry.data);
|
|
2665
|
-
// Find the DOM element for this item by its data-draggable-id
|
|
2666
|
-
const el = wrapper
|
|
2945
|
+
// Find the DOM element for this item by its data-draggable-id.
|
|
2946
|
+
const el = queryByAttribute(wrapper, 'data-draggable-id', key);
|
|
2667
2947
|
if (el) {
|
|
2668
2948
|
nextObservedByKey.set(key, el);
|
|
2669
2949
|
}
|
|
@@ -3112,6 +3392,18 @@ function createEffectiveGroupSignal(options) {
|
|
|
3112
3392
|
});
|
|
3113
3393
|
}
|
|
3114
3394
|
|
|
3395
|
+
function normalizeDropDestinationIndex({ sourceIndex, placeholderIndex, sourceDroppableId, activeDroppableId, }) {
|
|
3396
|
+
if (placeholderIndex === null) {
|
|
3397
|
+
return null;
|
|
3398
|
+
}
|
|
3399
|
+
if (sourceDroppableId !== null &&
|
|
3400
|
+
sourceDroppableId === activeDroppableId &&
|
|
3401
|
+
sourceIndex < placeholderIndex) {
|
|
3402
|
+
return placeholderIndex - 1;
|
|
3403
|
+
}
|
|
3404
|
+
return placeholderIndex;
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3115
3407
|
/**
|
|
3116
3408
|
* Marks an element as a valid drop target within the virtual scroll drag-and-drop system.
|
|
3117
3409
|
*
|
|
@@ -3137,6 +3429,7 @@ class DroppableDirective {
|
|
|
3137
3429
|
#elementRef = inject((ElementRef));
|
|
3138
3430
|
#dragState = inject(DragStateService);
|
|
3139
3431
|
#autoScroll = inject(AutoScrollService);
|
|
3432
|
+
#positionCalculator = inject(PositionCalculatorService);
|
|
3140
3433
|
#parentGroup = inject(VDND_GROUP_TOKEN, { optional: true });
|
|
3141
3434
|
/** Unique identifier for this droppable */
|
|
3142
3435
|
vdndDroppable = input.required(...(ngDevMode ? [{ debugName: "vdndDroppable" }] : []));
|
|
@@ -3180,62 +3473,66 @@ class DroppableDirective {
|
|
|
3180
3473
|
}
|
|
3181
3474
|
return this.#dragState.placeholderId();
|
|
3182
3475
|
}, ...(ngDevMode ? [{ debugName: "placeholderId" }] : []));
|
|
3183
|
-
/** Track previous active state
|
|
3476
|
+
/** Track previous active state to detect the drag-end transition */
|
|
3184
3477
|
#wasActive = false;
|
|
3185
|
-
/** Cached state for handling drop (since state is cleared before effect fires) */
|
|
3186
|
-
#cachedDragState = null;
|
|
3187
|
-
ngOnInit() {
|
|
3188
|
-
// Without a group, this droppable can't participate in DnD (fail gracefully).
|
|
3189
|
-
if (!this.effectiveGroup()) {
|
|
3190
|
-
return;
|
|
3191
|
-
}
|
|
3192
|
-
// Register with auto-scroll service only if this element is actually scrollable
|
|
3193
|
-
// (i.e., has overflow: auto/scroll and content taller than container)
|
|
3194
|
-
if (this.autoScrollEnabled() && this.#isScrollable()) {
|
|
3195
|
-
this.#autoScroll.registerContainer(this.vdndDroppable(), this.#elementRef.nativeElement, this.autoScrollConfig());
|
|
3196
|
-
}
|
|
3197
|
-
}
|
|
3198
3478
|
/**
|
|
3199
|
-
*
|
|
3479
|
+
* The terminal drag snapshot this droppable has already processed. `endedDragState`
|
|
3480
|
+
* persists until the next drag starts, so this guards against replaying the same drop
|
|
3481
|
+
* when the effect re-runs (e.g. `disabled()` toggling) after the drag has ended.
|
|
3200
3482
|
*/
|
|
3201
|
-
#
|
|
3202
|
-
const el = this.#elementRef.nativeElement;
|
|
3203
|
-
const style = window.getComputedStyle(el);
|
|
3204
|
-
const overflowY = style.overflowY;
|
|
3205
|
-
const overflowX = style.overflowX;
|
|
3206
|
-
// Check if overflow allows scrolling
|
|
3207
|
-
const hasScrollableOverflow = overflowY === 'auto' ||
|
|
3208
|
-
overflowY === 'scroll' ||
|
|
3209
|
-
overflowX === 'auto' ||
|
|
3210
|
-
overflowX === 'scroll';
|
|
3211
|
-
if (!hasScrollableOverflow) {
|
|
3212
|
-
return false;
|
|
3213
|
-
}
|
|
3214
|
-
// Check if content is larger than container
|
|
3215
|
-
return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
|
|
3216
|
-
}
|
|
3483
|
+
#handledEndedState = null;
|
|
3217
3484
|
constructor() {
|
|
3218
|
-
|
|
3485
|
+
createAutoScrollRegistration({
|
|
3486
|
+
autoScrollService: this.#autoScroll,
|
|
3487
|
+
getElement: () => this.#elementRef.nativeElement,
|
|
3488
|
+
getId: () => this.vdndDroppable(),
|
|
3489
|
+
enabled: () => this.autoScrollEnabled(),
|
|
3490
|
+
config: () => this.autoScrollConfig(),
|
|
3491
|
+
// Register whenever a group is resolved; do NOT gate on scrollability. DOM size is
|
|
3492
|
+
// not reactive, so a list that becomes scrollable after init (async data, resize)
|
|
3493
|
+
// would never register. AutoScrollService filters by live scroll geometry per drag.
|
|
3494
|
+
canRegister: () => Boolean(this.effectiveGroup()),
|
|
3495
|
+
});
|
|
3496
|
+
// Notify the calculator once this droppable is rendered (host data attributes applied).
|
|
3497
|
+
// Matters when it mounts DURING an active drag — the candidate snapshot was frozen at
|
|
3498
|
+
// drag start, so without this a conditionally rendered list would never become a target.
|
|
3499
|
+
afterNextRender(() => {
|
|
3500
|
+
const group = this.effectiveGroup();
|
|
3501
|
+
if (group) {
|
|
3502
|
+
this.#positionCalculator.notifyCandidatesChanged(group);
|
|
3503
|
+
}
|
|
3504
|
+
});
|
|
3505
|
+
// React to state changes and handle drop events.
|
|
3506
|
+
//
|
|
3507
|
+
// Keyed only on the low-frequency fields (isActive/isDragging/draggedItem). The final
|
|
3508
|
+
// placeholder/index values #handleDrop needs are captured by DragStateService.endDrag()
|
|
3509
|
+
// into endedDragState() at the drag-end boundary, so this effect never has to poll the
|
|
3510
|
+
// high-frequency cursor/placeholder signals — reading them here would make it re-run and
|
|
3511
|
+
// re-allocate a snapshot on every 60fps frame, defeating the service's signal splitting.
|
|
3219
3512
|
effect(() => {
|
|
3220
3513
|
const active = this.isActive();
|
|
3221
3514
|
const draggedItem = this.#dragState.draggedItem();
|
|
3222
3515
|
const isDragging = this.#dragState.isDragging();
|
|
3223
|
-
//
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
//
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3516
|
+
// Handle drag end (drop). This droppable is the release target when it was either
|
|
3517
|
+
// observed active during the drag (#wasActive) OR named as the target in the ended
|
|
3518
|
+
// drag snapshot. The snapshot path covers the pointer-up flush, where the final
|
|
3519
|
+
// position is processed and the state cleared in the same synchronous task, so this
|
|
3520
|
+
// effect never observes isActive() === true mid-drag and #wasActive stays false.
|
|
3521
|
+
const endedState = untracked(() => this.#dragState.endedDragState());
|
|
3522
|
+
const endedTargetedThis = endedState?.activeDroppableId === this.vdndDroppable();
|
|
3523
|
+
if (!isDragging && draggedItem === null && (this.#wasActive || endedTargetedThis)) {
|
|
3524
|
+
// Consume each terminal snapshot exactly once. endedDragState persists until the
|
|
3525
|
+
// next drag starts and disabled()/other inputs re-run this effect, so without a
|
|
3526
|
+
// per-snapshot guard a later state change would replay the same historical drop.
|
|
3527
|
+
if (endedState !== this.#handledEndedState) {
|
|
3528
|
+
this.#handledEndedState = endedState;
|
|
3529
|
+
// Emit only for an enabled target that ended normally. A target disabled at
|
|
3530
|
+
// release still consumes the snapshot (so re-enabling can't resurrect the drop)
|
|
3531
|
+
// but emits nothing, keeping dragEnd and drop consistent.
|
|
3532
|
+
if (!this.disabled() && !this.#dragState.wasCancelled()) {
|
|
3533
|
+
this.#handleDrop();
|
|
3534
|
+
}
|
|
3233
3535
|
}
|
|
3234
|
-
this.#cachedDragState = null;
|
|
3235
|
-
}
|
|
3236
|
-
// Clear cached state when leaving without dropping
|
|
3237
|
-
if (!active && this.#wasActive && isDragging) {
|
|
3238
|
-
this.#cachedDragState = null;
|
|
3239
3536
|
}
|
|
3240
3537
|
this.#wasActive = active;
|
|
3241
3538
|
});
|
|
@@ -3247,13 +3544,20 @@ class DroppableDirective {
|
|
|
3247
3544
|
}
|
|
3248
3545
|
// Unregister from auto-scroll
|
|
3249
3546
|
this.#autoScroll.unregisterContainer(this.vdndDroppable());
|
|
3547
|
+
// If this droppable unmounts mid-drag, tell the calculator so it drops it from the
|
|
3548
|
+
// frozen candidate list (deferred re-query runs once the element has left the DOM).
|
|
3549
|
+
const group = untracked(() => this.effectiveGroup());
|
|
3550
|
+
if (group) {
|
|
3551
|
+
this.#positionCalculator.notifyCandidatesChanged(group);
|
|
3552
|
+
}
|
|
3250
3553
|
}
|
|
3251
3554
|
/**
|
|
3252
3555
|
* Handle a drop on this droppable.
|
|
3253
3556
|
*/
|
|
3254
3557
|
#handleDrop() {
|
|
3255
|
-
//
|
|
3256
|
-
|
|
3558
|
+
// The live state is cleared before this effect fires, so read the terminal snapshot
|
|
3559
|
+
// captured by endDrag()/cancelDrag() immediately before the reset.
|
|
3560
|
+
const state = untracked(() => this.#dragState.endedDragState());
|
|
3257
3561
|
if (!state?.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
|
|
3258
3562
|
return;
|
|
3259
3563
|
}
|
|
@@ -3264,16 +3568,17 @@ class DroppableDirective {
|
|
|
3264
3568
|
// be in the DOM after autoscroll (it gets virtualized out of view)
|
|
3265
3569
|
const sourceIndex = state.sourceIndex ?? this.#getItemIndex(state.draggedItem.draggableId, sourceDroppableId);
|
|
3266
3570
|
// Use the pre-calculated placeholderIndex if available (more reliable)
|
|
3267
|
-
// Fall back to DOM-based calculation if not
|
|
3268
|
-
|
|
3571
|
+
// Fall back to DOM-based calculation if not. Then normalize to the same
|
|
3572
|
+
// consumer-facing insertion index emitted by DragEndEvent.
|
|
3573
|
+
const placeholderIndex = state.placeholderIndex !== null && state.placeholderIndex !== undefined
|
|
3269
3574
|
? state.placeholderIndex
|
|
3270
3575
|
: this.#getDestinationIndex(placeholderId);
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
}
|
|
3576
|
+
const destinationIndex = normalizeDropDestinationIndex({
|
|
3577
|
+
sourceIndex,
|
|
3578
|
+
placeholderIndex,
|
|
3579
|
+
sourceDroppableId,
|
|
3580
|
+
activeDroppableId: this.vdndDroppable(),
|
|
3581
|
+
});
|
|
3277
3582
|
this.drop.emit({
|
|
3278
3583
|
source: {
|
|
3279
3584
|
draggableId: state.draggedItem.draggableId,
|
|
@@ -3295,7 +3600,7 @@ class DroppableDirective {
|
|
|
3295
3600
|
*/
|
|
3296
3601
|
#getItemIndex(draggableId, droppableId) {
|
|
3297
3602
|
// Find all draggables in the source droppable
|
|
3298
|
-
const droppable = document
|
|
3603
|
+
const droppable = queryByAttribute(document, 'data-droppable-id', droppableId);
|
|
3299
3604
|
if (!droppable) {
|
|
3300
3605
|
return 0;
|
|
3301
3606
|
}
|
|
@@ -3349,7 +3654,7 @@ class DroppableDirective {
|
|
|
3349
3654
|
return this.#elementRef.nativeElement.scrollHeight;
|
|
3350
3655
|
}
|
|
3351
3656
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
3352
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: DroppableDirective, isStandalone: true, selector: "[vdndDroppable]", inputs: { vdndDroppable: { classPropertyName: "vdndDroppable", publicName: "vdndDroppable", isSignal: true, isRequired: true, transformFunction: null }, vdndDroppableGroup: { classPropertyName: "vdndDroppableGroup", publicName: "vdndDroppableGroup", isSignal: true, isRequired: false, transformFunction: null }, vdndDroppableData: { classPropertyName: "vdndDroppableData", publicName: "vdndDroppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", 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 }, constrainToContainer: { classPropertyName: "constrainToContainer", publicName: "constrainToContainer", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { drop: "drop" }, host: { properties: { "attr.data-droppable-id": "vdndDroppable()", "attr.data-droppable-group": "effectiveGroup()", "attr.data-constrain-to-container": "constrainToContainer() || null", "attr.aria-dropeffect": "\"move\"", "class.vdnd-droppable": "true", "class.vdnd-droppable-active": "isActive()", "class.vdnd-droppable-disabled": "disabled()" } }, ngImport: i0 });
|
|
3657
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: DroppableDirective, isStandalone: true, selector: "[vdndDroppable]", inputs: { vdndDroppable: { classPropertyName: "vdndDroppable", publicName: "vdndDroppable", isSignal: true, isRequired: true, transformFunction: null }, vdndDroppableGroup: { classPropertyName: "vdndDroppableGroup", publicName: "vdndDroppableGroup", isSignal: true, isRequired: false, transformFunction: null }, vdndDroppableData: { classPropertyName: "vdndDroppableData", publicName: "vdndDroppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", 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 }, constrainToContainer: { classPropertyName: "constrainToContainer", publicName: "constrainToContainer", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { drop: "drop" }, host: { properties: { "attr.data-droppable-id": "vdndDroppable()", "attr.data-droppable-group": "effectiveGroup()", "attr.data-droppable-disabled": "disabled() || null", "attr.data-constrain-to-container": "constrainToContainer() || null", "attr.aria-dropeffect": "\"move\"", "class.vdnd-droppable": "true", "class.vdnd-droppable-active": "isActive()", "class.vdnd-droppable-disabled": "disabled()" } }, ngImport: i0 });
|
|
3353
3658
|
}
|
|
3354
3659
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableDirective, decorators: [{
|
|
3355
3660
|
type: Directive,
|
|
@@ -3358,6 +3663,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
3358
3663
|
host: {
|
|
3359
3664
|
'[attr.data-droppable-id]': 'vdndDroppable()',
|
|
3360
3665
|
'[attr.data-droppable-group]': 'effectiveGroup()',
|
|
3666
|
+
'[attr.data-droppable-disabled]': 'disabled() || null',
|
|
3361
3667
|
'[attr.data-constrain-to-container]': 'constrainToContainer() || null',
|
|
3362
3668
|
'[attr.aria-dropeffect]': '"move"',
|
|
3363
3669
|
'[class.vdnd-droppable]': 'true',
|
|
@@ -3605,6 +3911,7 @@ class VirtualViewportComponent {
|
|
|
3605
3911
|
contentTransform = computed(() => {
|
|
3606
3912
|
const startIndex = this.#renderStartIndex();
|
|
3607
3913
|
const s = this.#strategy();
|
|
3914
|
+
s.version();
|
|
3608
3915
|
const offset = s.getOffsetForIndex(startIndex);
|
|
3609
3916
|
return `translateY(${offset}px)`;
|
|
3610
3917
|
}, ...(ngDevMode ? [{ debugName: "contentTransform" }] : []));
|
|
@@ -3637,15 +3944,22 @@ class VirtualViewportComponent {
|
|
|
3637
3944
|
this.scrollTo({ top: newPosition });
|
|
3638
3945
|
}
|
|
3639
3946
|
// ========== Lifecycle ==========
|
|
3947
|
+
constructor() {
|
|
3948
|
+
createAutoScrollRegistration({
|
|
3949
|
+
autoScrollService: this.#autoScrollService,
|
|
3950
|
+
getElement: () => this.nativeElement,
|
|
3951
|
+
getId: () => this.scrollContainerId() ?? this.#generatedScrollId,
|
|
3952
|
+
enabled: () => this.autoScrollEnabled(),
|
|
3953
|
+
config: () => this.autoScrollConfig(),
|
|
3954
|
+
});
|
|
3955
|
+
}
|
|
3640
3956
|
ngOnInit() {
|
|
3641
3957
|
this.#setupScrollListener();
|
|
3642
3958
|
this.#setupResizeObserver();
|
|
3643
|
-
this.#registerAutoScroll();
|
|
3644
3959
|
}
|
|
3645
3960
|
ngOnDestroy() {
|
|
3646
3961
|
this.#scrollCleanup?.();
|
|
3647
3962
|
this.#resizeCleanup?.();
|
|
3648
|
-
this.#unregisterAutoScroll();
|
|
3649
3963
|
}
|
|
3650
3964
|
// ========== Private Methods ==========
|
|
3651
3965
|
/** Minimum scroll delta (px) to trigger signal update */
|
|
@@ -3666,16 +3980,6 @@ class VirtualViewportComponent {
|
|
|
3666
3980
|
minDeltaPx: 1,
|
|
3667
3981
|
});
|
|
3668
3982
|
}
|
|
3669
|
-
#registerAutoScroll() {
|
|
3670
|
-
if (this.autoScrollEnabled()) {
|
|
3671
|
-
const id = this.scrollContainerId() ?? this.#generatedScrollId;
|
|
3672
|
-
this.#autoScrollService.registerContainer(id, this.nativeElement, this.autoScrollConfig());
|
|
3673
|
-
}
|
|
3674
|
-
}
|
|
3675
|
-
#unregisterAutoScroll() {
|
|
3676
|
-
const id = this.scrollContainerId() ?? this.#generatedScrollId;
|
|
3677
|
-
this.#autoScrollService.unregisterContainer(id);
|
|
3678
|
-
}
|
|
3679
3983
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualViewportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3680
3984
|
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: [
|
|
3681
3985
|
{ provide: VDND_VIRTUAL_VIEWPORT, useExisting: VirtualViewportComponent },
|
|
@@ -3722,7 +4026,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
3722
4026
|
<ng-content></ng-content>
|
|
3723
4027
|
</div>
|
|
3724
4028
|
`, 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"] }]
|
|
3725
|
-
}], 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 }] }] } });
|
|
4029
|
+
}], 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 }] }] } });
|
|
3726
4030
|
|
|
3727
4031
|
/**
|
|
3728
4032
|
* Marks an element as the header content for a `vdnd-virtual-content` component.
|
|
@@ -3875,6 +4179,7 @@ class VirtualContentComponent {
|
|
|
3875
4179
|
contentTransform = computed(() => {
|
|
3876
4180
|
const startIndex = this.#renderStartIndex();
|
|
3877
4181
|
const s = this.#strategy();
|
|
4182
|
+
s.version();
|
|
3878
4183
|
const offset = s.getOffsetForIndex(startIndex);
|
|
3879
4184
|
return `translateY(${offset}px)`;
|
|
3880
4185
|
}, ...(ngDevMode ? [{ debugName: "contentTransform" }] : []));
|
|
@@ -4072,36 +4377,46 @@ class KeyboardDragHandler {
|
|
|
4072
4377
|
/**
|
|
4073
4378
|
* Unified key dispatch. Returns true if the key was handled.
|
|
4074
4379
|
* Used by both host bindings and the document-level listener.
|
|
4380
|
+
*
|
|
4381
|
+
* Handled keys are consumed entirely (preventDefault + stopPropagation): right after pickup
|
|
4382
|
+
* the source element can still be focused because Angular has not applied display:none yet,
|
|
4383
|
+
* and without stopping propagation the same keydown would be processed by both the element's
|
|
4384
|
+
* host binding and the document-level listener — moving the item two positions per press.
|
|
4075
4385
|
*/
|
|
4076
4386
|
handleKey(event) {
|
|
4077
4387
|
if (!this.isActive()) {
|
|
4078
4388
|
return false;
|
|
4079
4389
|
}
|
|
4080
|
-
|
|
4390
|
+
const handled = this.#dispatchKey(event.key);
|
|
4391
|
+
if (handled) {
|
|
4392
|
+
event.preventDefault();
|
|
4393
|
+
event.stopPropagation();
|
|
4394
|
+
}
|
|
4395
|
+
return handled;
|
|
4396
|
+
}
|
|
4397
|
+
/**
|
|
4398
|
+
* Execute the drag action for a key. Returns true if the key maps to an action.
|
|
4399
|
+
*/
|
|
4400
|
+
#dispatchKey(key) {
|
|
4401
|
+
switch (key) {
|
|
4081
4402
|
case ' ': // Space
|
|
4082
4403
|
case 'Enter':
|
|
4083
|
-
event.preventDefault();
|
|
4084
4404
|
this.complete();
|
|
4085
4405
|
return true;
|
|
4086
4406
|
case 'Escape':
|
|
4087
4407
|
case 'Tab':
|
|
4088
|
-
event.preventDefault();
|
|
4089
4408
|
this.cancel();
|
|
4090
4409
|
return true;
|
|
4091
4410
|
case 'ArrowUp':
|
|
4092
|
-
event.preventDefault();
|
|
4093
4411
|
this.#deps.keyboardDrag.moveUp();
|
|
4094
4412
|
return true;
|
|
4095
4413
|
case 'ArrowDown':
|
|
4096
|
-
event.preventDefault();
|
|
4097
4414
|
this.#deps.keyboardDrag.moveDown();
|
|
4098
4415
|
return true;
|
|
4099
4416
|
case 'ArrowLeft':
|
|
4100
|
-
event.preventDefault();
|
|
4101
4417
|
this.#moveToAdjacentDroppable('left');
|
|
4102
4418
|
return true;
|
|
4103
4419
|
case 'ArrowRight':
|
|
4104
|
-
event.preventDefault();
|
|
4105
4420
|
this.#moveToAdjacentDroppable('right');
|
|
4106
4421
|
return true;
|
|
4107
4422
|
default:
|
|
@@ -4114,7 +4429,21 @@ class KeyboardDragHandler {
|
|
|
4114
4429
|
complete() {
|
|
4115
4430
|
const ctx = this.#deps.getContext();
|
|
4116
4431
|
const sourceIndex = this.#deps.dragState.sourceIndex() ?? 0;
|
|
4117
|
-
const
|
|
4432
|
+
const activeDroppableId = this.#deps.dragState.activeDroppableId();
|
|
4433
|
+
// Revalidate the active target: it may have been disabled after the drag navigated
|
|
4434
|
+
// into it. A disabled (or missing) target is not a valid drop — report no destination.
|
|
4435
|
+
// The droppable's own disabled guard suppresses the drop event, keeping dragEnd and
|
|
4436
|
+
// drop consistent (non-cancelled dragEnd with destinationIndex: null, no drop).
|
|
4437
|
+
const hasValidTarget = activeDroppableId !== null &&
|
|
4438
|
+
!this.#deps.positionCalculator.isDroppableDisabledById(activeDroppableId);
|
|
4439
|
+
const destinationIndex = hasValidTarget
|
|
4440
|
+
? normalizeDropDestinationIndex({
|
|
4441
|
+
sourceIndex,
|
|
4442
|
+
placeholderIndex: this.#deps.dragState.placeholderIndex(),
|
|
4443
|
+
sourceDroppableId: this.#deps.dragState.sourceDroppableId(),
|
|
4444
|
+
activeDroppableId,
|
|
4445
|
+
})
|
|
4446
|
+
: null;
|
|
4118
4447
|
// Remove document listener
|
|
4119
4448
|
this.#cleanupDocumentListener();
|
|
4120
4449
|
// Clear droppable metadata cache from this drag session
|
|
@@ -4174,13 +4503,14 @@ class KeyboardDragHandler {
|
|
|
4174
4503
|
// (the directive may be destroyed during cross-list moves)
|
|
4175
4504
|
const destinationDroppableId = this.#deps.dragState.activeDroppableId();
|
|
4176
4505
|
afterNextRender(() => {
|
|
4177
|
-
const element = document
|
|
4506
|
+
const element = queryByAttribute(document, 'data-draggable-id', draggableId);
|
|
4178
4507
|
if (element) {
|
|
4179
4508
|
element.focus();
|
|
4180
4509
|
}
|
|
4181
4510
|
else if (destinationDroppableId) {
|
|
4182
4511
|
// Fallback: focus the first draggable in the destination container
|
|
4183
|
-
const
|
|
4512
|
+
const destination = queryByAttribute(document, 'data-droppable-id', destinationDroppableId);
|
|
4513
|
+
const firstDraggable = destination?.querySelector('[data-draggable-id]');
|
|
4184
4514
|
firstDraggable?.focus();
|
|
4185
4515
|
}
|
|
4186
4516
|
}, { injector: this.#deps.envInjector });
|
|
@@ -4201,10 +4531,19 @@ class KeyboardDragHandler {
|
|
|
4201
4531
|
if (!adjacent) {
|
|
4202
4532
|
return;
|
|
4203
4533
|
}
|
|
4534
|
+
// Derive the destination list's item count from DragIndexCalculatorService — the single
|
|
4535
|
+
// source of truth that correctly handles registered strategies, dynamic-height lists, and
|
|
4536
|
+
// vdnd-virtual-content (page-scroll) lists. Arrowing back to the origin lands on the source
|
|
4537
|
+
// list, so compute isSameList rather than assuming a distinct target.
|
|
4538
|
+
const itemCount = this.#deps.dragIndexCalculator.getTotalItemCount({
|
|
4539
|
+
droppableElement: adjacent.element,
|
|
4540
|
+
isSameList: adjacent.id === this.#deps.dragState.sourceDroppableId(),
|
|
4541
|
+
draggedItemHeight: this.#deps.dragState.draggedItem()?.height ?? 0,
|
|
4542
|
+
});
|
|
4204
4543
|
// Maintain the current target index (clamped to the new list's size)
|
|
4205
4544
|
const currentTargetIndex = this.#deps.keyboardDrag.targetIndex() ?? 0;
|
|
4206
|
-
const targetIndex = Math.min(currentTargetIndex,
|
|
4207
|
-
this.#deps.keyboardDrag.moveToDroppable(adjacent.id, targetIndex,
|
|
4545
|
+
const targetIndex = Math.min(currentTargetIndex, itemCount);
|
|
4546
|
+
this.#deps.keyboardDrag.moveToDroppable(adjacent.id, targetIndex, itemCount);
|
|
4208
4547
|
}
|
|
4209
4548
|
/**
|
|
4210
4549
|
* Document-level keydown listener active during keyboard drag.
|
|
@@ -4511,7 +4850,12 @@ class DraggableDirective {
|
|
|
4511
4850
|
* Set to 0 for immediate drag (default behavior).
|
|
4512
4851
|
*/
|
|
4513
4852
|
dragDelay = input(0, ...(ngDevMode ? [{ debugName: "dragDelay" }] : []));
|
|
4514
|
-
/**
|
|
4853
|
+
/**
|
|
4854
|
+
* Lock dragging by freezing one axis. The value names the axis that stays
|
|
4855
|
+
* fixed: `'x'` freezes the X coordinate (vertical-only movement), `'y'`
|
|
4856
|
+
* freezes the Y coordinate (horizontal-only movement). `null` allows free
|
|
4857
|
+
* movement. Note: this is the opposite of Angular CDK's `cdkDragLockAxis`.
|
|
4858
|
+
*/
|
|
4515
4859
|
lockAxis = input(null, ...(ngDevMode ? [{ debugName: "lockAxis" }] : []));
|
|
4516
4860
|
/** Emits when drag starts */
|
|
4517
4861
|
dragStart = output();
|
|
@@ -4690,7 +5034,6 @@ class DraggableDirective {
|
|
|
4690
5034
|
const clonedElement = this.#overlayContainer.hasTemplatePreview()
|
|
4691
5035
|
? undefined
|
|
4692
5036
|
: this.#elementClone.cloneElement(element);
|
|
4693
|
-
const parentDroppableId = this.#getParentDroppableId();
|
|
4694
5037
|
// Find droppable and calculate initial placeholder position
|
|
4695
5038
|
// This fixes the UI glitch by ensuring placeholder is set before the element is hidden
|
|
4696
5039
|
const groupName = this.#effectiveGroup();
|
|
@@ -4699,6 +5042,10 @@ class DraggableDirective {
|
|
|
4699
5042
|
this.#pointerHandler.cleanup();
|
|
4700
5043
|
return;
|
|
4701
5044
|
}
|
|
5045
|
+
const parentDroppableElement = this.#positionCalculator.getDroppableParent(element, groupName);
|
|
5046
|
+
const parentDroppableId = parentDroppableElement
|
|
5047
|
+
? this.#positionCalculator.getDroppableId(parentDroppableElement)
|
|
5048
|
+
: null;
|
|
4702
5049
|
// Snapshot droppable rects for this drag session so subsequent hit-testing is
|
|
4703
5050
|
// pure geometry (no elementFromPoint layout flush per pointermove).
|
|
4704
5051
|
this.#positionCalculator.beginDragSession(groupName);
|
|
@@ -4714,7 +5061,7 @@ class DraggableDirective {
|
|
|
4714
5061
|
: parentDroppableId;
|
|
4715
5062
|
// Calculate source index BEFORE the element is hidden (display: none)
|
|
4716
5063
|
// This is critical because getBoundingClientRect() returns all zeros for hidden elements
|
|
4717
|
-
const sourceIndex = this.#calculateSourceIndex(element,
|
|
5064
|
+
const sourceIndex = this.#calculateSourceIndex(element, parentDroppableElement);
|
|
4718
5065
|
let initialPlaceholderId = null;
|
|
4719
5066
|
let initialPlaceholderIndex = null;
|
|
4720
5067
|
if (droppableElement) {
|
|
@@ -4810,10 +5157,24 @@ class DraggableDirective {
|
|
|
4810
5157
|
return strategy.findIndexAtOffset(relativeY);
|
|
4811
5158
|
}
|
|
4812
5159
|
}
|
|
4813
|
-
//
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
5160
|
+
// Preserve the geometry fallback for a virtual container whose strategy is unavailable.
|
|
5161
|
+
if (virtualScroll || virtualContent) {
|
|
5162
|
+
const itemHeight = rect.height || 50;
|
|
5163
|
+
const relativeY = rect.top - containerRect.top + scrollTop;
|
|
5164
|
+
return Math.round(relativeY / itemHeight);
|
|
5165
|
+
}
|
|
5166
|
+
// Non-virtual fallback: derive the logical index from preceding draggable siblings.
|
|
5167
|
+
// Geometry-based division is incorrect when the list has padding, gaps, margins,
|
|
5168
|
+
// or variable-height items.
|
|
5169
|
+
let sourceIndex = 0;
|
|
5170
|
+
let sibling = element.previousElementSibling;
|
|
5171
|
+
while (sibling) {
|
|
5172
|
+
if (sibling.matches('[data-draggable-id]')) {
|
|
5173
|
+
sourceIndex++;
|
|
5174
|
+
}
|
|
5175
|
+
sibling = sibling.previousElementSibling;
|
|
5176
|
+
}
|
|
5177
|
+
return sourceIndex;
|
|
4817
5178
|
}
|
|
4818
5179
|
/**
|
|
4819
5180
|
* Recalculate placeholder position (called during auto-scroll).
|
|
@@ -4959,6 +5320,16 @@ class DraggableDirective {
|
|
|
4959
5320
|
* End the drag operation.
|
|
4960
5321
|
*/
|
|
4961
5322
|
#endDrag(cancelled) {
|
|
5323
|
+
// Flush the final pointer position for a real drop. Pointer moves only QUEUE a cursor
|
|
5324
|
+
// for the next RAF tick; on release the scheduler is stopped below and any un-processed
|
|
5325
|
+
// cursor is discarded, so without this the drop would resolve against the last
|
|
5326
|
+
// RAF-PROCESSED position — a release that just entered a different (e.g. newly disabled)
|
|
5327
|
+
// droppable could otherwise land on the previously-targeted one. Guarded on group
|
|
5328
|
+
// presence so it never re-enters #endDrag via #updateDrag's group-lost cancel path.
|
|
5329
|
+
// Cancels skip this: their destination is intentionally null.
|
|
5330
|
+
if (!cancelled && this.#lastRawPosition && this.isDragging() && this.#effectiveGroup()) {
|
|
5331
|
+
this.#updateDrag(this.#lastRawPosition);
|
|
5332
|
+
}
|
|
4962
5333
|
// Stop the scheduler RAF loop first, then remove the autoscroll participant.
|
|
4963
5334
|
this.#scheduler.stop();
|
|
4964
5335
|
this.#autoScroll.stopMonitoring();
|
|
@@ -4971,20 +5342,14 @@ class DraggableDirective {
|
|
|
4971
5342
|
this.#constraintElement = null;
|
|
4972
5343
|
this.#lastRawPosition = null;
|
|
4973
5344
|
const sourceIndex = this.#dragState.sourceIndex() ?? 0;
|
|
4974
|
-
const
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
if (sourceDroppableId !== null && sourceDroppableId === activeDroppableId) {
|
|
4983
|
-
if (sourceIndex < placeholderIndex) {
|
|
4984
|
-
destinationIndex = placeholderIndex - 1;
|
|
4985
|
-
}
|
|
4986
|
-
}
|
|
4987
|
-
}
|
|
5345
|
+
const destinationIndex = cancelled
|
|
5346
|
+
? null
|
|
5347
|
+
: normalizeDropDestinationIndex({
|
|
5348
|
+
sourceIndex,
|
|
5349
|
+
placeholderIndex: this.#dragState.placeholderIndex(),
|
|
5350
|
+
sourceDroppableId: this.#dragState.sourceDroppableId(),
|
|
5351
|
+
activeDroppableId: this.#dragState.activeDroppableId(),
|
|
5352
|
+
});
|
|
4988
5353
|
// Emit drag end event
|
|
4989
5354
|
this.dragEnd.emit({
|
|
4990
5355
|
draggableId: this.vdndDraggable(),
|
|
@@ -5101,6 +5466,15 @@ class ScrollableDirective {
|
|
|
5101
5466
|
/** Auto-scroll configuration */
|
|
5102
5467
|
autoScrollConfig = input({}, ...(ngDevMode ? [{ debugName: "autoScrollConfig" }] : []));
|
|
5103
5468
|
// ========== VdndScrollContainer Implementation ==========
|
|
5469
|
+
constructor() {
|
|
5470
|
+
createAutoScrollRegistration({
|
|
5471
|
+
autoScrollService: this.#autoScrollService,
|
|
5472
|
+
getElement: () => this.nativeElement,
|
|
5473
|
+
getId: () => this.scrollContainerId() ?? this.#generatedScrollId,
|
|
5474
|
+
enabled: () => this.autoScrollEnabled(),
|
|
5475
|
+
config: () => this.autoScrollConfig(),
|
|
5476
|
+
});
|
|
5477
|
+
}
|
|
5104
5478
|
get nativeElement() {
|
|
5105
5479
|
return this.#elementRef.nativeElement;
|
|
5106
5480
|
}
|
|
@@ -5124,12 +5498,10 @@ class ScrollableDirective {
|
|
|
5124
5498
|
ngOnInit() {
|
|
5125
5499
|
this.#setupScrollListener();
|
|
5126
5500
|
this.#setupResizeObserver();
|
|
5127
|
-
this.#registerAutoScroll();
|
|
5128
5501
|
}
|
|
5129
5502
|
ngOnDestroy() {
|
|
5130
5503
|
this.#scrollCleanup?.();
|
|
5131
5504
|
this.#resizeCleanup?.();
|
|
5132
|
-
this.#unregisterAutoScroll();
|
|
5133
5505
|
}
|
|
5134
5506
|
// ========== Private Methods ==========
|
|
5135
5507
|
/** Minimum scroll delta (px) to trigger signal update */
|
|
@@ -5150,16 +5522,6 @@ class ScrollableDirective {
|
|
|
5150
5522
|
minDeltaPx: 1,
|
|
5151
5523
|
});
|
|
5152
5524
|
}
|
|
5153
|
-
#registerAutoScroll() {
|
|
5154
|
-
if (this.autoScrollEnabled()) {
|
|
5155
|
-
const id = this.scrollContainerId() ?? this.#generatedScrollId;
|
|
5156
|
-
this.#autoScrollService.registerContainer(id, this.nativeElement, this.autoScrollConfig());
|
|
5157
|
-
}
|
|
5158
|
-
}
|
|
5159
|
-
#unregisterAutoScroll() {
|
|
5160
|
-
const id = this.scrollContainerId() ?? this.#generatedScrollId;
|
|
5161
|
-
this.#autoScrollService.unregisterContainer(id);
|
|
5162
|
-
}
|
|
5163
5525
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ScrollableDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
5164
5526
|
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 });
|
|
5165
5527
|
}
|
|
@@ -5173,7 +5535,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
5173
5535
|
'[style.overflow-anchor]': '"none"',
|
|
5174
5536
|
},
|
|
5175
5537
|
}]
|
|
5176
|
-
}], 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 }] }] } });
|
|
5538
|
+
}], 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 }] }] } });
|
|
5177
5539
|
|
|
5178
5540
|
/**
|
|
5179
5541
|
* A structural directive for virtual scrolling within custom scroll containers.
|