diff --git a/src/cdk/drag-drop/directives/drop-list.ts b/src/cdk/drag-drop/directives/drop-list.ts index 8ff7eb3654fb..aeb0b42e7c3e 100644 --- a/src/cdk/drag-drop/directives/drop-list.ts +++ b/src/cdk/drag-drop/directives/drop-list.ts @@ -363,6 +363,10 @@ export class CdkDropList implements OnDestroy { /** Handles events from the underlying DropListRef. */ private _handleEvents(ref: DropListRef) { + merge(ref.receivingStarted, ref.receivingStopped) + .pipe(takeUntil(this._destroyed)) + .subscribe(() => this._changeDetectorRef.markForCheck()); + ref.beforeStarted.subscribe(() => { this._syncItemsWithRef(this.getSortedItems().map(item => item._dragRef)); this._changeDetectorRef.markForCheck(); diff --git a/src/cdk/drag-drop/drop-list-ref.ts b/src/cdk/drag-drop/drop-list-ref.ts index 000b3c188691..0b0bd16cf0e0 100644 --- a/src/cdk/drag-drop/drop-list-ref.ts +++ b/src/cdk/drag-drop/drop-list-ref.ts @@ -211,6 +211,12 @@ export class DropListRef { /** Direction of the list's layout. */ private _direction: Direction = 'ltr'; + /** + * Cache of all connected lists (including self) ordered by DOM hierarchy — outer containers + * first, inner containers last. Invalidated whenever siblings change. + */ + private _cachedSortedSiblings: DropListRef[] | null = null; + constructor( element: ElementRef | HTMLElement, private _dragDropRegistry: DragDropRegistry, @@ -240,6 +246,7 @@ export class DropListRef { this._activeSiblings.clear(); this._scrollNode = null!; this._parentPositions.clear(); + this._cachedSortedSiblings = null; this._dragDropRegistry.removeDropContainer(this); } @@ -368,6 +375,8 @@ export class DropListRef { */ connectedTo(connectedTo: DropListRef[]): this { this._siblings = connectedTo.slice(); + // Invalidate the hierarchy cache since siblings have changed. + this._cachedSortedSiblings = null; return this; } @@ -443,6 +452,8 @@ export class DropListRef { } this._cachedShadowRoot = null; + // Invalidate the hierarchy cache since the DOM structure may have changed. + this._cachedSortedSiblings = null; this._scrollableElements.unshift(container); this._container = container; return this; @@ -687,7 +698,101 @@ export class DropListRef { * @param y Position of the item along the Y axis. */ _getSiblingContainerFromPosition(item: DragRef, x: number, y: number): DropListRef | undefined { - return this._siblings.find(sibling => sibling._canReceive(item, x, y)); + // Possible targets include siblings and 'this'. + const targets = [this, ...this._siblings]; + + // Only consider targets where the drag position is within the client rect + // (this avoids calling enterPredicate on each possible target). + const matchingTargets = targets.filter( + ref => ref._domRect && isInsideClientRect(ref._domRect, x, y), + ); + + // Stop if no targets match the coordinates. + if (matchingTargets.length === 0) { + return undefined; + } + + // Use the cached hierarchy order, computing it only when siblings have changed. + // This avoids rebuilding the DOM tree on every pointermove event. + if (!this._cachedSortedSiblings) { + this._cachedSortedSiblings = this._orderByHierarchy([this, ...this._siblings]); + } + + // Filter the pre-ordered list to retain only the matching targets, + // preserving the hierarchy order without re-sorting. + const orderedMatchingTargets = this._cachedSortedSiblings.filter(ref => + matchingTargets.includes(ref), + ); + + // The drop target is the last matching target in the ordered list, + // i.e. the innermost container in the DOM hierarchy. + const matchingTarget = orderedMatchingTargets[orderedMatchingTargets.length - 1]; + + // Only return the matching target if it is a sibling, not 'this'. + if (matchingTarget === this) { + return undefined; + } + + // Can the matching target receive the item? + if (!matchingTarget._canReceive(item, x, y)) { + return undefined; + } + + return matchingTarget; + } + + /** + * Sorts a list of DropListRefs such that for every nested pair of drop containers, + * the outer drop container appears before the inner drop container. + * @param refs List of DropListRefs to sort. + */ + private _orderByHierarchy(refs: DropListRef[]): DropListRef[] { + // Build a map from HTMLElement to DropListRef for fast ancestor lookup. + const refsByElement = new Map(); + refs.forEach(ref => refsByElement.set(ref._container, ref)); + + // Finds the closest ancestor DropListRef of a given ref, if any. + const findAncestor = (ref: DropListRef): DropListRef | undefined => { + let ancestor = ref._container.parentElement; + while (ancestor) { + if (refsByElement.has(ancestor)) { + return refsByElement.get(ancestor); + } + ancestor = ancestor.parentElement; + } + return undefined; + }; + + // Node type for the tree structure. + type NodeType = {ref: DropListRef; parent?: NodeType; children: NodeType[]}; + + // Create a tree node for each ref. + const tree = new Map(); + refs.forEach(ref => tree.set(ref, {ref, children: []})); + + // Build parent-child relationships. + refs.forEach(ref => { + const parent = findAncestor(ref); + if (parent) { + const node = tree.get(ref)!; + const parentNode = tree.get(parent)!; + node.parent = parentNode; + parentNode.children.push(node); + } + }); + + // Find root nodes (those without a parent among the refs). + const roots = Array.from(tree.values()).filter(node => !node.parent); + + // Recursively build the ordered list: parent before children. + const buildOrderedList = (nodes: NodeType[], list: DropListRef[]) => { + list.push(...nodes.map(node => node.ref)); + nodes.forEach(node => buildOrderedList(node.children, list)); + }; + + const ordered: DropListRef[] = []; + buildOrderedList(roots, ordered); + return ordered; } /** @@ -863,7 +968,7 @@ function getElementScrollDirections( // Note that we here we do some extra checks for whether the element is actually scrollable in // a certain direction and we only assign the scroll direction if it is. We do this so that we - // can allow other elements to be scrolled, if the current element can't be scrolled anymore. + // can allow other elements to be scrolled, if the current element can't be scrollated anymore. // This allows us to handle cases where the scroll regions of two scrollable elements overlap. if (computedVertical) { const scrollTop = element.scrollTop; diff --git a/src/dev-app/drag-drop/drag-drop-demo.html b/src/dev-app/drag-drop/drag-drop-demo.html index 104101ae2496..ee3488b48664 100644 --- a/src/dev-app/drag-drop/drag-drop-demo.html +++ b/src/dev-app/drag-drop/drag-drop-demo.html @@ -1,3 +1,44 @@ +
+
+

To do

+ +
+ @for (item of todo2; track item) { +
+ @if (isArray(item)) { +
+
+ @for (innerItem of item; track innerItem) { +
+ {{innerItem}} + +
+ } +
+
+ } @else { +
+ {{item}} + +
+ } +
+ } +
+
+
+

To do

@@ -5,7 +46,8 @@

To do

cdkDropList (cdkDropListDropped)="drop($event)" [cdkDropListLockAxis]="axisLock" - [cdkDropListData]="todo"> + [cdkDropListData]="todo" + > @for (item of todo; track item) {
{{item}} @@ -21,10 +63,11 @@

Done

cdkDropList (cdkDropListDropped)="drop($event)" [cdkDropListLockAxis]="axisLock" - [cdkDropListData]="done"> + [cdkDropListData]="done" + > @for (item of done; track item) {
- {{item}} + {{ item }}
} @@ -40,10 +83,11 @@

Ages

cdkDropListOrientation="horizontal" (cdkDropListDropped)="drop($event)" [cdkDropListLockAxis]="axisLock" - [cdkDropListData]="ages"> + [cdkDropListData]="ages" + > @for (item of ages; track item) {
- {{item}} + {{ item }}
} @@ -57,10 +101,11 @@

Preferred Ages

cdkDropListOrientation="horizontal" (cdkDropListDropped)="drop($event)" [cdkDropListLockAxis]="axisLock" - [cdkDropListData]="preferredAges"> + [cdkDropListData]="preferredAges" + > @for (item of preferredAges; track item) {
- {{item}} + {{ item }}
} @@ -78,16 +123,18 @@

Mixed orientation

+ [class.demo-list-horizontal]="mixedWrap" + >
+ [cdkDropListData]="mixedTodo" + > @for (item of mixedTodo; track item) {
- {{item}} + {{ item }}
} @@ -97,16 +144,18 @@

Mixed orientation

+ [class.demo-list-horizontal]="mixedWrap" + >
+ [cdkDropListData]="mixedDone" + > @for (item of mixedDone; track item) {
- {{item}} + {{ item }}
} @@ -159,10 +208,10 @@

Drag with box boundary and custom constrain

Data

-
{{todo.join(', ')}}
-
{{done.join(', ')}}
-
{{ages.join(', ')}}
-
{{preferredAges.join(', ')}}
+
{{ todo.join(', ') }}
+
{{ done.join(', ') }}
+
{{ ages.join(', ') }}
+
{{ preferredAges.join(', ') }}
diff --git a/src/dev-app/drag-drop/drag-drop-demo.ts b/src/dev-app/drag-drop/drag-drop-demo.ts index d4bd63ea6601..421eedc19e64 100644 --- a/src/dev-app/drag-drop/drag-drop-demo.ts +++ b/src/dev-app/drag-drop/drag-drop-demo.ts @@ -6,16 +6,24 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Component, ViewEncapsulation, ChangeDetectionStrategy, inject} from '@angular/core'; +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, + inject, + QueryList, + AfterViewInit, + ViewChildren, +} from '@angular/core'; import {MatIconModule, MatIconRegistry} from '@angular/material/icon'; import {DomSanitizer} from '@angular/platform-browser'; import { - CdkDragDrop, DragDropModule, moveItemInArray, transferArrayItem, Point, DragRef, + CdkDropList, } from '@angular/cdk/drag-drop'; import {FormsModule} from '@angular/forms'; import {MatFormFieldModule} from '@angular/material/form-field'; @@ -39,7 +47,7 @@ import {MatCheckbox} from '@angular/material/checkbox'; MatCheckbox, ], }) -export class DragAndDropDemo { +export class DragAndDropDemo implements AfterViewInit { axisLock!: 'x' | 'y'; dragStartDelay = 0; todo = ['Go out for Lunch', 'Make a cool app', 'Watch TV', 'Eat a healthy dinner', 'Go to sleep']; @@ -50,6 +58,17 @@ export class DragAndDropDemo { ages = ['Stone age', 'Bronze age', 'Iron age', 'Middle ages']; preferredAges = ['Modern period', 'Renaissance']; + dls: CdkDropList[] = []; + + todo2 = [ + 'Get to work', + ['Get up', 'Brush teeth', 'Take a shower', 'Check e-mail', 'Walk dog'], + ['Preare for work', 'Drive to office', 'Üark car'], + 'Pick up groceries', + 'Go home', + 'Fall asleep', + ]; + @ViewChildren(CdkDropList) dlq: QueryList | undefined; constructor() { const iconRegistry = inject(MatIconRegistry); @@ -69,7 +88,21 @@ export class DragAndDropDemo { ); } - drop(event: CdkDragDrop) { + ngAfterViewInit() { + const updateList = () => { + const ldls: CdkDropList[] = []; + this.dlq?.forEach(dl => ldls.push(dl)); + queueMicrotask(() => { + this.dls = ldls; + }); + }; + + updateList(); + this.dlq?.changes.subscribe(() => updateList()); + } + + // CdkDragDrop + drop(event: any) { if (event.previousContainer === event.container) { moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); } else { @@ -88,4 +121,8 @@ export class DragAndDropDemo { y -= pickup.y; return {x, y}; } + + isArray(item: any): boolean { + return Array.isArray(item); + } }