ngx-virtual-dnd 3.1.2 → 3.2.0-alpha.0
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 +87 -32
- package/fesm2022/ngx-virtual-dnd.mjs +826 -161
- package/fesm2022/ngx-virtual-dnd.mjs.map +1 -1
- package/package.json +4 -4
- package/types/ngx-virtual-dnd.d.ts +114 -10
|
@@ -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, afterNextRender, ElementRef, Injector, untracked, viewChild, Directive, isDevMode, output, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
|
|
3
3
|
import { NgTemplateOutlet } from '@angular/common';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -452,6 +452,25 @@ const VDND_SCROLL_CONTAINER = new InjectionToken('VDND_SCROLL_CONTAINER');
|
|
|
452
452
|
*/
|
|
453
453
|
const VDND_VIRTUAL_VIEWPORT = new InjectionToken('VDND_VIRTUAL_VIEWPORT');
|
|
454
454
|
|
|
455
|
+
/** Default shift animation duration (ms) used when the config omits `shiftDuration`. */
|
|
456
|
+
const DEFAULT_SHIFT_DURATION = 200;
|
|
457
|
+
/** Default shift animation easing used when the config omits `shiftEasing`. */
|
|
458
|
+
const DEFAULT_SHIFT_EASING = 'cubic-bezier(0.2, 0, 0, 1)';
|
|
459
|
+
/**
|
|
460
|
+
* Opt-in animation configuration. When provided, items displaced by the placeholder
|
|
461
|
+
* slide into their new position instead of jumping. Resolved through the element
|
|
462
|
+
* injector, so it can be provided app-wide or per component subtree.
|
|
463
|
+
*
|
|
464
|
+
* The animation is skipped when the user prefers reduced motion. Values are read each
|
|
465
|
+
* time an animation starts, so getters can toggle the animation at runtime.
|
|
466
|
+
*
|
|
467
|
+
* @example
|
|
468
|
+
* ```typescript
|
|
469
|
+
* providers: [{ provide: VDND_ANIMATION_CONFIG, useValue: { shiftDuration: 200 } }]
|
|
470
|
+
* ```
|
|
471
|
+
*/
|
|
472
|
+
const VDND_ANIMATION_CONFIG = new InjectionToken('VDND_ANIMATION_CONFIG');
|
|
473
|
+
|
|
455
474
|
const INITIAL_CORE_STATE = {
|
|
456
475
|
isDragging: false,
|
|
457
476
|
draggedItem: null,
|
|
@@ -486,10 +505,14 @@ class DragStateService {
|
|
|
486
505
|
#placeholderId = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderId" }] : []));
|
|
487
506
|
/** Index where the placeholder should be inserted — updated on each placeholder move */
|
|
488
507
|
#placeholderIndex = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderIndex" }] : []));
|
|
508
|
+
/** Snapshot captured synchronously immediately before the last drag state reset. */
|
|
509
|
+
#endedDragState = signal(null, ...(ngDevMode ? [{ debugName: "#endedDragState" }] : []));
|
|
489
510
|
/** Flag indicating if the last drag was cancelled (not dropped) */
|
|
490
511
|
#wasCancelled = signal(false, ...(ngDevMode ? [{ debugName: "#wasCancelled" }] : []));
|
|
491
512
|
/** Whether the last drag was cancelled (for droppable to check before emitting drop) */
|
|
492
513
|
wasCancelled = this.#wasCancelled.asReadonly();
|
|
514
|
+
/** Snapshot captured synchronously before the last drag reset. */
|
|
515
|
+
endedDragState = this.#endedDragState.asReadonly();
|
|
493
516
|
/** Whether a drag operation is in progress */
|
|
494
517
|
isDragging = computed(() => this.#state().isDragging, ...(ngDevMode ? [{ debugName: "isDragging" }] : []));
|
|
495
518
|
/** The currently dragged item, or null */
|
|
@@ -546,7 +569,8 @@ class DragStateService {
|
|
|
546
569
|
* Start a drag operation.
|
|
547
570
|
*/
|
|
548
571
|
startDrag(item, cursorPosition, grabOffset, lockAxis, activeDroppableId, placeholderId, placeholderIndex, sourceIndex, isKeyboardDrag, axisLockPosition) {
|
|
549
|
-
// Reset
|
|
572
|
+
// Reset terminal drag metadata at start of new drag
|
|
573
|
+
this.#endedDragState.set(null);
|
|
550
574
|
this.#wasCancelled.set(false);
|
|
551
575
|
this.#cursorPosition.set(cursorPosition ?? null);
|
|
552
576
|
this.#keyboardTargetIndex.set(isKeyboardDrag ? (sourceIndex ?? 0) : null);
|
|
@@ -616,6 +640,7 @@ class DragStateService {
|
|
|
616
640
|
* End the drag operation and reset state (normal drop).
|
|
617
641
|
*/
|
|
618
642
|
endDrag() {
|
|
643
|
+
this.#endedDragState.set(this.getStateSnapshot());
|
|
619
644
|
this.#wasCancelled.set(false);
|
|
620
645
|
this.#resetHighFrequencySignals();
|
|
621
646
|
this.#state.set(INITIAL_CORE_STATE);
|
|
@@ -624,6 +649,7 @@ class DragStateService {
|
|
|
624
649
|
* Cancel the drag operation (escape key, disabled, etc.).
|
|
625
650
|
*/
|
|
626
651
|
cancelDrag() {
|
|
652
|
+
this.#endedDragState.set(this.getStateSnapshot());
|
|
627
653
|
this.#wasCancelled.set(true);
|
|
628
654
|
this.#resetHighFrequencySignals();
|
|
629
655
|
this.#state.set(INITIAL_CORE_STATE);
|
|
@@ -739,6 +765,8 @@ class PositionCalculatorService {
|
|
|
739
765
|
#DROPPABLE_ID_ATTR = 'data-droppable-id';
|
|
740
766
|
/** Data attribute used to identify droppable groups */
|
|
741
767
|
#DROPPABLE_GROUP_ATTR = 'data-droppable-group';
|
|
768
|
+
/** Data attribute reflecting a droppable's disabled state (present === disabled) */
|
|
769
|
+
#DROPPABLE_DISABLED_ATTR = 'data-droppable-disabled';
|
|
742
770
|
/** Data attribute used to identify draggable elements */
|
|
743
771
|
#DRAGGABLE_ID_ATTR = 'data-draggable-id';
|
|
744
772
|
/** Maximum DOM levels to traverse when looking for parent elements */
|
|
@@ -766,10 +794,15 @@ class PositionCalculatorService {
|
|
|
766
794
|
this.#session = {
|
|
767
795
|
groupName,
|
|
768
796
|
candidates,
|
|
769
|
-
rects: candidates.map((el) => el
|
|
797
|
+
rects: candidates.map((el) => this.#measureRect(el)),
|
|
770
798
|
dirty: false,
|
|
799
|
+
candidatesStale: false,
|
|
771
800
|
onViewportChange,
|
|
801
|
+
resizeObserver: this.#createResizeObserver(),
|
|
772
802
|
};
|
|
803
|
+
// A container-only reflow (an element resize that fires no window scroll/resize)
|
|
804
|
+
// would otherwise leave stale rects — observe the candidates so it marks them dirty.
|
|
805
|
+
this.#observeCandidates(this.#session);
|
|
773
806
|
// Capture-phase scroll catches scrolling on any ancestor scroller (scroll does
|
|
774
807
|
// not bubble); resize covers viewport changes. Both only mark rects dirty —
|
|
775
808
|
// the actual re-read is deferred to the next hit-test.
|
|
@@ -790,6 +823,7 @@ class PositionCalculatorService {
|
|
|
790
823
|
return;
|
|
791
824
|
}
|
|
792
825
|
this.#session = null;
|
|
826
|
+
session.resizeObserver?.disconnect();
|
|
793
827
|
if (typeof window !== 'undefined') {
|
|
794
828
|
window.removeEventListener('scroll', session.onViewportChange, { capture: true });
|
|
795
829
|
window.removeEventListener('resize', session.onViewportChange);
|
|
@@ -805,6 +839,39 @@ class PositionCalculatorService {
|
|
|
805
839
|
this.#session.dirty = true;
|
|
806
840
|
}
|
|
807
841
|
}
|
|
842
|
+
/**
|
|
843
|
+
* Re-query the candidate droppable *list* for the active session and re-read their
|
|
844
|
+
* rects. Unlike {@link invalidateDroppableRects} (which only refreshes rects), this
|
|
845
|
+
* picks up droppables added or removed since the snapshot was captured at drag start —
|
|
846
|
+
* the frozen candidate list is otherwise blind to mid-drag mounts/unmounts.
|
|
847
|
+
*
|
|
848
|
+
* Safe to call when no session is active (no-op).
|
|
849
|
+
*/
|
|
850
|
+
refreshCandidates() {
|
|
851
|
+
const session = this.#session;
|
|
852
|
+
if (!session) {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
session.candidates = this.#queryDroppables(session.groupName);
|
|
856
|
+
session.rects = session.candidates.map((el) => this.#measureRect(el));
|
|
857
|
+
session.dirty = false;
|
|
858
|
+
session.candidatesStale = false;
|
|
859
|
+
this.#observeCandidates(session);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Signal that the set of droppables for `groupName` may have changed (a droppable
|
|
863
|
+
* registered or unregistered mid-drag). Defers the actual re-query to the next
|
|
864
|
+
* hit-test — where a removed element is already gone from the DOM and a newly added
|
|
865
|
+
* one has its data attributes applied — keeping this notification path cheap.
|
|
866
|
+
*
|
|
867
|
+
* Called by {@link DroppableDirective} lifecycle hooks. No-op when the active session
|
|
868
|
+
* belongs to a different group or no session is active.
|
|
869
|
+
*/
|
|
870
|
+
notifyCandidatesChanged(groupName) {
|
|
871
|
+
if (this.#session && this.#session.groupName === groupName) {
|
|
872
|
+
this.#session.candidatesStale = true;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
808
875
|
/**
|
|
809
876
|
* Find the droppable element at a given point.
|
|
810
877
|
*
|
|
@@ -823,9 +890,13 @@ class PositionCalculatorService {
|
|
|
823
890
|
findDroppableAtPoint(x, y, _draggedElement, groupName) {
|
|
824
891
|
const session = this.#session;
|
|
825
892
|
if (session && session.groupName === groupName) {
|
|
826
|
-
if (session.
|
|
893
|
+
if (session.candidatesStale) {
|
|
894
|
+
// A droppable was added/removed mid-drag: re-query the list (also refreshes rects).
|
|
895
|
+
this.refreshCandidates();
|
|
896
|
+
}
|
|
897
|
+
else if (session.dirty) {
|
|
827
898
|
for (let i = 0; i < session.candidates.length; i++) {
|
|
828
|
-
session.rects[i] = session.candidates[i]
|
|
899
|
+
session.rects[i] = this.#measureRect(session.candidates[i]);
|
|
829
900
|
}
|
|
830
901
|
session.dirty = false;
|
|
831
902
|
}
|
|
@@ -833,7 +904,7 @@ class PositionCalculatorService {
|
|
|
833
904
|
}
|
|
834
905
|
// No active session: one-shot geometric query (still avoids elementFromPoint).
|
|
835
906
|
const candidates = this.#queryDroppables(groupName);
|
|
836
|
-
const rects = candidates.map((el) => el
|
|
907
|
+
const rects = candidates.map((el) => this.#measureRect(el));
|
|
837
908
|
return this.#hitTest(x, y, candidates, rects);
|
|
838
909
|
}
|
|
839
910
|
/**
|
|
@@ -861,6 +932,11 @@ class PositionCalculatorService {
|
|
|
861
932
|
}
|
|
862
933
|
/**
|
|
863
934
|
* Query all droppable elements belonging to a group, in document order.
|
|
935
|
+
*
|
|
936
|
+
* All group members are included — the disabled check happens per-frame in
|
|
937
|
+
* {@link #hitTest} rather than here, because a droppable can be disabled or
|
|
938
|
+
* re-enabled mid-drag (e.g. a consumer disabling incompatible targets from a
|
|
939
|
+
* `(dragStart)` handler, which fires AFTER the candidate snapshot is captured).
|
|
864
940
|
*/
|
|
865
941
|
#queryDroppables(groupName) {
|
|
866
942
|
if (typeof document === 'undefined') {
|
|
@@ -868,18 +944,88 @@ class PositionCalculatorService {
|
|
|
868
944
|
}
|
|
869
945
|
return queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
|
|
870
946
|
}
|
|
947
|
+
/**
|
|
948
|
+
* Measure a candidate's hit-test rect, clipped to its nearest `.vdnd-scrollable`
|
|
949
|
+
* ancestor. Without clipping a droppable scrolled mostly out of a clipping container
|
|
950
|
+
* still hit-tests over its full unclipped rect (issue #23 case 3). The intersection is
|
|
951
|
+
* built as a plain DOMRect; an empty intersection yields a negative width/height so the
|
|
952
|
+
* `#hitTest` bounds check can never match it.
|
|
953
|
+
*/
|
|
954
|
+
#measureRect(el) {
|
|
955
|
+
const rect = el.getBoundingClientRect();
|
|
956
|
+
const scrollable = el.closest('.vdnd-scrollable');
|
|
957
|
+
if (!scrollable || scrollable === el) {
|
|
958
|
+
return rect;
|
|
959
|
+
}
|
|
960
|
+
const clip = scrollable.getBoundingClientRect();
|
|
961
|
+
const top = Math.max(rect.top, clip.top);
|
|
962
|
+
const left = Math.max(rect.left, clip.left);
|
|
963
|
+
const right = Math.min(rect.right, clip.right);
|
|
964
|
+
const bottom = Math.min(rect.bottom, clip.bottom);
|
|
965
|
+
return new DOMRect(left, top, right - left, bottom - top);
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Create a ResizeObserver (run outside Angular per CLAUDE.md) that marks the session's
|
|
969
|
+
* cached rects dirty when a candidate resizes. Returns null when ResizeObserver is
|
|
970
|
+
* unavailable (SSR / jsdom) so callers degrade gracefully.
|
|
971
|
+
*/
|
|
972
|
+
#createResizeObserver() {
|
|
973
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
return this.#ngZone.runOutsideAngular(() => new ResizeObserver(() => {
|
|
977
|
+
if (this.#session) {
|
|
978
|
+
this.#session.dirty = true;
|
|
979
|
+
}
|
|
980
|
+
}));
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Point the session's ResizeObserver at the current candidate list, dropping any
|
|
984
|
+
* previously observed elements. Called on session start and whenever the candidate
|
|
985
|
+
* list is refreshed.
|
|
986
|
+
*/
|
|
987
|
+
#observeCandidates(session) {
|
|
988
|
+
const observer = session.resizeObserver;
|
|
989
|
+
if (!observer) {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
this.#ngZone.runOutsideAngular(() => {
|
|
993
|
+
observer.disconnect();
|
|
994
|
+
for (const candidate of session.candidates) {
|
|
995
|
+
observer.observe(candidate);
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Whether a droppable element is currently disabled (reflected via the
|
|
1001
|
+
* `data-droppable-disabled` attribute by `DroppableDirective`). Read live on each
|
|
1002
|
+
* hit-test / navigation step so disabled↔enabled transitions during an active drag
|
|
1003
|
+
* take effect immediately. Disabled droppables are excluded from all drag-time
|
|
1004
|
+
* candidate sets — pointer hit-testing and keyboard cross-list navigation.
|
|
1005
|
+
*/
|
|
1006
|
+
#isDroppableDisabled(el) {
|
|
1007
|
+
return el.hasAttribute(this.#DROPPABLE_DISABLED_ATTR);
|
|
1008
|
+
}
|
|
871
1009
|
/**
|
|
872
1010
|
* Geometric hit-test: return the last candidate (in document order) whose rect
|
|
873
1011
|
* contains the point. "Last in document order" reproduces the painter's-order
|
|
874
1012
|
* tie-break that `elementFromPoint` provided for free — a nested or later
|
|
875
1013
|
* overlapping droppable is painted on top of an earlier/ancestor one.
|
|
1014
|
+
*
|
|
1015
|
+
* Currently-disabled candidates are skipped here (not filtered from the snapshot) so
|
|
1016
|
+
* that toggling `disabled` mid-drag takes effect on the very next frame. The check is
|
|
1017
|
+
* a cheap `hasAttribute` read that does not force layout, keeping the loop hot.
|
|
876
1018
|
*/
|
|
877
1019
|
#hitTest(x, y, candidates, rects) {
|
|
878
1020
|
let match = null;
|
|
879
1021
|
for (let i = 0; i < candidates.length; i++) {
|
|
1022
|
+
const candidate = candidates[i];
|
|
1023
|
+
if (this.#isDroppableDisabled(candidate)) {
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
880
1026
|
const r = rects[i];
|
|
881
1027
|
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
|
|
882
|
-
match =
|
|
1028
|
+
match = candidate;
|
|
883
1029
|
}
|
|
884
1030
|
}
|
|
885
1031
|
return match;
|
|
@@ -1018,12 +1164,15 @@ class PositionCalculatorService {
|
|
|
1018
1164
|
* @returns Object with droppable info, or null if none found
|
|
1019
1165
|
*/
|
|
1020
1166
|
findAdjacentDroppable(currentDroppableId, direction, groupName) {
|
|
1021
|
-
//
|
|
1167
|
+
// Include disabled droppables when establishing left-to-right order and locating the
|
|
1168
|
+
// current container — otherwise a container disabled mid-drag (its own index becomes
|
|
1169
|
+
// -1) would trap the drag with no reachable neighbour. Disabled droppables are skipped
|
|
1170
|
+
// as *targets* during the outward scan below instead.
|
|
1022
1171
|
const allDroppables = queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
|
|
1023
1172
|
if (allDroppables.length <= 1) {
|
|
1024
1173
|
return null;
|
|
1025
1174
|
}
|
|
1026
|
-
// Get bounding rects and
|
|
1175
|
+
// Get bounding rects, IDs, and disabled state, sorted by X position
|
|
1027
1176
|
const droppableInfos = [];
|
|
1028
1177
|
allDroppables.forEach((el) => {
|
|
1029
1178
|
const htmlEl = el;
|
|
@@ -1033,56 +1182,41 @@ class PositionCalculatorService {
|
|
|
1033
1182
|
element: htmlEl,
|
|
1034
1183
|
id,
|
|
1035
1184
|
rect: htmlEl.getBoundingClientRect(),
|
|
1185
|
+
disabled: this.#isDroppableDisabled(htmlEl),
|
|
1036
1186
|
});
|
|
1037
1187
|
}
|
|
1038
1188
|
});
|
|
1039
1189
|
// Sort by X position (left to right)
|
|
1040
1190
|
droppableInfos.sort((a, b) => a.rect.left - b.rect.left);
|
|
1041
|
-
// Find current droppable index
|
|
1191
|
+
// Find current droppable index (among all, including a now-disabled current container)
|
|
1042
1192
|
const currentIndex = droppableInfos.findIndex((d) => d.id === currentDroppableId);
|
|
1043
1193
|
if (currentIndex === -1) {
|
|
1044
1194
|
return null;
|
|
1045
1195
|
}
|
|
1046
|
-
//
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1196
|
+
// Scan outward in the requested direction, skipping disabled droppables, until the
|
|
1197
|
+
// first enabled neighbour (or run off the end of the list).
|
|
1198
|
+
const step = direction === 'left' ? -1 : 1;
|
|
1199
|
+
for (let i = currentIndex + step; i >= 0 && i < droppableInfos.length; i += step) {
|
|
1200
|
+
const target = droppableInfos[i];
|
|
1201
|
+
if (target.disabled) {
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
return {
|
|
1205
|
+
element: target.element,
|
|
1206
|
+
id: target.id,
|
|
1207
|
+
};
|
|
1050
1208
|
}
|
|
1051
|
-
|
|
1052
|
-
// Get item count from the target droppable
|
|
1053
|
-
const itemCount = this.#getDroppableItemCount(target.element);
|
|
1054
|
-
return {
|
|
1055
|
-
element: target.element,
|
|
1056
|
-
id: target.id,
|
|
1057
|
-
itemCount,
|
|
1058
|
-
};
|
|
1209
|
+
return null;
|
|
1059
1210
|
}
|
|
1060
1211
|
/**
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1212
|
+
* Whether the droppable with the given ID is currently disabled. A droppable that cannot
|
|
1213
|
+
* be found is reported as disabled (treat missing as not a valid target). Used to
|
|
1214
|
+
* revalidate a keyboard drag's active target at drop time, since it may have been disabled
|
|
1215
|
+
* after the drag navigated into it.
|
|
1063
1216
|
*/
|
|
1064
|
-
|
|
1065
|
-
const
|
|
1066
|
-
|
|
1067
|
-
const configuredHeight = virtualScroll.getAttribute('data-item-height');
|
|
1068
|
-
if (!configuredHeight) {
|
|
1069
|
-
if (isDevMode()) {
|
|
1070
|
-
console.error('[ngx-virtual-dnd] vdnd-virtual-scroll requires data-item-height attribute ' +
|
|
1071
|
-
'for keyboard navigation. Cross-list keyboard drag will not work correctly.');
|
|
1072
|
-
}
|
|
1073
|
-
// Short-circuit: return 0 to prevent navigation to this droppable
|
|
1074
|
-
return 0;
|
|
1075
|
-
}
|
|
1076
|
-
// Prefer spacer height over scrollHeight for accuracy
|
|
1077
|
-
const spacer = virtualScroll.querySelector('.vdnd-virtual-scroll-spacer');
|
|
1078
|
-
const totalHeight = spacer
|
|
1079
|
-
? parseFloat(spacer.style.height) || 0
|
|
1080
|
-
: virtualScroll.scrollHeight;
|
|
1081
|
-
const itemHeight = parseInt(configuredHeight, 10);
|
|
1082
|
-
return Math.floor(totalHeight / itemHeight);
|
|
1083
|
-
}
|
|
1084
|
-
// Fallback for non-virtual scroll: DOM count is valid
|
|
1085
|
-
return droppableElement.querySelectorAll(`[${this.#DRAGGABLE_ID_ATTR}]`).length;
|
|
1217
|
+
isDroppableDisabledById(id) {
|
|
1218
|
+
const element = this.getDroppableById(id);
|
|
1219
|
+
return element === null || this.#isDroppableDisabled(element);
|
|
1086
1220
|
}
|
|
1087
1221
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: PositionCalculatorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1088
1222
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: PositionCalculatorService, providedIn: 'root' });
|
|
@@ -1219,6 +1353,9 @@ const DEFAULT_CONFIG = {
|
|
|
1219
1353
|
maxSpeed: 15,
|
|
1220
1354
|
accelerate: true,
|
|
1221
1355
|
};
|
|
1356
|
+
const BASE_FRAME_DURATION_MS = 1000 / 60;
|
|
1357
|
+
const MIN_SCROLL_FRAME_SCALE = 1;
|
|
1358
|
+
const MAX_SCROLL_FRAME_SCALE = 6;
|
|
1222
1359
|
/**
|
|
1223
1360
|
* Service that handles auto-scrolling when dragging near container edges.
|
|
1224
1361
|
*/
|
|
@@ -1228,6 +1365,13 @@ class AutoScrollService {
|
|
|
1228
1365
|
#scheduler = inject(DragSchedulerService);
|
|
1229
1366
|
/** Currently registered scrollable containers */
|
|
1230
1367
|
#scrollableContainers = new Map();
|
|
1368
|
+
/**
|
|
1369
|
+
* Candidate containers ordered innermost (deepest in the DOM) first, so a
|
|
1370
|
+
* nested scrollable wins over its ancestors under the cursor. Rebuilt lazily
|
|
1371
|
+
* whenever registrations change (containers rarely move mid-drag) and once per
|
|
1372
|
+
* drag (see startMonitoring), not per frame.
|
|
1373
|
+
*/
|
|
1374
|
+
#orderedContainers = null;
|
|
1231
1375
|
/** Bound reference to #participantTick for stable add/remove with the scheduler. */
|
|
1232
1376
|
#boundParticipantTick = () => this.#participantTick();
|
|
1233
1377
|
/** Callback to invoke when scrolling occurs (for placeholder recalculation) */
|
|
@@ -1237,6 +1381,12 @@ class AutoScrollService {
|
|
|
1237
1381
|
/** Last tick cursor position for stationary detection */
|
|
1238
1382
|
#lastTickCursorX = NaN;
|
|
1239
1383
|
#lastTickCursorY = NaN;
|
|
1384
|
+
/**
|
|
1385
|
+
* Last timestamp used to scale per-frame autoscroll distance by elapsed frame time.
|
|
1386
|
+
* Scale is intentionally floored at 1 to preserve the existing feel on 60fps+ displays while
|
|
1387
|
+
* compensating slower frames that would otherwise under-scroll.
|
|
1388
|
+
*/
|
|
1389
|
+
#lastScrollTimestamp = 0;
|
|
1240
1390
|
/** Reusable direction object for tick loop (avoids per-frame allocation) */
|
|
1241
1391
|
#tickDirection = { x: 0, y: 0 };
|
|
1242
1392
|
/** Current scroll state */
|
|
@@ -1253,12 +1403,61 @@ class AutoScrollService {
|
|
|
1253
1403
|
element,
|
|
1254
1404
|
config: { ...DEFAULT_CONFIG, ...config },
|
|
1255
1405
|
});
|
|
1406
|
+
this.#orderedContainers = null;
|
|
1256
1407
|
}
|
|
1257
1408
|
/**
|
|
1258
1409
|
* Unregister a scrollable container.
|
|
1259
1410
|
*/
|
|
1260
1411
|
unregisterContainer(id) {
|
|
1261
1412
|
this.#scrollableContainers.delete(id);
|
|
1413
|
+
this.#orderedContainers = null;
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Build (or reuse) the candidate list ordered innermost-first by DOM depth.
|
|
1417
|
+
*
|
|
1418
|
+
* Sorting by absolute DOM depth (descending) is a *total* order, unlike an
|
|
1419
|
+
* `element.contains()` comparator which returns 0 for unrelated pairs and so
|
|
1420
|
+
* violates transitivity — with ≥3 containers a nested pair separated by an
|
|
1421
|
+
* unrelated one in registration order could end up never compared, leaving the
|
|
1422
|
+
* ancestor ahead of its descendant. Equal-depth (including unrelated) containers
|
|
1423
|
+
* keep registration order via the stable sort, so unrelated lists behave as before.
|
|
1424
|
+
*/
|
|
1425
|
+
#getOrderedContainers() {
|
|
1426
|
+
if (this.#orderedContainers) {
|
|
1427
|
+
return this.#orderedContainers;
|
|
1428
|
+
}
|
|
1429
|
+
const entries = [];
|
|
1430
|
+
for (const [id, { element, config }] of this.#scrollableContainers) {
|
|
1431
|
+
// Registration is unconditional (directives can't expose DOM size as a signal, so
|
|
1432
|
+
// gating registration on it goes stale — see #27). Filter by live scroll geometry
|
|
1433
|
+
// here instead: this list is rebuilt per drag (startMonitoring nulls the cache) and
|
|
1434
|
+
// on register/unregister, so a container that grew scrollable after init is picked
|
|
1435
|
+
// up on the next drag, and one that shrank stops occupying a candidate slot.
|
|
1436
|
+
if (!this.#isScrollable(element)) {
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
entries.push({ id, element, config, depth: this.#domDepth(element) });
|
|
1440
|
+
}
|
|
1441
|
+
// Deepest first; stable sort keeps registration order on ties.
|
|
1442
|
+
entries.sort((a, b) => b.depth - a.depth);
|
|
1443
|
+
this.#orderedContainers = entries;
|
|
1444
|
+
return entries;
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Whether the element currently has room to scroll on either axis. Read fresh each
|
|
1448
|
+
* time the candidate list is (re)built — never cached — so it always reflects the
|
|
1449
|
+
* live layout at drag start rather than a stale value captured at registration.
|
|
1450
|
+
*/
|
|
1451
|
+
#isScrollable(element) {
|
|
1452
|
+
return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
|
|
1453
|
+
}
|
|
1454
|
+
/** Number of ancestor elements — the element's absolute depth in the DOM tree. */
|
|
1455
|
+
#domDepth(element) {
|
|
1456
|
+
let depth = 0;
|
|
1457
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
1458
|
+
depth++;
|
|
1459
|
+
}
|
|
1460
|
+
return depth;
|
|
1262
1461
|
}
|
|
1263
1462
|
/**
|
|
1264
1463
|
* Start monitoring for auto-scroll.
|
|
@@ -1268,6 +1467,10 @@ class AutoScrollService {
|
|
|
1268
1467
|
*/
|
|
1269
1468
|
startMonitoring(onScroll) {
|
|
1270
1469
|
this.#onScrollCallback = onScroll ?? null;
|
|
1470
|
+
// Recompute containment ordering fresh per drag: a registered container may have
|
|
1471
|
+
// been reparented (portal/DOM move) since the cache was built, which register/
|
|
1472
|
+
// unregister alone wouldn't catch.
|
|
1473
|
+
this.#orderedContainers = null;
|
|
1271
1474
|
this.#scheduler.addParticipant(this.#boundParticipantTick);
|
|
1272
1475
|
}
|
|
1273
1476
|
/**
|
|
@@ -1281,6 +1484,7 @@ class AutoScrollService {
|
|
|
1281
1484
|
this.#cursorOverride = null;
|
|
1282
1485
|
this.#lastTickCursorX = NaN;
|
|
1283
1486
|
this.#lastTickCursorY = NaN;
|
|
1487
|
+
this.#lastScrollTimestamp = 0;
|
|
1284
1488
|
this.#scrollState.containerId = null;
|
|
1285
1489
|
this.#scrollState.direction.x = 0;
|
|
1286
1490
|
this.#scrollState.direction.y = 0;
|
|
@@ -1317,7 +1521,12 @@ class AutoScrollService {
|
|
|
1317
1521
|
this.#lastTickCursorX = cursor.x;
|
|
1318
1522
|
this.#lastTickCursorY = cursor.y;
|
|
1319
1523
|
let scrollPerformed = false;
|
|
1320
|
-
|
|
1524
|
+
// Frame scale is shared across candidates within a tick; computed lazily on
|
|
1525
|
+
// the first scroll attempt so stationary/idle frames don't advance the clock.
|
|
1526
|
+
let frameScale = 0;
|
|
1527
|
+
// Candidates are ordered innermost-first: when the deepest container under the
|
|
1528
|
+
// cursor is exhausted at its boundary, the loop falls through to its ancestors.
|
|
1529
|
+
for (const { id, element, config } of this.#getOrderedContainers()) {
|
|
1321
1530
|
const rect = element.getBoundingClientRect();
|
|
1322
1531
|
const isInside = this.#positionCalculator.isInsideContainer(cursor, rect);
|
|
1323
1532
|
if (!isInside) {
|
|
@@ -1351,16 +1560,31 @@ class AutoScrollService {
|
|
|
1351
1560
|
const distanceRatio = maxDistance / config.threshold;
|
|
1352
1561
|
speed = Math.min(config.maxSpeed, Math.max(1, config.maxSpeed * distanceRatio));
|
|
1353
1562
|
}
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1563
|
+
if (frameScale === 0) {
|
|
1564
|
+
frameScale = this.#getFrameScale();
|
|
1565
|
+
}
|
|
1566
|
+
// #performScroll reports whether it actually moved the element. Only claim the
|
|
1567
|
+
// tick (and stop descending the candidate list) when a scroll really happened —
|
|
1568
|
+
// an exhausted container must not block its ancestors from scrolling.
|
|
1569
|
+
//
|
|
1570
|
+
// Axis independence is per-container: if this container scrolls on *either* axis
|
|
1571
|
+
// it claims the whole tick, so a corner where the inner container's Y is exhausted
|
|
1572
|
+
// but its X still moves will not additionally apply an ancestor's available Y that
|
|
1573
|
+
// same frame. This matches the issue's "break once a container scrolled" contract;
|
|
1574
|
+
// cross-container axis handoff is intentionally out of scope.
|
|
1575
|
+
const scrolled = this.#performScroll(element, direction, frameScale, speed);
|
|
1576
|
+
if (scrolled) {
|
|
1577
|
+
this.#scrollState.containerId = id;
|
|
1578
|
+
this.#scrollState.direction.x = direction.x;
|
|
1579
|
+
this.#scrollState.direction.y = direction.y;
|
|
1580
|
+
this.#scrollState.speed = speed;
|
|
1581
|
+
scrollPerformed = true;
|
|
1582
|
+
break;
|
|
1583
|
+
}
|
|
1361
1584
|
}
|
|
1362
1585
|
}
|
|
1363
1586
|
if (!scrollPerformed) {
|
|
1587
|
+
this.#lastScrollTimestamp = 0;
|
|
1364
1588
|
this.#scrollState.containerId = null;
|
|
1365
1589
|
this.#scrollState.direction.x = 0;
|
|
1366
1590
|
this.#scrollState.direction.y = 0;
|
|
@@ -1369,32 +1593,55 @@ class AutoScrollService {
|
|
|
1369
1593
|
}
|
|
1370
1594
|
/**
|
|
1371
1595
|
* Perform the actual scroll operation.
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1596
|
+
*
|
|
1597
|
+
* Each axis is evaluated independently: a boundary-exhausted axis is skipped
|
|
1598
|
+
* while the other axis still scrolls (e.g. at a corner where vertical is
|
|
1599
|
+
* maxed out but horizontal is available).
|
|
1600
|
+
*
|
|
1601
|
+
* @returns `true` when at least one axis actually scrolled, `false` when the
|
|
1602
|
+
* element was already at its boundary in every requested direction. The
|
|
1603
|
+
* caller uses this to fall through to ancestor containers.
|
|
1604
|
+
*/
|
|
1605
|
+
#performScroll(element, direction, frameScale, speed) {
|
|
1606
|
+
const scrollX = direction.x * speed * frameScale;
|
|
1607
|
+
const scrollY = direction.y * speed * frameScale;
|
|
1608
|
+
// Integer bounds used only as a cheap fast path to skip the assignment.
|
|
1609
|
+
// scrollHeight/clientHeight are rounded integers while scrollTop is fractional on
|
|
1610
|
+
// WebKit and any non-integer zoom/DPR, so this max can overstate the true one by
|
|
1611
|
+
// up to ~1px. It must NOT decide whether a scroll happened — the browser clamps the
|
|
1612
|
+
// assignment, so a before/after comparison is the only reliable truth signal.
|
|
1613
|
+
// Without it, an element pinned at its fractional max reports `scrolled = true`,
|
|
1614
|
+
// permanently claims the tick (blocking ancestor fall-through) and fires the callback
|
|
1615
|
+
// every frame against unchanged offsets (the WebKit placeholder-drift family).
|
|
1377
1616
|
const maxScrollY = element.scrollHeight - element.clientHeight;
|
|
1378
1617
|
const maxScrollX = element.scrollWidth - element.clientWidth;
|
|
1379
|
-
|
|
1380
|
-
return;
|
|
1381
|
-
}
|
|
1382
|
-
if (direction.y > 0 && element.scrollTop >= maxScrollY) {
|
|
1383
|
-
return;
|
|
1384
|
-
}
|
|
1385
|
-
if (direction.x < 0 && element.scrollLeft <= 0) {
|
|
1386
|
-
return;
|
|
1387
|
-
}
|
|
1388
|
-
if (direction.x > 0 && element.scrollLeft >= maxScrollX) {
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1618
|
+
let scrolled = false;
|
|
1391
1619
|
// Use direct property assignment for guaranteed synchronous scroll
|
|
1392
1620
|
// scrollBy() with 'instant' behavior may have async issues in Safari
|
|
1393
1621
|
if (scrollY !== 0) {
|
|
1394
|
-
element.scrollTop
|
|
1622
|
+
const canScrollUp = direction.y < 0 && element.scrollTop > 0;
|
|
1623
|
+
const canScrollDown = direction.y > 0 && element.scrollTop < maxScrollY;
|
|
1624
|
+
if (canScrollUp || canScrollDown) {
|
|
1625
|
+
const before = element.scrollTop;
|
|
1626
|
+
element.scrollTop += scrollY;
|
|
1627
|
+
if (element.scrollTop !== before) {
|
|
1628
|
+
scrolled = true;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1395
1631
|
}
|
|
1396
1632
|
if (scrollX !== 0) {
|
|
1397
|
-
element.scrollLeft
|
|
1633
|
+
const canScrollLeft = direction.x < 0 && element.scrollLeft > 0;
|
|
1634
|
+
const canScrollRight = direction.x > 0 && element.scrollLeft < maxScrollX;
|
|
1635
|
+
if (canScrollLeft || canScrollRight) {
|
|
1636
|
+
const before = element.scrollLeft;
|
|
1637
|
+
element.scrollLeft += scrollX;
|
|
1638
|
+
if (element.scrollLeft !== before) {
|
|
1639
|
+
scrolled = true;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
if (!scrolled) {
|
|
1644
|
+
return false;
|
|
1398
1645
|
}
|
|
1399
1646
|
// Notify callback IMMEDIATELY in the same frame (no RAF delay)
|
|
1400
1647
|
// Delaying via RAF causes cumulative drift during continuous autoscroll
|
|
@@ -1402,6 +1649,13 @@ class AutoScrollService {
|
|
|
1402
1649
|
// Note: No ngZone.run() needed here - the callback (DraggableDirective.#recalculatePlaceholder)
|
|
1403
1650
|
// already enters the zone when updating drag state.
|
|
1404
1651
|
this.#onScrollCallback?.();
|
|
1652
|
+
return true;
|
|
1653
|
+
}
|
|
1654
|
+
#getFrameScale() {
|
|
1655
|
+
const now = performance.now();
|
|
1656
|
+
const elapsedMs = this.#lastScrollTimestamp === 0 ? BASE_FRAME_DURATION_MS : now - this.#lastScrollTimestamp;
|
|
1657
|
+
this.#lastScrollTimestamp = now;
|
|
1658
|
+
return Math.min(Math.max(elapsedMs / BASE_FRAME_DURATION_MS, MIN_SCROLL_FRAME_SCALE), MAX_SCROLL_FRAME_SCALE);
|
|
1405
1659
|
}
|
|
1406
1660
|
/**
|
|
1407
1661
|
* Check if auto-scrolling is currently active.
|
|
@@ -2191,6 +2445,181 @@ function createAutoScrollRegistration(options) {
|
|
|
2191
2445
|
destroyRef.onDestroy(unregisterCurrent);
|
|
2192
2446
|
}
|
|
2193
2447
|
|
|
2448
|
+
/** Layout changes smaller than this are ignored (sub-pixel rounding noise). */
|
|
2449
|
+
const MIN_SHIFT_PX = 0.5;
|
|
2450
|
+
/**
|
|
2451
|
+
* FLIP animator for items displaced by the drag placeholder.
|
|
2452
|
+
*
|
|
2453
|
+
* Call `beforeUpdate()` before the DOM reflects a new drag/placeholder state. When the
|
|
2454
|
+
* placeholder moved, it snapshots every rendered element's visual position, then after
|
|
2455
|
+
* the render measures again and plays a compositor-only `transform` animation from the
|
|
2456
|
+
* old position to the new one.
|
|
2457
|
+
*
|
|
2458
|
+
* Positions are measured relative to the scroll content, so scrolling (including
|
|
2459
|
+
* programmatic scroll in the same tick) never animates. Measurements include any
|
|
2460
|
+
* in-flight animation, so a new displacement retargets from where the item currently
|
|
2461
|
+
* is instead of stacking or jumping. Elements whose target did not change keep their
|
|
2462
|
+
* running animation untouched.
|
|
2463
|
+
* @internal
|
|
2464
|
+
*/
|
|
2465
|
+
class ShiftAnimator {
|
|
2466
|
+
#config;
|
|
2467
|
+
#injector;
|
|
2468
|
+
#getScrollElement;
|
|
2469
|
+
#getEntries;
|
|
2470
|
+
#running = new Map();
|
|
2471
|
+
#snapshot = null;
|
|
2472
|
+
#reducedMotionQuery;
|
|
2473
|
+
#wasDragging = false;
|
|
2474
|
+
#lastPlaceholderIndex = -1;
|
|
2475
|
+
constructor(options) {
|
|
2476
|
+
this.#config = options.config;
|
|
2477
|
+
this.#injector = options.injector;
|
|
2478
|
+
this.#getScrollElement = options.getScrollElement;
|
|
2479
|
+
this.#getEntries = options.getEntries;
|
|
2480
|
+
}
|
|
2481
|
+
/**
|
|
2482
|
+
* Notify the animator of the drag state about to be rendered.
|
|
2483
|
+
* @param placeholderIndex placeholder index in this list, or -1 when not shown here
|
|
2484
|
+
*/
|
|
2485
|
+
beforeUpdate(isDragging, placeholderIndex) {
|
|
2486
|
+
const wasDragging = this.#wasDragging;
|
|
2487
|
+
const previousIndex = this.#lastPlaceholderIndex;
|
|
2488
|
+
this.#wasDragging = isDragging;
|
|
2489
|
+
this.#lastPlaceholderIndex = placeholderIndex;
|
|
2490
|
+
if (!isDragging) {
|
|
2491
|
+
// Drop/cancel: the list re-renders with the committed order, so snap everything.
|
|
2492
|
+
this.#snapshot = null;
|
|
2493
|
+
if (wasDragging) {
|
|
2494
|
+
this.cancelAll();
|
|
2495
|
+
}
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
// Drag start renders the placeholder in the hidden item's slot — nothing moves.
|
|
2499
|
+
// A pending snapshot means another update already captured this tick's "before".
|
|
2500
|
+
if (!wasDragging || placeholderIndex === previousIndex || this.#snapshot) {
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
if (!this.#isEnabled()) {
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
this.#snapshot = this.#measure();
|
|
2507
|
+
afterNextRender(() => this.#play(), { injector: this.#injector });
|
|
2508
|
+
}
|
|
2509
|
+
/** Stop the shift animation on an element (e.g. before its view is recycled). */
|
|
2510
|
+
cancel(element) {
|
|
2511
|
+
const running = this.#running.get(element);
|
|
2512
|
+
if (!running)
|
|
2513
|
+
return;
|
|
2514
|
+
this.#running.delete(element);
|
|
2515
|
+
running.animation.cancel();
|
|
2516
|
+
}
|
|
2517
|
+
/** Stop every running shift animation. */
|
|
2518
|
+
cancelAll() {
|
|
2519
|
+
for (const running of this.#running.values()) {
|
|
2520
|
+
running.animation.cancel();
|
|
2521
|
+
}
|
|
2522
|
+
this.#running.clear();
|
|
2523
|
+
}
|
|
2524
|
+
#isEnabled() {
|
|
2525
|
+
if (this.#duration() <= 0)
|
|
2526
|
+
return false;
|
|
2527
|
+
if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
|
|
2528
|
+
return false;
|
|
2529
|
+
}
|
|
2530
|
+
return !this.#prefersReducedMotion();
|
|
2531
|
+
}
|
|
2532
|
+
#duration() {
|
|
2533
|
+
return this.#config.shiftDuration ?? DEFAULT_SHIFT_DURATION;
|
|
2534
|
+
}
|
|
2535
|
+
#prefersReducedMotion() {
|
|
2536
|
+
if (this.#reducedMotionQuery === undefined) {
|
|
2537
|
+
this.#reducedMotionQuery =
|
|
2538
|
+
typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
|
|
2539
|
+
}
|
|
2540
|
+
return this.#reducedMotionQuery?.matches ?? false;
|
|
2541
|
+
}
|
|
2542
|
+
/** Scroll-content origin, so positions are independent of the scroll offset. */
|
|
2543
|
+
#origin() {
|
|
2544
|
+
const scrollElement = this.#getScrollElement();
|
|
2545
|
+
if (!scrollElement)
|
|
2546
|
+
return { x: 0, y: 0 };
|
|
2547
|
+
const rect = scrollElement.getBoundingClientRect();
|
|
2548
|
+
return { x: rect.left - scrollElement.scrollLeft, y: rect.top - scrollElement.scrollTop };
|
|
2549
|
+
}
|
|
2550
|
+
#measure() {
|
|
2551
|
+
const origin = this.#origin();
|
|
2552
|
+
const positions = new Map();
|
|
2553
|
+
for (const [key, element] of this.#getEntries()) {
|
|
2554
|
+
const rect = element.getBoundingClientRect();
|
|
2555
|
+
// Hidden (display: none) elements, such as the dragged item, have no box.
|
|
2556
|
+
if (rect.width === 0 && rect.height === 0)
|
|
2557
|
+
continue;
|
|
2558
|
+
positions.set(element, { key, x: rect.left - origin.x, y: rect.top - origin.y });
|
|
2559
|
+
}
|
|
2560
|
+
return positions;
|
|
2561
|
+
}
|
|
2562
|
+
#play() {
|
|
2563
|
+
const first = this.#snapshot;
|
|
2564
|
+
this.#snapshot = null;
|
|
2565
|
+
if (!first || !this.#wasDragging)
|
|
2566
|
+
return;
|
|
2567
|
+
const last = this.#measure();
|
|
2568
|
+
const stale = [];
|
|
2569
|
+
const shifts = [];
|
|
2570
|
+
// Read phase — no writes until every element is measured.
|
|
2571
|
+
for (const [element, to] of last) {
|
|
2572
|
+
const from = first.get(element);
|
|
2573
|
+
if (!from || !Object.is(from.key, to.key)) {
|
|
2574
|
+
// Newly rendered or recycled for another item: must not animate from a stale offset.
|
|
2575
|
+
stale.push(element);
|
|
2576
|
+
continue;
|
|
2577
|
+
}
|
|
2578
|
+
const dx = from.x - to.x;
|
|
2579
|
+
const dy = from.y - to.y;
|
|
2580
|
+
if (Math.abs(dx) < MIN_SHIFT_PX && Math.abs(dy) < MIN_SHIFT_PX) {
|
|
2581
|
+
continue;
|
|
2582
|
+
}
|
|
2583
|
+
// Both measurements include the in-flight offset, so the new animation must
|
|
2584
|
+
// start from that offset plus the layout delta to continue from the current spot.
|
|
2585
|
+
const current = this.#currentOffset(element);
|
|
2586
|
+
shifts.push({ element, x: dx + current.x, y: dy + current.y });
|
|
2587
|
+
}
|
|
2588
|
+
// Write phase
|
|
2589
|
+
for (const element of stale) {
|
|
2590
|
+
this.cancel(element);
|
|
2591
|
+
}
|
|
2592
|
+
for (const shift of shifts) {
|
|
2593
|
+
this.#start(shift.element, shift.x, shift.y);
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
#currentOffset(element) {
|
|
2597
|
+
const running = this.#running.get(element);
|
|
2598
|
+
// Effect-level easing makes `progress` the eased progress, i.e. the fraction travelled.
|
|
2599
|
+
const progress = running?.animation.effect?.getComputedTiming().progress;
|
|
2600
|
+
if (!running || progress === null || progress === undefined) {
|
|
2601
|
+
return { x: 0, y: 0 };
|
|
2602
|
+
}
|
|
2603
|
+
const remaining = 1 - progress;
|
|
2604
|
+
return { x: running.x * remaining, y: running.y * remaining };
|
|
2605
|
+
}
|
|
2606
|
+
#start(element, x, y) {
|
|
2607
|
+
this.#running.get(element)?.animation.cancel();
|
|
2608
|
+
const animation = element.animate([{ transform: `translate(${x}px, ${y}px)` }, { transform: 'translate(0px, 0px)' }], {
|
|
2609
|
+
duration: this.#duration(),
|
|
2610
|
+
easing: this.#config.shiftEasing ?? DEFAULT_SHIFT_EASING,
|
|
2611
|
+
// Layer on top of any transform the consumer already applies to the item.
|
|
2612
|
+
composite: 'add',
|
|
2613
|
+
});
|
|
2614
|
+
this.#running.set(element, { animation, x, y });
|
|
2615
|
+
animation.onfinish = () => {
|
|
2616
|
+
if (this.#running.get(element)?.animation === animation) {
|
|
2617
|
+
this.#running.delete(element);
|
|
2618
|
+
}
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2194
2623
|
/**
|
|
2195
2624
|
* A virtual scroll container that only renders visible items.
|
|
2196
2625
|
*
|
|
@@ -2246,6 +2675,8 @@ class VirtualScrollContainerComponent {
|
|
|
2246
2675
|
#observedElementByKey = new Map();
|
|
2247
2676
|
/** Measured height from ResizeObserver (used when containerHeight is not provided) */
|
|
2248
2677
|
#measuredHeight = signal(0, ...(ngDevMode ? [{ debugName: "#measuredHeight" }] : []));
|
|
2678
|
+
/** Slides items displaced by the placeholder (only when VDND_ANIMATION_CONFIG is provided) */
|
|
2679
|
+
#shiftAnimator = this.#createShiftAnimator();
|
|
2249
2680
|
/** Template for rendering each item - passed as input instead of content child for reliability */
|
|
2250
2681
|
itemTemplate = input.required(...(ngDevMode ? [{ debugName: "itemTemplate" }] : []));
|
|
2251
2682
|
/** Unique ID for this scroll container (used for auto-scroll registration) */
|
|
@@ -2538,6 +2969,16 @@ class VirtualScrollContainerComponent {
|
|
|
2538
2969
|
const draggedIndex = this.#draggedItemIndex();
|
|
2539
2970
|
strategy.setExcludedIndex(draggedIndex >= 0 ? draggedIndex : null);
|
|
2540
2971
|
});
|
|
2972
|
+
// Snapshot item positions before the template re-renders the placeholder move.
|
|
2973
|
+
// Component effects run before the component's own template is refreshed.
|
|
2974
|
+
if (this.#shiftAnimator) {
|
|
2975
|
+
const animator = this.#shiftAnimator;
|
|
2976
|
+
effect(() => {
|
|
2977
|
+
const isDragging = this.#dragState.isDragging();
|
|
2978
|
+
const placeholderIndex = this.placeholderIndex();
|
|
2979
|
+
untracked(() => animator.beforeUpdate(isDragging, placeholderIndex));
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2541
2982
|
// Keyboard drag autoscroll: scroll to keep target index visible
|
|
2542
2983
|
effect(() => {
|
|
2543
2984
|
// Only apply when this droppable is active during keyboard drag
|
|
@@ -2663,6 +3104,32 @@ class VirtualScrollContainerComponent {
|
|
|
2663
3104
|
}
|
|
2664
3105
|
this.#observedElementByKey.clear();
|
|
2665
3106
|
this.#itemResizeObserver?.disconnect();
|
|
3107
|
+
this.#shiftAnimator?.cancelAll();
|
|
3108
|
+
}
|
|
3109
|
+
#createShiftAnimator() {
|
|
3110
|
+
const config = inject(VDND_ANIMATION_CONFIG, { optional: true });
|
|
3111
|
+
if (!config)
|
|
3112
|
+
return null;
|
|
3113
|
+
return new ShiftAnimator({
|
|
3114
|
+
config,
|
|
3115
|
+
injector: this.#injector,
|
|
3116
|
+
getScrollElement: () => this.#elementRef.nativeElement,
|
|
3117
|
+
getEntries: () => this.#shiftAnimationEntries(),
|
|
3118
|
+
});
|
|
3119
|
+
}
|
|
3120
|
+
/**
|
|
3121
|
+
* Rendered item roots and the placeholder. `@for` never reuses a view for a different
|
|
3122
|
+
* track key, so each element is its own stable identity.
|
|
3123
|
+
*/
|
|
3124
|
+
*#shiftAnimationEntries() {
|
|
3125
|
+
const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
|
|
3126
|
+
if (!wrapper)
|
|
3127
|
+
return;
|
|
3128
|
+
for (const child of Array.from(wrapper.children)) {
|
|
3129
|
+
if (child instanceof HTMLElement) {
|
|
3130
|
+
yield [child, child];
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
2666
3133
|
}
|
|
2667
3134
|
/**
|
|
2668
3135
|
* Set up ResizeObserver for measuring individual item heights.
|
|
@@ -3157,6 +3624,18 @@ function createEffectiveGroupSignal(options) {
|
|
|
3157
3624
|
});
|
|
3158
3625
|
}
|
|
3159
3626
|
|
|
3627
|
+
function normalizeDropDestinationIndex({ sourceIndex, placeholderIndex, sourceDroppableId, activeDroppableId, }) {
|
|
3628
|
+
if (placeholderIndex === null) {
|
|
3629
|
+
return null;
|
|
3630
|
+
}
|
|
3631
|
+
if (sourceDroppableId !== null &&
|
|
3632
|
+
sourceDroppableId === activeDroppableId &&
|
|
3633
|
+
sourceIndex < placeholderIndex) {
|
|
3634
|
+
return placeholderIndex - 1;
|
|
3635
|
+
}
|
|
3636
|
+
return placeholderIndex;
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3160
3639
|
/**
|
|
3161
3640
|
* Marks an element as a valid drop target within the virtual scroll drag-and-drop system.
|
|
3162
3641
|
*
|
|
@@ -3182,6 +3661,7 @@ class DroppableDirective {
|
|
|
3182
3661
|
#elementRef = inject((ElementRef));
|
|
3183
3662
|
#dragState = inject(DragStateService);
|
|
3184
3663
|
#autoScroll = inject(AutoScrollService);
|
|
3664
|
+
#positionCalculator = inject(PositionCalculatorService);
|
|
3185
3665
|
#parentGroup = inject(VDND_GROUP_TOKEN, { optional: true });
|
|
3186
3666
|
/** Unique identifier for this droppable */
|
|
3187
3667
|
vdndDroppable = input.required(...(ngDevMode ? [{ debugName: "vdndDroppable" }] : []));
|
|
@@ -3213,6 +3693,11 @@ class DroppableDirective {
|
|
|
3213
3693
|
/** Emits when an item is dropped on this droppable */
|
|
3214
3694
|
// eslint-disable-next-line @angular-eslint/no-output-native
|
|
3215
3695
|
drop = output();
|
|
3696
|
+
/**
|
|
3697
|
+
* Emits each time the placeholder moves within this droppable during a drag
|
|
3698
|
+
* (every item displacement) — e.g. to trigger haptic feedback.
|
|
3699
|
+
*/
|
|
3700
|
+
placeholderMove = output();
|
|
3216
3701
|
/** Whether this droppable is currently being targeted */
|
|
3217
3702
|
isActive = computed(() => {
|
|
3218
3703
|
const activeId = this.#dragState.activeDroppableId();
|
|
@@ -3225,29 +3710,35 @@ class DroppableDirective {
|
|
|
3225
3710
|
}
|
|
3226
3711
|
return this.#dragState.placeholderId();
|
|
3227
3712
|
}, ...(ngDevMode ? [{ debugName: "placeholderId" }] : []));
|
|
3228
|
-
/**
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
#isScrollable() {
|
|
3236
|
-
const el = this.#elementRef.nativeElement;
|
|
3237
|
-
const style = window.getComputedStyle(el);
|
|
3238
|
-
const overflowY = style.overflowY;
|
|
3239
|
-
const overflowX = style.overflowX;
|
|
3240
|
-
// Check if overflow allows scrolling
|
|
3241
|
-
const hasScrollableOverflow = overflowY === 'auto' ||
|
|
3242
|
-
overflowY === 'scroll' ||
|
|
3243
|
-
overflowX === 'auto' ||
|
|
3244
|
-
overflowX === 'scroll';
|
|
3245
|
-
if (!hasScrollableOverflow) {
|
|
3246
|
-
return false;
|
|
3713
|
+
/**
|
|
3714
|
+
* The placeholder's insertion index in this droppable (DropEvent convention),
|
|
3715
|
+
* or null when the placeholder is not here.
|
|
3716
|
+
*/
|
|
3717
|
+
#placeholderInsertionIndex = computed(() => {
|
|
3718
|
+
if (!this.isActive() || !this.#dragState.isDragging()) {
|
|
3719
|
+
return null;
|
|
3247
3720
|
}
|
|
3248
|
-
|
|
3249
|
-
return
|
|
3250
|
-
|
|
3721
|
+
const sourceIndex = this.#dragState.sourceIndex();
|
|
3722
|
+
return normalizeDropDestinationIndex({
|
|
3723
|
+
sourceIndex: sourceIndex ?? -1,
|
|
3724
|
+
placeholderIndex: this.#dragState.placeholderIndex(),
|
|
3725
|
+
// Without a known source index there is no hidden-source adjustment to undo
|
|
3726
|
+
sourceDroppableId: sourceIndex === null ? null : this.#dragState.sourceDroppableId(),
|
|
3727
|
+
activeDroppableId: this.vdndDroppable(),
|
|
3728
|
+
});
|
|
3729
|
+
}, ...(ngDevMode ? [{ debugName: "#placeholderInsertionIndex" }] : []));
|
|
3730
|
+
/** Last insertion index reported via placeholderMove (null when not here) */
|
|
3731
|
+
#lastPlaceholderIndex = null;
|
|
3732
|
+
/** Drag whose initial placeholder position has already been seen by this droppable */
|
|
3733
|
+
#placeholderTrackedDrag = null;
|
|
3734
|
+
/** Track previous active state to detect the drag-end transition */
|
|
3735
|
+
#wasActive = false;
|
|
3736
|
+
/**
|
|
3737
|
+
* The terminal drag snapshot this droppable has already processed. `endedDragState`
|
|
3738
|
+
* persists until the next drag starts, so this guards against replaying the same drop
|
|
3739
|
+
* when the effect re-runs (e.g. `disabled()` toggling) after the drag has ended.
|
|
3740
|
+
*/
|
|
3741
|
+
#handledEndedState = null;
|
|
3251
3742
|
constructor() {
|
|
3252
3743
|
createAutoScrollRegistration({
|
|
3253
3744
|
autoScrollService: this.#autoScroll,
|
|
@@ -3255,29 +3746,55 @@ class DroppableDirective {
|
|
|
3255
3746
|
getId: () => this.vdndDroppable(),
|
|
3256
3747
|
enabled: () => this.autoScrollEnabled(),
|
|
3257
3748
|
config: () => this.autoScrollConfig(),
|
|
3258
|
-
|
|
3749
|
+
// Register whenever a group is resolved; do NOT gate on scrollability. DOM size is
|
|
3750
|
+
// not reactive, so a list that becomes scrollable after init (async data, resize)
|
|
3751
|
+
// would never register. AutoScrollService filters by live scroll geometry per drag.
|
|
3752
|
+
canRegister: () => Boolean(this.effectiveGroup()),
|
|
3753
|
+
});
|
|
3754
|
+
// Notify the calculator once this droppable is rendered (host data attributes applied).
|
|
3755
|
+
// Matters when it mounts DURING an active drag — the candidate snapshot was frozen at
|
|
3756
|
+
// drag start, so without this a conditionally rendered list would never become a target.
|
|
3757
|
+
afterNextRender(() => {
|
|
3758
|
+
const group = this.effectiveGroup();
|
|
3759
|
+
if (group) {
|
|
3760
|
+
this.#positionCalculator.notifyCandidatesChanged(group);
|
|
3761
|
+
}
|
|
3762
|
+
});
|
|
3763
|
+
effect(() => {
|
|
3764
|
+
const currentIndex = this.#placeholderInsertionIndex();
|
|
3765
|
+
untracked(() => this.#handlePlaceholderMove(currentIndex));
|
|
3259
3766
|
});
|
|
3260
|
-
// React to state changes and handle drop events
|
|
3767
|
+
// React to state changes and handle drop events.
|
|
3768
|
+
//
|
|
3769
|
+
// Keyed only on the low-frequency fields (isActive/isDragging/draggedItem). The final
|
|
3770
|
+
// placeholder/index values #handleDrop needs are captured by DragStateService.endDrag()
|
|
3771
|
+
// into endedDragState() at the drag-end boundary, so this effect never has to poll the
|
|
3772
|
+
// high-frequency cursor/placeholder signals — reading them here would make it re-run and
|
|
3773
|
+
// re-allocate a snapshot on every 60fps frame, defeating the service's signal splitting.
|
|
3261
3774
|
effect(() => {
|
|
3262
3775
|
const active = this.isActive();
|
|
3263
3776
|
const draggedItem = this.#dragState.draggedItem();
|
|
3264
3777
|
const isDragging = this.#dragState.isDragging();
|
|
3265
|
-
//
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
//
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3778
|
+
// Handle drag end (drop). This droppable is the release target when it was either
|
|
3779
|
+
// observed active during the drag (#wasActive) OR named as the target in the ended
|
|
3780
|
+
// drag snapshot. The snapshot path covers the pointer-up flush, where the final
|
|
3781
|
+
// position is processed and the state cleared in the same synchronous task, so this
|
|
3782
|
+
// effect never observes isActive() === true mid-drag and #wasActive stays false.
|
|
3783
|
+
const endedState = untracked(() => this.#dragState.endedDragState());
|
|
3784
|
+
const endedTargetedThis = endedState?.activeDroppableId === this.vdndDroppable();
|
|
3785
|
+
if (!isDragging && draggedItem === null && (this.#wasActive || endedTargetedThis)) {
|
|
3786
|
+
// Consume each terminal snapshot exactly once. endedDragState persists until the
|
|
3787
|
+
// next drag starts and disabled()/other inputs re-run this effect, so without a
|
|
3788
|
+
// per-snapshot guard a later state change would replay the same historical drop.
|
|
3789
|
+
if (endedState !== this.#handledEndedState) {
|
|
3790
|
+
this.#handledEndedState = endedState;
|
|
3791
|
+
// Emit only for an enabled target that ended normally. A target disabled at
|
|
3792
|
+
// release still consumes the snapshot (so re-enabling can't resurrect the drop)
|
|
3793
|
+
// but emits nothing, keeping dragEnd and drop consistent.
|
|
3794
|
+
if (!this.disabled() && !this.#dragState.wasCancelled()) {
|
|
3795
|
+
this.#handleDrop();
|
|
3796
|
+
}
|
|
3275
3797
|
}
|
|
3276
|
-
this.#cachedDragState = null;
|
|
3277
|
-
}
|
|
3278
|
-
// Clear cached state when leaving without dropping
|
|
3279
|
-
if (!active && this.#wasActive && isDragging) {
|
|
3280
|
-
this.#cachedDragState = null;
|
|
3281
3798
|
}
|
|
3282
3799
|
this.#wasActive = active;
|
|
3283
3800
|
});
|
|
@@ -3289,13 +3806,51 @@ class DroppableDirective {
|
|
|
3289
3806
|
}
|
|
3290
3807
|
// Unregister from auto-scroll
|
|
3291
3808
|
this.#autoScroll.unregisterContainer(this.vdndDroppable());
|
|
3809
|
+
// If this droppable unmounts mid-drag, tell the calculator so it drops it from the
|
|
3810
|
+
// frozen candidate list (deferred re-query runs once the element has left the DOM).
|
|
3811
|
+
const group = untracked(() => this.effectiveGroup());
|
|
3812
|
+
if (group) {
|
|
3813
|
+
this.#positionCalculator.notifyCandidatesChanged(group);
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
/**
|
|
3817
|
+
* Emit placeholderMove when the placeholder moved within (or entered) this droppable.
|
|
3818
|
+
*/
|
|
3819
|
+
#handlePlaceholderMove(currentIndex) {
|
|
3820
|
+
let previousIndex = this.#lastPlaceholderIndex;
|
|
3821
|
+
this.#lastPlaceholderIndex = currentIndex;
|
|
3822
|
+
const draggedItem = this.#dragState.draggedItem();
|
|
3823
|
+
if (currentIndex === null || currentIndex === previousIndex || !draggedItem) {
|
|
3824
|
+
return;
|
|
3825
|
+
}
|
|
3826
|
+
// The drag's first placement in the source list starts from the item's own slot:
|
|
3827
|
+
// nothing is displaced when the placeholder simply takes the hidden item's place.
|
|
3828
|
+
const isFirstPlacement = this.#placeholderTrackedDrag !== draggedItem;
|
|
3829
|
+
this.#placeholderTrackedDrag = draggedItem;
|
|
3830
|
+
const sourceDroppableId = this.#dragState.sourceDroppableId();
|
|
3831
|
+
const sourceIndex = this.#dragState.sourceIndex();
|
|
3832
|
+
if (isFirstPlacement && sourceDroppableId === this.vdndDroppable() && sourceIndex !== null) {
|
|
3833
|
+
if (currentIndex === sourceIndex) {
|
|
3834
|
+
return;
|
|
3835
|
+
}
|
|
3836
|
+
previousIndex = sourceIndex;
|
|
3837
|
+
}
|
|
3838
|
+
this.placeholderMove.emit({
|
|
3839
|
+
draggableId: draggedItem.draggableId,
|
|
3840
|
+
sourceDroppableId: sourceDroppableId ?? '',
|
|
3841
|
+
droppableId: this.vdndDroppable(),
|
|
3842
|
+
previousIndex,
|
|
3843
|
+
currentIndex,
|
|
3844
|
+
data: draggedItem.data,
|
|
3845
|
+
});
|
|
3292
3846
|
}
|
|
3293
3847
|
/**
|
|
3294
3848
|
* Handle a drop on this droppable.
|
|
3295
3849
|
*/
|
|
3296
3850
|
#handleDrop() {
|
|
3297
|
-
//
|
|
3298
|
-
|
|
3851
|
+
// The live state is cleared before this effect fires, so read the terminal snapshot
|
|
3852
|
+
// captured by endDrag()/cancelDrag() immediately before the reset.
|
|
3853
|
+
const state = untracked(() => this.#dragState.endedDragState());
|
|
3299
3854
|
if (!state?.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
|
|
3300
3855
|
return;
|
|
3301
3856
|
}
|
|
@@ -3306,16 +3861,17 @@ class DroppableDirective {
|
|
|
3306
3861
|
// be in the DOM after autoscroll (it gets virtualized out of view)
|
|
3307
3862
|
const sourceIndex = state.sourceIndex ?? this.#getItemIndex(state.draggedItem.draggableId, sourceDroppableId);
|
|
3308
3863
|
// Use the pre-calculated placeholderIndex if available (more reliable)
|
|
3309
|
-
// Fall back to DOM-based calculation if not
|
|
3310
|
-
|
|
3864
|
+
// Fall back to DOM-based calculation if not. Then normalize to the same
|
|
3865
|
+
// consumer-facing insertion index emitted by DragEndEvent.
|
|
3866
|
+
const placeholderIndex = state.placeholderIndex !== null && state.placeholderIndex !== undefined
|
|
3311
3867
|
? state.placeholderIndex
|
|
3312
3868
|
: this.#getDestinationIndex(placeholderId);
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
}
|
|
3869
|
+
const destinationIndex = normalizeDropDestinationIndex({
|
|
3870
|
+
sourceIndex,
|
|
3871
|
+
placeholderIndex,
|
|
3872
|
+
sourceDroppableId,
|
|
3873
|
+
activeDroppableId: this.vdndDroppable(),
|
|
3874
|
+
});
|
|
3319
3875
|
this.drop.emit({
|
|
3320
3876
|
source: {
|
|
3321
3877
|
draggableId: state.draggedItem.draggableId,
|
|
@@ -3391,7 +3947,7 @@ class DroppableDirective {
|
|
|
3391
3947
|
return this.#elementRef.nativeElement.scrollHeight;
|
|
3392
3948
|
}
|
|
3393
3949
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
3394
|
-
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 });
|
|
3950
|
+
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", placeholderMove: "placeholderMove" }, 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 });
|
|
3395
3951
|
}
|
|
3396
3952
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableDirective, decorators: [{
|
|
3397
3953
|
type: Directive,
|
|
@@ -3400,6 +3956,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
3400
3956
|
host: {
|
|
3401
3957
|
'[attr.data-droppable-id]': 'vdndDroppable()',
|
|
3402
3958
|
'[attr.data-droppable-group]': 'effectiveGroup()',
|
|
3959
|
+
'[attr.data-droppable-disabled]': 'disabled() || null',
|
|
3403
3960
|
'[attr.data-constrain-to-container]': 'constrainToContainer() || null',
|
|
3404
3961
|
'[attr.aria-dropeffect]': '"move"',
|
|
3405
3962
|
'[class.vdnd-droppable]': 'true',
|
|
@@ -3407,7 +3964,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
3407
3964
|
'[class.vdnd-droppable-disabled]': 'disabled()',
|
|
3408
3965
|
},
|
|
3409
3966
|
}]
|
|
3410
|
-
}], ctorParameters: () => [], propDecorators: { vdndDroppable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppable", required: true }] }], vdndDroppableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableGroup", required: false }] }], vdndDroppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }] } });
|
|
3967
|
+
}], ctorParameters: () => [], propDecorators: { vdndDroppable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppable", required: true }] }], vdndDroppableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableGroup", required: false }] }], vdndDroppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }], placeholderMove: [{ type: i0.Output, args: ["placeholderMove"] }] } });
|
|
3411
3968
|
|
|
3412
3969
|
/**
|
|
3413
3970
|
* A high-level component that combines droppable, virtual scroll, and placeholder
|
|
@@ -3496,8 +4053,10 @@ class VirtualSortableListComponent {
|
|
|
3496
4053
|
/** Emits when an item is dropped on this list */
|
|
3497
4054
|
// eslint-disable-next-line @angular-eslint/no-output-native
|
|
3498
4055
|
drop = output();
|
|
4056
|
+
/** Emits each time the placeholder moves within this list (every item displacement) */
|
|
4057
|
+
placeholderMove = output();
|
|
3499
4058
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3500
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualSortableListComponent, isStandalone: true, selector: "vdnd-sortable-list", inputs: { droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: true, transformFunction: null }, group: { classPropertyName: "group", publicName: "group", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableData: { classPropertyName: "droppableData", publicName: "droppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", 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: { classAttribute: "vdnd-sortable-list" }, ngImport: i0, template: `
|
|
4059
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualSortableListComponent, isStandalone: true, selector: "vdnd-sortable-list", inputs: { droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: true, transformFunction: null }, group: { classPropertyName: "group", publicName: "group", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableData: { classPropertyName: "droppableData", publicName: "droppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", 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", placeholderMove: "placeholderMove" }, host: { classAttribute: "vdnd-sortable-list" }, ngImport: i0, template: `
|
|
3501
4060
|
<div
|
|
3502
4061
|
class="vdnd-sortable-list-droppable"
|
|
3503
4062
|
[vdndDroppable]="droppableId()"
|
|
@@ -3506,6 +4065,7 @@ class VirtualSortableListComponent {
|
|
|
3506
4065
|
[disabled]="disabled()"
|
|
3507
4066
|
[constrainToContainer]="constrainToContainer()"
|
|
3508
4067
|
(drop)="drop.emit($event)"
|
|
4068
|
+
(placeholderMove)="placeholderMove.emit($event)"
|
|
3509
4069
|
>
|
|
3510
4070
|
<vdnd-virtual-scroll
|
|
3511
4071
|
[items]="items()"
|
|
@@ -3523,7 +4083,7 @@ class VirtualSortableListComponent {
|
|
|
3523
4083
|
>
|
|
3524
4084
|
</vdnd-virtual-scroll>
|
|
3525
4085
|
</div>
|
|
3526
|
-
`, isInline: true, styles: [":host{display:block}\n"], dependencies: [{ kind: "component", type: VirtualScrollContainerComponent, selector: "vdnd-virtual-scroll", inputs: ["itemTemplate", "scrollContainerId", "autoScrollEnabled", "autoScrollConfig", "items", "itemHeight", "dynamicItemHeight", "containerHeight", "overscan", "stickyItemIds", "itemIdFn", "trackByFn", "droppableId", "autoStickyDraggedItem"] }, { kind: "directive", type: DroppableDirective, selector: "[vdndDroppable]", inputs: ["vdndDroppable", "vdndDroppableGroup", "vdndDroppableData", "disabled", "autoScrollEnabled", "autoScrollConfig", "constrainToContainer"], outputs: ["drop"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
4086
|
+
`, isInline: true, styles: [":host{display:block}\n"], dependencies: [{ kind: "component", type: VirtualScrollContainerComponent, selector: "vdnd-virtual-scroll", inputs: ["itemTemplate", "scrollContainerId", "autoScrollEnabled", "autoScrollConfig", "items", "itemHeight", "dynamicItemHeight", "containerHeight", "overscan", "stickyItemIds", "itemIdFn", "trackByFn", "droppableId", "autoStickyDraggedItem"] }, { kind: "directive", type: DroppableDirective, selector: "[vdndDroppable]", inputs: ["vdndDroppable", "vdndDroppableGroup", "vdndDroppableData", "disabled", "autoScrollEnabled", "autoScrollConfig", "constrainToContainer"], outputs: ["drop", "placeholderMove"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3527
4087
|
}
|
|
3528
4088
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, decorators: [{
|
|
3529
4089
|
type: Component,
|
|
@@ -3538,6 +4098,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
3538
4098
|
[disabled]="disabled()"
|
|
3539
4099
|
[constrainToContainer]="constrainToContainer()"
|
|
3540
4100
|
(drop)="drop.emit($event)"
|
|
4101
|
+
(placeholderMove)="placeholderMove.emit($event)"
|
|
3541
4102
|
>
|
|
3542
4103
|
<vdnd-virtual-scroll
|
|
3543
4104
|
[items]="items()"
|
|
@@ -3556,7 +4117,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
3556
4117
|
</vdnd-virtual-scroll>
|
|
3557
4118
|
</div>
|
|
3558
4119
|
`, styles: [":host{display:block}\n"] }]
|
|
3559
|
-
}], propDecorators: { droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: true }] }], group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }] } });
|
|
4120
|
+
}], propDecorators: { droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: true }] }], group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }], placeholderMove: [{ type: i0.Output, args: ["placeholderMove"] }] } });
|
|
3560
4121
|
|
|
3561
4122
|
/**
|
|
3562
4123
|
* A virtual viewport component that provides efficient wrapper-based positioning
|
|
@@ -4113,36 +4674,46 @@ class KeyboardDragHandler {
|
|
|
4113
4674
|
/**
|
|
4114
4675
|
* Unified key dispatch. Returns true if the key was handled.
|
|
4115
4676
|
* Used by both host bindings and the document-level listener.
|
|
4677
|
+
*
|
|
4678
|
+
* Handled keys are consumed entirely (preventDefault + stopPropagation): right after pickup
|
|
4679
|
+
* the source element can still be focused because Angular has not applied display:none yet,
|
|
4680
|
+
* and without stopping propagation the same keydown would be processed by both the element's
|
|
4681
|
+
* host binding and the document-level listener — moving the item two positions per press.
|
|
4116
4682
|
*/
|
|
4117
4683
|
handleKey(event) {
|
|
4118
4684
|
if (!this.isActive()) {
|
|
4119
4685
|
return false;
|
|
4120
4686
|
}
|
|
4121
|
-
|
|
4687
|
+
const handled = this.#dispatchKey(event.key);
|
|
4688
|
+
if (handled) {
|
|
4689
|
+
event.preventDefault();
|
|
4690
|
+
event.stopPropagation();
|
|
4691
|
+
}
|
|
4692
|
+
return handled;
|
|
4693
|
+
}
|
|
4694
|
+
/**
|
|
4695
|
+
* Execute the drag action for a key. Returns true if the key maps to an action.
|
|
4696
|
+
*/
|
|
4697
|
+
#dispatchKey(key) {
|
|
4698
|
+
switch (key) {
|
|
4122
4699
|
case ' ': // Space
|
|
4123
4700
|
case 'Enter':
|
|
4124
|
-
event.preventDefault();
|
|
4125
4701
|
this.complete();
|
|
4126
4702
|
return true;
|
|
4127
4703
|
case 'Escape':
|
|
4128
4704
|
case 'Tab':
|
|
4129
|
-
event.preventDefault();
|
|
4130
4705
|
this.cancel();
|
|
4131
4706
|
return true;
|
|
4132
4707
|
case 'ArrowUp':
|
|
4133
|
-
event.preventDefault();
|
|
4134
4708
|
this.#deps.keyboardDrag.moveUp();
|
|
4135
4709
|
return true;
|
|
4136
4710
|
case 'ArrowDown':
|
|
4137
|
-
event.preventDefault();
|
|
4138
4711
|
this.#deps.keyboardDrag.moveDown();
|
|
4139
4712
|
return true;
|
|
4140
4713
|
case 'ArrowLeft':
|
|
4141
|
-
event.preventDefault();
|
|
4142
4714
|
this.#moveToAdjacentDroppable('left');
|
|
4143
4715
|
return true;
|
|
4144
4716
|
case 'ArrowRight':
|
|
4145
|
-
event.preventDefault();
|
|
4146
4717
|
this.#moveToAdjacentDroppable('right');
|
|
4147
4718
|
return true;
|
|
4148
4719
|
default:
|
|
@@ -4155,7 +4726,21 @@ class KeyboardDragHandler {
|
|
|
4155
4726
|
complete() {
|
|
4156
4727
|
const ctx = this.#deps.getContext();
|
|
4157
4728
|
const sourceIndex = this.#deps.dragState.sourceIndex() ?? 0;
|
|
4158
|
-
const
|
|
4729
|
+
const activeDroppableId = this.#deps.dragState.activeDroppableId();
|
|
4730
|
+
// Revalidate the active target: it may have been disabled after the drag navigated
|
|
4731
|
+
// into it. A disabled (or missing) target is not a valid drop — report no destination.
|
|
4732
|
+
// The droppable's own disabled guard suppresses the drop event, keeping dragEnd and
|
|
4733
|
+
// drop consistent (non-cancelled dragEnd with destinationIndex: null, no drop).
|
|
4734
|
+
const hasValidTarget = activeDroppableId !== null &&
|
|
4735
|
+
!this.#deps.positionCalculator.isDroppableDisabledById(activeDroppableId);
|
|
4736
|
+
const destinationIndex = hasValidTarget
|
|
4737
|
+
? normalizeDropDestinationIndex({
|
|
4738
|
+
sourceIndex,
|
|
4739
|
+
placeholderIndex: this.#deps.dragState.placeholderIndex(),
|
|
4740
|
+
sourceDroppableId: this.#deps.dragState.sourceDroppableId(),
|
|
4741
|
+
activeDroppableId,
|
|
4742
|
+
})
|
|
4743
|
+
: null;
|
|
4159
4744
|
// Remove document listener
|
|
4160
4745
|
this.#cleanupDocumentListener();
|
|
4161
4746
|
// Clear droppable metadata cache from this drag session
|
|
@@ -4243,10 +4828,19 @@ class KeyboardDragHandler {
|
|
|
4243
4828
|
if (!adjacent) {
|
|
4244
4829
|
return;
|
|
4245
4830
|
}
|
|
4831
|
+
// Derive the destination list's item count from DragIndexCalculatorService — the single
|
|
4832
|
+
// source of truth that correctly handles registered strategies, dynamic-height lists, and
|
|
4833
|
+
// vdnd-virtual-content (page-scroll) lists. Arrowing back to the origin lands on the source
|
|
4834
|
+
// list, so compute isSameList rather than assuming a distinct target.
|
|
4835
|
+
const itemCount = this.#deps.dragIndexCalculator.getTotalItemCount({
|
|
4836
|
+
droppableElement: adjacent.element,
|
|
4837
|
+
isSameList: adjacent.id === this.#deps.dragState.sourceDroppableId(),
|
|
4838
|
+
draggedItemHeight: this.#deps.dragState.draggedItem()?.height ?? 0,
|
|
4839
|
+
});
|
|
4246
4840
|
// Maintain the current target index (clamped to the new list's size)
|
|
4247
4841
|
const currentTargetIndex = this.#deps.keyboardDrag.targetIndex() ?? 0;
|
|
4248
|
-
const targetIndex = Math.min(currentTargetIndex,
|
|
4249
|
-
this.#deps.keyboardDrag.moveToDroppable(adjacent.id, targetIndex,
|
|
4842
|
+
const targetIndex = Math.min(currentTargetIndex, itemCount);
|
|
4843
|
+
this.#deps.keyboardDrag.moveToDroppable(adjacent.id, targetIndex, itemCount);
|
|
4250
4844
|
}
|
|
4251
4845
|
/**
|
|
4252
4846
|
* Document-level keydown listener active during keyboard drag.
|
|
@@ -4553,7 +5147,12 @@ class DraggableDirective {
|
|
|
4553
5147
|
* Set to 0 for immediate drag (default behavior).
|
|
4554
5148
|
*/
|
|
4555
5149
|
dragDelay = input(0, ...(ngDevMode ? [{ debugName: "dragDelay" }] : []));
|
|
4556
|
-
/**
|
|
5150
|
+
/**
|
|
5151
|
+
* Lock dragging by freezing one axis. The value names the axis that stays
|
|
5152
|
+
* fixed: `'x'` freezes the X coordinate (vertical-only movement), `'y'`
|
|
5153
|
+
* freezes the Y coordinate (horizontal-only movement). `null` allows free
|
|
5154
|
+
* movement. Note: this is the opposite of Angular CDK's `cdkDragLockAxis`.
|
|
5155
|
+
*/
|
|
4557
5156
|
lockAxis = input(null, ...(ngDevMode ? [{ debugName: "lockAxis" }] : []));
|
|
4558
5157
|
/** Emits when drag starts */
|
|
4559
5158
|
dragStart = output();
|
|
@@ -4732,7 +5331,6 @@ class DraggableDirective {
|
|
|
4732
5331
|
const clonedElement = this.#overlayContainer.hasTemplatePreview()
|
|
4733
5332
|
? undefined
|
|
4734
5333
|
: this.#elementClone.cloneElement(element);
|
|
4735
|
-
const parentDroppableId = this.#getParentDroppableId();
|
|
4736
5334
|
// Find droppable and calculate initial placeholder position
|
|
4737
5335
|
// This fixes the UI glitch by ensuring placeholder is set before the element is hidden
|
|
4738
5336
|
const groupName = this.#effectiveGroup();
|
|
@@ -4741,6 +5339,10 @@ class DraggableDirective {
|
|
|
4741
5339
|
this.#pointerHandler.cleanup();
|
|
4742
5340
|
return;
|
|
4743
5341
|
}
|
|
5342
|
+
const parentDroppableElement = this.#positionCalculator.getDroppableParent(element, groupName);
|
|
5343
|
+
const parentDroppableId = parentDroppableElement
|
|
5344
|
+
? this.#positionCalculator.getDroppableId(parentDroppableElement)
|
|
5345
|
+
: null;
|
|
4744
5346
|
// Snapshot droppable rects for this drag session so subsequent hit-testing is
|
|
4745
5347
|
// pure geometry (no elementFromPoint layout flush per pointermove).
|
|
4746
5348
|
this.#positionCalculator.beginDragSession(groupName);
|
|
@@ -4756,7 +5358,7 @@ class DraggableDirective {
|
|
|
4756
5358
|
: parentDroppableId;
|
|
4757
5359
|
// Calculate source index BEFORE the element is hidden (display: none)
|
|
4758
5360
|
// This is critical because getBoundingClientRect() returns all zeros for hidden elements
|
|
4759
|
-
const sourceIndex = this.#calculateSourceIndex(element,
|
|
5361
|
+
const sourceIndex = this.#calculateSourceIndex(element, parentDroppableElement);
|
|
4760
5362
|
let initialPlaceholderId = null;
|
|
4761
5363
|
let initialPlaceholderIndex = null;
|
|
4762
5364
|
if (droppableElement) {
|
|
@@ -4852,10 +5454,24 @@ class DraggableDirective {
|
|
|
4852
5454
|
return strategy.findIndexAtOffset(relativeY);
|
|
4853
5455
|
}
|
|
4854
5456
|
}
|
|
4855
|
-
//
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
5457
|
+
// Preserve the geometry fallback for a virtual container whose strategy is unavailable.
|
|
5458
|
+
if (virtualScroll || virtualContent) {
|
|
5459
|
+
const itemHeight = rect.height || 50;
|
|
5460
|
+
const relativeY = rect.top - containerRect.top + scrollTop;
|
|
5461
|
+
return Math.round(relativeY / itemHeight);
|
|
5462
|
+
}
|
|
5463
|
+
// Non-virtual fallback: derive the logical index from preceding draggable siblings.
|
|
5464
|
+
// Geometry-based division is incorrect when the list has padding, gaps, margins,
|
|
5465
|
+
// or variable-height items.
|
|
5466
|
+
let sourceIndex = 0;
|
|
5467
|
+
let sibling = element.previousElementSibling;
|
|
5468
|
+
while (sibling) {
|
|
5469
|
+
if (sibling.matches('[data-draggable-id]')) {
|
|
5470
|
+
sourceIndex++;
|
|
5471
|
+
}
|
|
5472
|
+
sibling = sibling.previousElementSibling;
|
|
5473
|
+
}
|
|
5474
|
+
return sourceIndex;
|
|
4859
5475
|
}
|
|
4860
5476
|
/**
|
|
4861
5477
|
* Recalculate placeholder position (called during auto-scroll).
|
|
@@ -5001,6 +5617,16 @@ class DraggableDirective {
|
|
|
5001
5617
|
* End the drag operation.
|
|
5002
5618
|
*/
|
|
5003
5619
|
#endDrag(cancelled) {
|
|
5620
|
+
// Flush the final pointer position for a real drop. Pointer moves only QUEUE a cursor
|
|
5621
|
+
// for the next RAF tick; on release the scheduler is stopped below and any un-processed
|
|
5622
|
+
// cursor is discarded, so without this the drop would resolve against the last
|
|
5623
|
+
// RAF-PROCESSED position — a release that just entered a different (e.g. newly disabled)
|
|
5624
|
+
// droppable could otherwise land on the previously-targeted one. Guarded on group
|
|
5625
|
+
// presence so it never re-enters #endDrag via #updateDrag's group-lost cancel path.
|
|
5626
|
+
// Cancels skip this: their destination is intentionally null.
|
|
5627
|
+
if (!cancelled && this.#lastRawPosition && this.isDragging() && this.#effectiveGroup()) {
|
|
5628
|
+
this.#updateDrag(this.#lastRawPosition);
|
|
5629
|
+
}
|
|
5004
5630
|
// Stop the scheduler RAF loop first, then remove the autoscroll participant.
|
|
5005
5631
|
this.#scheduler.stop();
|
|
5006
5632
|
this.#autoScroll.stopMonitoring();
|
|
@@ -5013,20 +5639,14 @@ class DraggableDirective {
|
|
|
5013
5639
|
this.#constraintElement = null;
|
|
5014
5640
|
this.#lastRawPosition = null;
|
|
5015
5641
|
const sourceIndex = this.#dragState.sourceIndex() ?? 0;
|
|
5016
|
-
const
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
if (sourceDroppableId !== null && sourceDroppableId === activeDroppableId) {
|
|
5025
|
-
if (sourceIndex < placeholderIndex) {
|
|
5026
|
-
destinationIndex = placeholderIndex - 1;
|
|
5027
|
-
}
|
|
5028
|
-
}
|
|
5029
|
-
}
|
|
5642
|
+
const destinationIndex = cancelled
|
|
5643
|
+
? null
|
|
5644
|
+
: normalizeDropDestinationIndex({
|
|
5645
|
+
sourceIndex,
|
|
5646
|
+
placeholderIndex: this.#dragState.placeholderIndex(),
|
|
5647
|
+
sourceDroppableId: this.#dragState.sourceDroppableId(),
|
|
5648
|
+
activeDroppableId: this.#dragState.activeDroppableId(),
|
|
5649
|
+
});
|
|
5030
5650
|
// Emit drag end event
|
|
5031
5651
|
this.dragEnd.emit({
|
|
5032
5652
|
draggableId: this.vdndDraggable(),
|
|
@@ -5214,6 +5834,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
5214
5834
|
}]
|
|
5215
5835
|
}], 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 }] }] } });
|
|
5216
5836
|
|
|
5837
|
+
/** Render-queue key of the placeholder entry */
|
|
5838
|
+
const PLACEHOLDER_KEY = '__placeholder__';
|
|
5217
5839
|
/**
|
|
5218
5840
|
* A structural directive for virtual scrolling within custom scroll containers.
|
|
5219
5841
|
* Provides maximum flexibility for advanced use cases where the component wrapper
|
|
@@ -5265,6 +5887,8 @@ class VirtualForDirective {
|
|
|
5265
5887
|
* When present, droppableId is inherited automatically.
|
|
5266
5888
|
*/
|
|
5267
5889
|
#droppable = inject(DroppableDirective, { optional: true });
|
|
5890
|
+
/** Slides items displaced by the placeholder (only when VDND_ANIMATION_CONFIG is provided) */
|
|
5891
|
+
#shiftAnimator = this.#createShiftAnimator();
|
|
5268
5892
|
/** Whether we're inside a viewport component (use wrapper positioning) */
|
|
5269
5893
|
#useViewportPositioning = this.#viewport !== null;
|
|
5270
5894
|
/** Pool of views for reuse */
|
|
@@ -5464,6 +6088,31 @@ class VirtualForDirective {
|
|
|
5464
6088
|
this.#placeholder?.remove();
|
|
5465
6089
|
// Clean up ResizeObserver
|
|
5466
6090
|
this.#resizeObserver?.disconnect();
|
|
6091
|
+
this.#shiftAnimator?.cancelAll();
|
|
6092
|
+
}
|
|
6093
|
+
#createShiftAnimator() {
|
|
6094
|
+
const config = inject(VDND_ANIMATION_CONFIG, { optional: true });
|
|
6095
|
+
if (!config)
|
|
6096
|
+
return null;
|
|
6097
|
+
return new ShiftAnimator({
|
|
6098
|
+
config,
|
|
6099
|
+
injector: this.#injector,
|
|
6100
|
+
getScrollElement: () => this.#scrollContainer.nativeElement,
|
|
6101
|
+
getEntries: () => this.#shiftAnimationEntries(),
|
|
6102
|
+
});
|
|
6103
|
+
}
|
|
6104
|
+
/** Rendered item root elements (keyed by trackBy key) plus the placeholder. */
|
|
6105
|
+
*#shiftAnimationEntries() {
|
|
6106
|
+
for (const [key, view] of this.#activeViews) {
|
|
6107
|
+
for (const node of view.rootNodes) {
|
|
6108
|
+
if (node instanceof HTMLElement) {
|
|
6109
|
+
yield [key, node];
|
|
6110
|
+
}
|
|
6111
|
+
}
|
|
6112
|
+
}
|
|
6113
|
+
if (this.#placeholder && this.#placeholderInDom) {
|
|
6114
|
+
yield [PLACEHOLDER_KEY, this.#placeholder];
|
|
6115
|
+
}
|
|
5467
6116
|
}
|
|
5468
6117
|
/**
|
|
5469
6118
|
* Create the placeholder element for drag operations.
|
|
@@ -5537,6 +6186,19 @@ class VirtualForDirective {
|
|
|
5537
6186
|
}
|
|
5538
6187
|
}
|
|
5539
6188
|
}
|
|
6189
|
+
/**
|
|
6190
|
+
* Stop any shift animation on a view about to be pooled — a recycled view must not
|
|
6191
|
+
* carry an in-flight offset over to the item it renders next.
|
|
6192
|
+
*/
|
|
6193
|
+
#cancelShiftAnimation(view) {
|
|
6194
|
+
if (!this.#shiftAnimator)
|
|
6195
|
+
return;
|
|
6196
|
+
for (const node of view.rootNodes) {
|
|
6197
|
+
if (node instanceof HTMLElement) {
|
|
6198
|
+
this.#shiftAnimator.cancel(node);
|
|
6199
|
+
}
|
|
6200
|
+
}
|
|
6201
|
+
}
|
|
5540
6202
|
/**
|
|
5541
6203
|
* Stop observing an element's root nodes.
|
|
5542
6204
|
*/
|
|
@@ -5569,6 +6231,8 @@ class VirtualForDirective {
|
|
|
5569
6231
|
draggedIndex >= 0 &&
|
|
5570
6232
|
isSourceList &&
|
|
5571
6233
|
draggedIndex < items.length;
|
|
6234
|
+
// Snapshot positions before the DOM changes so displaced items can slide
|
|
6235
|
+
this.#shiftAnimator?.beforeUpdate(this.#dragState.isDragging(), placeholderIndex);
|
|
5572
6236
|
// Notify viewport of render start index for wrapper positioning
|
|
5573
6237
|
this.#notifyViewportRenderStart(start);
|
|
5574
6238
|
// 1. Build the list of items to render
|
|
@@ -5614,7 +6278,7 @@ class VirtualForDirective {
|
|
|
5614
6278
|
if (showPlaceholder && placeholderIndex === i && !placeholderInserted) {
|
|
5615
6279
|
itemsToRender.push({
|
|
5616
6280
|
type: 'placeholder',
|
|
5617
|
-
key:
|
|
6281
|
+
key: PLACEHOLDER_KEY,
|
|
5618
6282
|
context: null,
|
|
5619
6283
|
visualIndex: placeholderIndex,
|
|
5620
6284
|
});
|
|
@@ -5638,7 +6302,7 @@ class VirtualForDirective {
|
|
|
5638
6302
|
if (showPlaceholder && placeholderIndex >= items.length && !placeholderInserted) {
|
|
5639
6303
|
itemsToRender.push({
|
|
5640
6304
|
type: 'placeholder',
|
|
5641
|
-
key:
|
|
6305
|
+
key: PLACEHOLDER_KEY,
|
|
5642
6306
|
context: null,
|
|
5643
6307
|
visualIndex: placeholderIndex,
|
|
5644
6308
|
});
|
|
@@ -5681,6 +6345,7 @@ class VirtualForDirective {
|
|
|
5681
6345
|
this.#viewContainer.detach(index);
|
|
5682
6346
|
}
|
|
5683
6347
|
this.#unobserveViewElements(view);
|
|
6348
|
+
this.#cancelShiftAnimation(view);
|
|
5684
6349
|
this.#viewPool.push(view);
|
|
5685
6350
|
this.#activeViews.delete(key);
|
|
5686
6351
|
}
|
|
@@ -6075,5 +6740,5 @@ function removeAt(list, index) {
|
|
|
6075
6740
|
* Generated bundle index. Do not edit.
|
|
6076
6741
|
*/
|
|
6077
6742
|
|
|
6078
|
-
export { AutoScrollService, ContentHeaderDirective, DragPlaceholderComponent, DragPreviewComponent, DragStateService, DraggableDirective, DroppableDirective, DroppableGroupDirective, DynamicHeightStrategy, END_OF_LIST, ElementCloneService, FixedHeightStrategy, INITIAL_DRAG_STATE, KeyboardDragService, OverlayContainerService, PlaceholderComponent, PositionCalculatorService, ScrollableDirective, VDND_GROUP_TOKEN, VDND_SCROLL_CONTAINER, VDND_VIRTUAL_VIEWPORT, VirtualContentComponent, VirtualForDirective, VirtualScrollContainerComponent, VirtualSortableListComponent, VirtualViewportComponent, applyMove, insertAt, isNoOpDrop, moveItem, removeAt, reorderItems };
|
|
6743
|
+
export { AutoScrollService, ContentHeaderDirective, DragPlaceholderComponent, DragPreviewComponent, DragStateService, DraggableDirective, DroppableDirective, DroppableGroupDirective, DynamicHeightStrategy, END_OF_LIST, ElementCloneService, FixedHeightStrategy, INITIAL_DRAG_STATE, KeyboardDragService, OverlayContainerService, PlaceholderComponent, PositionCalculatorService, ScrollableDirective, VDND_ANIMATION_CONFIG, VDND_GROUP_TOKEN, VDND_SCROLL_CONTAINER, VDND_VIRTUAL_VIEWPORT, VirtualContentComponent, VirtualForDirective, VirtualScrollContainerComponent, VirtualSortableListComponent, VirtualViewportComponent, applyMove, insertAt, isNoOpDrop, moveItem, removeAt, reorderItems };
|
|
6079
6744
|
//# sourceMappingURL=ngx-virtual-dnd.mjs.map
|