diff --git a/src/cdk/drag-drop/drop-list-ref.ts b/src/cdk/drag-drop/drop-list-ref.ts index 2e2fd8436676..d97f71ea0638 100644 --- a/src/cdk/drag-drop/drop-list-ref.ts +++ b/src/cdk/drag-drop/drop-list-ref.ts @@ -13,15 +13,17 @@ import {ViewportRuler} from '../scrolling'; import {_getShadowRoot} from '../platform'; import {Subject, Subscription, interval, animationFrameScheduler} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; +import {moveItemInArray} from './drag-utils'; import {DragDropRegistry} from './drag-drop-registry'; -import type {DragRef, Point} from './drag-ref'; -import {isPointerNearDomRect, isInsideClientRect} from './dom/dom-rect'; +import {DragRef, Point} from './drag-ref'; +import { + isPointerNearDomRect, + adjustDomRect, + getMutableClientRect, + isInsideClientRect, +} from './dom/dom-rect'; import {ParentPositionTracker} from './dom/parent-position-tracker'; import {DragCSSStyleDeclaration} from './dom/styling'; -import {DropListSortStrategy} from './sorting/drop-list-sort-strategy'; -import {SingleAxisSortStrategy} from './sorting/single-axis-sort-strategy'; -import {MixedSortStrategy} from './sorting/mixed-sort-strategy'; -import {DropListOrientation} from './directives/config'; /** * Proximity, as a ratio to width/height, at which a @@ -35,6 +37,22 @@ const DROP_PROXIMITY_THRESHOLD = 0.05; */ const SCROLL_PROXIMITY_THRESHOLD = 0.05; +/** Number of pixels to scroll for each frame when auto-scrolling an element. */ +// Kept as an instance property on the ref so it can be configured via the directive. + +/** + * Entry in the position cache for draggable items. + * @docs-private + */ +interface CachedItemPosition { + /** Instance of the drag item. */ + drag: DragRef; + /** Dimensions of the item. */ + clientRect: DOMRect; + /** Amount by which the item has been moved since dragging started. */ + offset: number; +} + /** Vertical direction in which we can auto-scroll. */ enum AutoScrollVerticalDirection { NONE, @@ -49,6 +67,13 @@ enum AutoScrollHorizontalDirection { RIGHT, } +/** + * Internal compile-time-only representation of a `DropListRef`. + * Used to avoid circular import issues between the `DropListRef` and the `DragRef`. + * @docs-private + */ +export interface DropListRefInternal extends DropListRef {} + /** * Reference to a drop list. Used to manipulate or dispose of the container. */ @@ -74,36 +99,31 @@ export class DropListRef { /** Number of pixels to scroll for each frame when auto-scrolling an element. */ autoScrollStep: number = 2; - /** - * Whether the items in the list should leave an anchor node when leaving the initial container. - */ - hasAnchor: boolean = false; - /** * Function that is used to determine whether an item * is allowed to be moved into a drop container. */ enterPredicate: (drag: DragRef, drop: DropListRef) => boolean = () => true; - /** Function that is used to determine whether an item can be sorted into a particular index. */ + /** Functions that is used to determine whether an item can be sorted into a particular index. */ sortPredicate: (index: number, drag: DragRef, drop: DropListRef) => boolean = () => true; /** Emits right before dragging has started. */ - readonly beforeStarted = new Subject(); + beforeStarted = new Subject(); /** * Emits when the user has moved a new drag item into this container. */ - readonly entered = new Subject<{item: DragRef; container: DropListRef; currentIndex: number}>(); + entered = new Subject<{item: DragRef; container: DropListRef; currentIndex: number}>(); /** * Emits when the user removes an item from the container * by dragging it into another container. */ - readonly exited = new Subject<{item: DragRef; container: DropListRef}>(); + exited = new Subject<{item: DragRef; container: DropListRef}>(); /** Emits when the user drops an item inside the container. */ - readonly dropped = new Subject<{ + dropped = new Subject<{ item: DragRef; currentIndex: number; previousIndex: number; @@ -116,43 +136,41 @@ export class DropListRef { }>(); /** Emits as the user is swapping items while actively dragging. */ - readonly sorted = new Subject<{ + sorted = new Subject<{ previousIndex: number; currentIndex: number; container: DropListRef; item: DragRef; }>(); - /** Emits when a dragging sequence is started in a list connected to the current one. */ - readonly receivingStarted = new Subject<{ - receiver: DropListRef; - initiator: DropListRef; - items: DragRef[]; - }>(); - - /** Emits when a dragging sequence is stopped from a list connected to the current one. */ - readonly receivingStopped = new Subject<{ - receiver: DropListRef; - initiator: DropListRef; - }>(); - /** Arbitrary data that can be attached to the drop list. */ data: T; - /** Element that is the direct parent of the drag items. */ - private _container: HTMLElement; - /** Whether an item in the list is being dragged. */ private _isDragging = false; + /** Cache of the dimensions of all the items inside the container. */ + private _itemPositions: CachedItemPosition[] = []; + /** Keeps track of the positions of any parent scrollable elements. */ private _parentPositions: ParentPositionTracker; - /** Strategy being used to sort items within the list. */ - private _sortStrategy: DropListSortStrategy; + /** Cached `ClientRect` of the drop list. */ + private _clientRect: DOMRect | undefined; - /** Cached `DOMRect` of the drop list. */ - private _domRect: DOMRect | undefined; + /** + * Draggable items that are currently active inside the container. Includes the items + * from `_draggables`, as well as any items that have been dragged in, but haven't + * been dropped yet. + */ + private _activeDraggables: DragRef[]; + + /** + * Keeps track of the item that was last swapped with the dragged item, as well as what direction + * the pointer was moving in when the swap occured and whether the user's pointer continued to + * overlap with the swapped item after the swapping occurred. + */ + private _previousSwap = {drag: null as DragRef | null, delta: 0, overlaps: false}; /** Draggable items in the container. */ private _draggables: readonly DragRef[] = []; @@ -160,9 +178,15 @@ export class DropListRef { /** Drop lists that are connected to the current one. */ private _siblings: readonly DropListRef[] = []; + /** Direction in which the list is oriented. */ + private _orientation: 'horizontal' | 'vertical' = 'vertical'; + /** Connected siblings that currently have a dragged item. */ private _activeSiblings = new Set(); + /** Layout direction of the drop list. */ + private _direction: Direction = 'ltr'; + /** Subscription to the window being scrolled. */ private _viewportScrollSubscription = Subscription.EMPTY; @@ -176,7 +200,7 @@ export class DropListRef { private _scrollNode: HTMLElement | Window; /** Used to signal to the current auto-scroll sequence when to stop. */ - private readonly _stopScrollTimers = new Subject(); + private _stopScrollTimers = new Subject(); /** Shadow root of the current element. Necessary for `elementFromPoint` to resolve correctly. */ private _cachedShadowRoot: DocumentOrShadowRoot | null = null; @@ -185,14 +209,11 @@ export class DropListRef { private _document: Document; /** Elements that can be scrolled while the user is dragging. */ - private _scrollableElements: HTMLElement[] = []; + private _scrollableElements: HTMLElement[]; /** Initial value for the element's `scroll-snap-type` style. */ private _initialScrollSnap: string; - /** Direction of the list's layout. */ - private _direction: Direction = 'ltr'; - constructor( element: ElementRef | HTMLElement, private _dragDropRegistry: DragDropRegistry, @@ -200,11 +221,11 @@ export class DropListRef { private _ngZone: NgZone, private _viewportRuler: ViewportRuler, ) { - const coercedElement = (this.element = coerceElement(element)); + this.element = coerceElement(element); this._document = _document; - this.withOrientation('vertical').withElementContainer(coercedElement); + this.withScrollableParents([this.element]); _dragDropRegistry.registerDropContainer(this); - this._parentPositions = new ParentPositionTracker(_document); + this._parentPositions = new ParentPositionTracker(_document, _viewportRuler); } /** Removes the drop list functionality from the DOM element. */ @@ -217,8 +238,6 @@ export class DropListRef { this.exited.complete(); this.dropped.complete(); this.sorted.complete(); - this.receivingStarted.complete(); - this.receivingStopped.complete(); this._activeSiblings.clear(); this._scrollNode = null!; this._parentPositions.clear(); @@ -237,7 +256,7 @@ export class DropListRef { } /** - * Attempts to move an item into the container. + * Emits an event to indicate that the user moved an item into the container. * @param item Item that was moved into the container. * @param pointerX Position of the item along the X axis. * @param pointerY Position of the item along the Y axis. @@ -249,14 +268,59 @@ export class DropListRef { // If sorting is disabled, we want the item to return to its starting // position if the user is returning it to its initial container. - if (index == null && this.sortingDisabled) { - index = this._draggables.indexOf(item); + let newIndex: number; + + if (index == null) { + newIndex = this.sortingDisabled ? this._draggables.indexOf(item) : -1; + + if (newIndex === -1) { + // We use the coordinates of where the item entered the drop + // zone to figure out at which index it should be inserted. + newIndex = this._getItemIndexFromPointerPosition(item, pointerX, pointerY); + } + } else { + newIndex = index; + } + + const activeDraggables = this._activeDraggables; + const currentIndex = activeDraggables.indexOf(item); + const placeholder = item.getPlaceholderElement(); + let newPositionReference: DragRef | undefined = activeDraggables[newIndex]; + + // If the item at the new position is the same as the item that is being dragged, + // it means that we're trying to restore the item to its initial position. In this + // case we should use the next item from the list as the reference. + if (newPositionReference === item) { + newPositionReference = activeDraggables[newIndex + 1]; + } + + // Since the item may be in the `activeDraggables` already (e.g. if the user dragged it + // into another container and back again), we have to ensure that it isn't duplicated. + if (currentIndex > -1) { + activeDraggables.splice(currentIndex, 1); + } + + // Don't use items that are being dragged as a reference, because + // their element has been moved down to the bottom of the body. + if (newPositionReference && !this._dragDropRegistry.isDragging(newPositionReference)) { + const element = newPositionReference.getRootElement(); + element.parentElement!.insertBefore(placeholder, element); + activeDraggables.splice(newIndex, 0, item); + } else if (this._shouldEnterAsFirstChild(pointerX, pointerY)) { + const reference = activeDraggables[0].getRootElement(); + reference.parentNode!.insertBefore(placeholder, reference); + activeDraggables.unshift(item); + } else { + coerceElement(this.element).appendChild(placeholder); + activeDraggables.push(item); } - this._sortStrategy.enter(item, pointerX, pointerY, index); + // The transform needs to be cleared so it doesn't throw off the measurements. + placeholder.style.transform = ''; - // Note that this usually happens inside `_draggingStarted` as well, but the dimensions - // can change when the sort strategy moves the item around inside `enter`. + // Note that the positions were already cached when we called `start` above, + // but we need to refresh them since the amount of items has changed and also parent rects. + this._cacheItemPositions(); this._cacheParentPositions(); // Notify siblings at the end so that the item has been inserted into the `activeDraggables`. @@ -327,7 +391,7 @@ export class DropListRef { if (draggedItems.every(item => items.indexOf(item) === -1)) { this._reset(); } else { - this._sortStrategy.withItems(this._draggables); + this._cacheItems(); } } @@ -337,9 +401,6 @@ export class DropListRef { /** Sets the layout direction of the drop list. */ withDirection(direction: Direction): this { this._direction = direction; - if (this._sortStrategy instanceof SingleAxisSortStrategy) { - this._sortStrategy.direction = direction; - } return this; } @@ -357,17 +418,8 @@ export class DropListRef { * Sets the orientation of the container. * @param orientation New orientation for the container. */ - withOrientation(orientation: DropListOrientation): this { - if (orientation === 'mixed') { - this._sortStrategy = new MixedSortStrategy(this._document, this._dragDropRegistry); - } else { - const strategy = new SingleAxisSortStrategy(this._dragDropRegistry); - strategy.direction = this._direction; - strategy.orientation = orientation; - this._sortStrategy = strategy; - } - this._sortStrategy.withElementContainer(this._container); - this._sortStrategy.withSortPredicate((index, item) => this.sortPredicate(index, item, this)); + withOrientation(orientation: 'vertical' | 'horizontal'): this { + this._orientation = orientation; return this; } @@ -376,7 +428,7 @@ export class DropListRef { * @param elements Elements that can be scrolled. */ withScrollableParents(elements: HTMLElement[]): this { - const element = this._container; + const element = coerceElement(this.element); // We always allow the current element to be scrollable // so we need to ensure that it's in the array. @@ -385,51 +437,6 @@ export class DropListRef { return this; } - /** - * Configures the drop list so that a different element is used as the container for the - * dragged items. This is useful for the cases when one might not have control over the - * full DOM that sets up the dragging. - * Note that the alternate container needs to be a descendant of the drop list. - * @param container New element container to be assigned. - */ - withElementContainer(container: HTMLElement): this { - if (container === this._container) { - return this; - } - - const element = coerceElement(this.element); - - if ( - (typeof ngDevMode === 'undefined' || ngDevMode) && - container !== element && - !element.contains(container) - ) { - throw new Error( - 'Invalid DOM structure for drop list. Alternate container element must be a descendant of the drop list.', - ); - } - - const oldContainerIndex = this._scrollableElements.indexOf(this._container); - const newContainerIndex = this._scrollableElements.indexOf(container); - - if (oldContainerIndex > -1) { - this._scrollableElements.splice(oldContainerIndex, 1); - } - - if (newContainerIndex > -1) { - this._scrollableElements.splice(newContainerIndex, 1); - } - - if (this._sortStrategy) { - this._sortStrategy.withElementContainer(container); - } - - this._cachedShadowRoot = null; - this._scrollableElements.unshift(container); - this._container = container; - return this; - } - /** Gets the scrollable parents that are registered with this drop container. */ getScrollableParents(): readonly HTMLElement[] { return this._scrollableElements; @@ -440,19 +447,19 @@ export class DropListRef { * @param item Item whose index should be determined. */ getItemIndex(item: DragRef): number { - return this._isDragging - ? this._sortStrategy.getItemIndex(item) - : this._draggables.indexOf(item); - } + if (!this._isDragging) { + return this._draggables.indexOf(item); + } - /** - * Gets the item at a specific index. - * @param index Index at which to retrieve the item. - */ - getItemAtIndex(index: number): DragRef | null { - return this._isDragging - ? this._sortStrategy.getItemAtIndex(index) - : this._draggables[index] || null; + // Items are sorted always by top/left in the cache, however they flow differently in RTL. + // The rest of the logic still stands no matter what orientation we're in, however + // we need to invert the array when determining the index. + const items = + this._orientation === 'horizontal' && this._direction === 'rtl' + ? this._itemPositions.slice().reverse() + : this._itemPositions; + + return findIndex(items, currentItem => currentItem.drag === item); } /** @@ -479,22 +486,80 @@ export class DropListRef { // Don't sort the item if sorting is disabled or it's out of range. if ( this.sortingDisabled || - !this._domRect || - !isPointerNearDomRect(this._domRect, DROP_PROXIMITY_THRESHOLD, pointerX, pointerY) + !this._clientRect || + !isPointerNearDomRect(this._clientRect, DROP_PROXIMITY_THRESHOLD, pointerX, pointerY) ) { return; } - const result = this._sortStrategy.sort(item, pointerX, pointerY, pointerDelta); + const siblings = this._itemPositions; + const newIndex = this._getItemIndexFromPointerPosition(item, pointerX, pointerY, pointerDelta); - if (result) { - this.sorted.next({ - previousIndex: result.previousIndex, - currentIndex: result.currentIndex, - container: this, - item, - }); + if (newIndex === -1 && siblings.length > 0) { + return; } + + const isHorizontal = this._orientation === 'horizontal'; + const currentIndex = findIndex(siblings, currentItem => currentItem.drag === item); + const siblingAtNewPosition = siblings[newIndex]; + const currentPosition = siblings[currentIndex].clientRect; + const newPosition = siblingAtNewPosition.clientRect; + const delta = currentIndex > newIndex ? 1 : -1; + + // How many pixels the item's placeholder should be offset. + const itemOffset = this._getItemOffsetPx(currentPosition, newPosition, delta); + + // How many pixels all the other items should be offset. + const siblingOffset = this._getSiblingOffsetPx(currentIndex, siblings, delta); + + // Save the previous order of the items before moving the item to its new index. + // We use this to check whether an item has been moved as a result of the sorting. + const oldOrder = siblings.slice(); + + // Shuffle the array in place. + moveItemInArray(siblings, currentIndex, newIndex); + + this.sorted.next({ + previousIndex: currentIndex, + currentIndex: newIndex, + container: this, + item, + }); + + siblings.forEach((sibling, index) => { + // Don't do anything if the position hasn't changed. + if (oldOrder[index] === sibling) { + return; + } + + const isDraggedItem = sibling.drag === item; + const offset = isDraggedItem ? itemOffset : siblingOffset; + const elementToOffset = isDraggedItem + ? item.getPlaceholderElement() + : sibling.drag.getRootElement(); + + // Update the offset to reflect the new position. + sibling.offset += offset; + + // Since we're moving the items with a `transform`, we need to adjust their cached + // client rects to reflect their new position, as well as swap their positions in the cache. + // Note that we shouldn't use `getBoundingClientRect` here to update the cache, because the + // elements may be mid-animation which will give us a wrong result. + if (isHorizontal) { + // Round the transforms since some browsers will + // blur the elements, for sub-pixel transforms. + elementToOffset.style.transform = `translate3d(${Math.round(sibling.offset)}px, 0, 0)`; + adjustDomRect(sibling.clientRect, 0, offset); + } else { + elementToOffset.style.transform = `translate3d(0, ${Math.round(sibling.offset)}px, 0)`; + adjustDomRect(sibling.clientRect, offset, 0); + } + }); + + // Note that it's important that we do this after the client rects have been adjusted. + this._previousSwap.overlaps = isInsideClientRect(newPosition, pointerX, pointerY); + this._previousSwap.drag = siblingAtNewPosition.drag; + this._previousSwap.delta = isHorizontal ? pointerDelta.x : pointerDelta.y; } /** @@ -524,7 +589,6 @@ export class DropListRef { [verticalScrollDirection, horizontalScrollDirection] = getElementScrollDirections( element as HTMLElement, position.clientRect, - this._direction, pointerX, pointerY, ); @@ -538,16 +602,9 @@ export class DropListRef { // Otherwise check if we can start scrolling the viewport. if (!verticalScrollDirection && !horizontalScrollDirection) { const {width, height} = this._viewportRuler.getViewportSize(); - const domRect = { - width, - height, - top: 0, - right: width, - bottom: height, - left: 0, - } as DOMRect; - verticalScrollDirection = getVerticalScrollDirection(domRect, pointerY); - horizontalScrollDirection = getHorizontalScrollDirection(domRect, pointerX); + const clientRect = {width, height, top: 0, right: width, bottom: height, left: 0}; + verticalScrollDirection = getVerticalScrollDirection(clientRect, pointerY); + horizontalScrollDirection = getHorizontalScrollDirection(clientRect, pointerX); scrollNode = window; } @@ -576,58 +633,202 @@ export class DropListRef { /** Starts the dragging sequence within the list. */ private _draggingStarted() { - const styles = this._container.style as DragCSSStyleDeclaration; + const styles = coerceElement(this.element).style as DragCSSStyleDeclaration; this.beforeStarted.next(); this._isDragging = true; - if ( - (typeof ngDevMode === 'undefined' || ngDevMode) && - // Prevent the check from running on apps not using an alternate container. Ideally we - // would always run it, but introducing it at this stage would be a breaking change. - this._container !== coerceElement(this.element) - ) { - for (const drag of this._draggables) { - if (!drag.isDragging() && drag.getVisibleElement().parentNode !== this._container) { - throw new Error( - 'Invalid DOM structure for drop list. All items must be placed directly inside of the element container.', - ); - } - } - } - // We need to disable scroll snapping while the user is dragging, because it breaks automatic // scrolling. The browser seems to round the value based on the snapping points which means // that we can't increment/decrement the scroll position. this._initialScrollSnap = styles.msScrollSnapType || styles.scrollSnapType || ''; styles.scrollSnapType = styles.msScrollSnapType = 'none'; - this._sortStrategy.start(this._draggables); - this._cacheParentPositions(); + this._cacheItems(); this._viewportScrollSubscription.unsubscribe(); this._listenToScrollEvents(); } /** Caches the positions of the configured scrollable parents. */ private _cacheParentPositions() { + const element = coerceElement(this.element); this._parentPositions.cache(this._scrollableElements); // The list element is always in the `scrollableElements` - // so we can take advantage of the cached `DOMRect`. - this._domRect = this._parentPositions.positions.get(this._container)!.clientRect!; + // so we can take advantage of the cached `ClientRect`. + this._clientRect = this._parentPositions.positions.get(element)!.clientRect!; + } + + /** Refreshes the position cache of the items and sibling containers. */ + private _cacheItemPositions() { + const isHorizontal = this._orientation === 'horizontal'; + + this._itemPositions = this._activeDraggables + .map(drag => { + const elementToMeasure = drag.getVisibleElement(); + return {drag, offset: 0, clientRect: getMutableClientRect(elementToMeasure)}; + }) + .sort((a, b) => { + return isHorizontal + ? a.clientRect.left - b.clientRect.left + : a.clientRect.top - b.clientRect.top; + }); } /** Resets the container to its initial state. */ private _reset() { this._isDragging = false; - const styles = this._container.style as DragCSSStyleDeclaration; + + const styles = coerceElement(this.element).style as DragCSSStyleDeclaration; styles.scrollSnapType = styles.msScrollSnapType = this._initialScrollSnap; + // TODO(crisbeto): may have to wait for the animations to finish. + this._activeDraggables.forEach(item => { + const rootElement = item.getRootElement(); + + if (rootElement) { + rootElement.style.transform = ''; + } + }); this._siblings.forEach(sibling => sibling._stopReceiving(this)); - this._sortStrategy.reset(); + this._activeDraggables = []; + this._itemPositions = []; + this._previousSwap.drag = null; + this._previousSwap.delta = 0; + this._previousSwap.overlaps = false; this._stopScrolling(); this._viewportScrollSubscription.unsubscribe(); this._parentPositions.clear(); } + /** + * Gets the offset in pixels by which the items that aren't being dragged should be moved. + * @param currentIndex Index of the item currently being dragged. + * @param siblings All of the items in the list. + * @param delta Direction in which the user is moving. + */ + private _getSiblingOffsetPx(currentIndex: number, siblings: CachedItemPosition[], delta: 1 | -1) { + const isHorizontal = this._orientation === 'horizontal'; + const currentPosition = siblings[currentIndex].clientRect; + const immediateSibling = siblings[currentIndex + delta * -1]; + let siblingOffset = currentPosition[isHorizontal ? 'width' : 'height'] * delta; + + if (immediateSibling) { + const start = isHorizontal ? 'left' : 'top'; + const end = isHorizontal ? 'right' : 'bottom'; + + // Get the spacing between the start of the current item and the end of the one immediately + // after it in the direction in which the user is dragging, or vice versa. We add it to the + // offset in order to push the element to where it will be when it's inline and is influenced + // by the `margin` of its siblings. + if (delta === -1) { + siblingOffset -= immediateSibling.clientRect[start] - currentPosition[end]; + } else { + siblingOffset += currentPosition[start] - immediateSibling.clientRect[end]; + } + } + + return siblingOffset; + } + + /** + * Gets the offset in pixels by which the item that is being dragged should be moved. + * @param currentPosition Current position of the item. + * @param newPosition Position of the item where the current item should be moved. + * @param delta Direction in which the user is moving. + */ + private _getItemOffsetPx(currentPosition: DOMRect, newPosition: DOMRect, delta: 1 | -1) { + const isHorizontal = this._orientation === 'horizontal'; + let itemOffset = isHorizontal + ? newPosition.left - currentPosition.left + : newPosition.top - currentPosition.top; + + // Account for differences in the item width/height. + if (delta === -1) { + itemOffset += isHorizontal + ? newPosition.width - currentPosition.width + : newPosition.height - currentPosition.height; + } + + return itemOffset; + } + + /** + * Checks if pointer is entering in the first position + * @param pointerX Position of the user's pointer along the X axis. + * @param pointerY Position of the user's pointer along the Y axis. + */ + private _shouldEnterAsFirstChild(pointerX: number, pointerY: number) { + if (!this._activeDraggables.length) { + return false; + } + + const itemPositions = this._itemPositions; + const isHorizontal = this._orientation === 'horizontal'; + + // `itemPositions` are sorted by position while `activeDraggables` are sorted by child index + // check if container is using some sort of "reverse" ordering (eg: flex-direction: row-reverse) + const reversed = itemPositions[0].drag !== this._activeDraggables[0]; + if (reversed) { + const lastItemRect = itemPositions[itemPositions.length - 1].clientRect; + return isHorizontal ? pointerX >= lastItemRect.right : pointerY >= lastItemRect.bottom; + } else { + const firstItemRect = itemPositions[0].clientRect; + return isHorizontal ? pointerX <= firstItemRect.left : pointerY <= firstItemRect.top; + } + } + + /** + * Gets the index of an item in the drop container, based on the position of the user's pointer. + * @param item Item that is being sorted. + * @param pointerX Position of the user's pointer along the X axis. + * @param pointerY Position of the user's pointer along the Y axis. + * @param delta Direction in which the user is moving their pointer. + */ + private _getItemIndexFromPointerPosition( + item: DragRef, + pointerX: number, + pointerY: number, + delta?: {x: number; y: number}, + ): number { + const isHorizontal = this._orientation === 'horizontal'; + const index = findIndex(this._itemPositions, ({drag, clientRect}, _, array) => { + if (drag === item) { + // If there's only one item left in the container, it must be + // the dragged item itself so we use it as a reference. + return array.length < 2; + } + + if (delta) { + const direction = isHorizontal ? delta.x : delta.y; + + // If the user is still hovering over the same item as last time, their cursor hasn't left + // the item after we made the swap, and they didn't change the direction in which they're + // dragging, we don't consider it a direction swap. + if ( + drag === this._previousSwap.drag && + this._previousSwap.overlaps && + direction === this._previousSwap.delta + ) { + return false; + } + } + + return isHorizontal + ? // Round these down since most browsers report client rects with + // sub-pixel precision, whereas the pointer coordinates are rounded to pixels. + pointerX >= Math.floor(clientRect.left) && pointerX < Math.floor(clientRect.right) + : pointerY >= Math.floor(clientRect.top) && pointerY < Math.floor(clientRect.bottom); + }); + + return index === -1 || !this.sortPredicate(index, item, this) ? -1 : index; + } + + /** Caches the current items in the list and their positions. */ + private _cacheItems(): void { + this._activeDraggables = this._draggables.slice(); + this._cacheItemPositions(); + this._cacheParentPositions(); + } + /** Starts the interval that'll auto-scroll the element. */ private _startScrollInterval = () => { this._stopScrolling(); @@ -636,18 +837,17 @@ export class DropListRef { .pipe(takeUntil(this._stopScrollTimers)) .subscribe(() => { const node = this._scrollNode; - const scrollStep = this.autoScrollStep; if (this._verticalScrollDirection === AutoScrollVerticalDirection.UP) { - node.scrollBy(0, -scrollStep); + incrementVerticalScroll(node, -this.autoScrollStep); } else if (this._verticalScrollDirection === AutoScrollVerticalDirection.DOWN) { - node.scrollBy(0, scrollStep); + incrementVerticalScroll(node, this.autoScrollStep); } if (this._horizontalScrollDirection === AutoScrollHorizontalDirection.LEFT) { - node.scrollBy(-scrollStep, 0); + incrementHorizontalScroll(node, -this.autoScrollStep); } else if (this._horizontalScrollDirection === AutoScrollHorizontalDirection.RIGHT) { - node.scrollBy(scrollStep, 0); + incrementHorizontalScroll(node, this.autoScrollStep); } }); }; @@ -658,7 +858,7 @@ export class DropListRef { * @param y Pointer position along the Y axis. */ _isOverContainer(x: number, y: number): boolean { - return this._domRect != null && isInsideClientRect(this._domRect, x, y); + return this._clientRect != null && isInsideClientRect(this._clientRect, x, y); } /** @@ -669,7 +869,103 @@ 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' + let targets = [this, ...this._siblings]; + + // Only consider targets where the drag postition is within the client rect + // (this avoids calling enterPredicate on each possible target) + let matchingTargets = targets.filter(ref => { + return ref._clientRect && isInsideClientRect(ref._clientRect, x, y); + }); + + // Stop if no targets match the coordinates + if (matchingTargets.length == 0) { + return undefined; + } + + // Order candidates by DOM hierarchy and z-index + let orderedMatchingTargets = this._orderByHierarchy(matchingTargets); + + // The drop target is the last matching target in the list + let matchingTarget = orderedMatchingTargets[orderedMatchingTargets.length - 1]; + + // Only return matching target if it is a sibling + if (matchingTarget === this) { + return undefined; + } + + // Can the matching target receive the item? + if (!matchingTarget._canReceive(item, x, y)) { + return undefined; + } + + // Return matching target + return matchingTarget; + } + + /** + * Sort a list of DropListRef in such that forevery nested pair drop containers, the + * outer drop container appear before the inner drop container. + * @param refs List of DropListRefs. + */ + private _orderByHierarchy(refs: DropListRef[]): DropListRef[] { + // Build a map from HTMLElement to DropListRef + let refsByElement: Map = new Map(); + refs.forEach(ref => { + refsByElement.set(coerceElement(ref.element), ref); + }); + + // Function to identify the closest ancestor among th DropListRefs + let findAncestor = (ref: DropListRef) => { + let ancestor = coerceElement(ref.element).parentElement; + + while (ancestor) { + if (refsByElement.has(ancestor)) { + return refsByElement.get(ancestor); + } + ancestor = ancestor.parentElement; + } + + return undefined; + }; + + // Node type for tree structure + type NodeType = {ref: DropListRef; parent?: NodeType; children: NodeType[]}; + + // Add all refs as nodes to the tree + let tree: Map = new Map(); + refs.forEach(ref => { + tree.set(ref, {ref: ref, children: []}); + }); + + // Build parent-child links in tree + refs.forEach(ref => { + let parent = findAncestor(ref); + + if (parent) { + let node = tree.get(ref); + let parentNode = tree.get(parent); + + node!.parent = parentNode; + parentNode!.children.push(node!); + } + }); + + // Find tree roots + let roots = Array.from(tree.values()).filter(node => !node.parent); + + // Function to recursively build ordered list from roots and down + let buildOrderedList = (nodes: NodeType[], list: DropListRef[]) => { + list.push(...nodes.map(node => node.ref)); + nodes.forEach(node => { + buildOrderedList(node.children, list); + }); + }; + + // Build and return the ordered list + let ordered: DropListRef[] = []; + buildOrderedList(roots, ordered); + return ordered; } /** @@ -680,8 +976,8 @@ export class DropListRef { */ _canReceive(item: DragRef, x: number, y: number): boolean { if ( - !this._domRect || - !isInsideClientRect(this._domRect, x, y) || + !this._clientRect || + !isInsideClientRect(this._clientRect, x, y) || !this.enterPredicate(item, this) ) { return false; @@ -695,13 +991,15 @@ export class DropListRef { return false; } - // The `DOMRect`, that we're using to find the container over which the user is + const nativeElement = coerceElement(this.element); + + // The `ClientRect`, that we're using to find the container over which the user is // hovering, doesn't give us any information on whether the element has been scrolled // out of the view or whether it's overlapping with other containers. This means that // we could end up transferring the item into a container that's invisible or is positioned // below another one. We use the result from `elementFromPoint` to get the top-most element // at the pointer position and to find whether it's one of the intersecting drop containers. - return elementFromPoint === this._container || this._container.contains(elementFromPoint); + return elementFromPoint === nativeElement || nativeElement.contains(elementFromPoint); } /** @@ -724,11 +1022,6 @@ export class DropListRef { activeSiblings.add(sibling); this._cacheParentPositions(); this._listenToScrollEvents(); - this.receivingStarted.next({ - initiator: sibling, - receiver: this, - items, - }); } } @@ -739,7 +1032,6 @@ export class DropListRef { _stopReceiving(sibling: DropListRef) { this._activeSiblings.delete(sibling); this._viewportScrollSubscription.unsubscribe(); - this.receivingStopped.next({initiator: sibling, receiver: this}); } /** @@ -747,19 +1039,33 @@ export class DropListRef { * Used for updating the internal state of the list. */ private _listenToScrollEvents() { - this._viewportScrollSubscription = this._dragDropRegistry - .scrolled(this._getShadowRoot()) - .subscribe(event => { - if (this.isDragging()) { - const scrollDifference = this._parentPositions.handleScroll(event); - - if (scrollDifference) { - this._sortStrategy.updateOnScroll(scrollDifference.top, scrollDifference.left); - } - } else if (this.isReceiving()) { - this._cacheParentPositions(); + this._viewportScrollSubscription = this._dragDropRegistry.scroll.subscribe((event: any) => { + if (this.isDragging()) { + const scrollDifference = this._parentPositions.handleScroll(event); + + if (scrollDifference) { + // Since we know the amount that the user has scrolled we can shift all of the + // client rectangles ourselves. This is cheaper than re-measuring everything and + // we can avoid inconsistent behavior where we might be measuring the element before + // its position has changed. + this._itemPositions.forEach(({clientRect}) => { + adjustDomRect(clientRect, scrollDifference.top, scrollDifference.left); + }); + + // We need two loops for this, because we want all of the cached + // positions to be up-to-date before we re-sort the item. + this._itemPositions.forEach(({drag}) => { + if (this._dragDropRegistry.isDragging(drag)) { + // We need to re-sort the item manually, because the pointer move + // events won't be dispatched while the user is scrolling. + drag._sortFromLastPointerPosition(); + } + }); } - }); + } else if (this.isReceiving()) { + this._cacheParentPositions(); + } + }); } /** @@ -770,7 +1076,7 @@ export class DropListRef { */ private _getShadowRoot(): DocumentOrShadowRoot { if (!this._cachedShadowRoot) { - const shadowRoot = _getShadowRoot(this._container); + const shadowRoot = _getShadowRoot(coerceElement(this.element)) as ShadowRoot | null; this._cachedShadowRoot = shadowRoot || this._document; } @@ -779,19 +1085,64 @@ export class DropListRef { /** Notifies any siblings that may potentially receive the item. */ private _notifyReceivingSiblings() { - const draggedItems = this._sortStrategy - .getActiveItemsSnapshot() - .filter(item => item.isDragging()); + const draggedItems = this._activeDraggables.filter(item => item.isDragging()); this._siblings.forEach(sibling => sibling._startReceiving(this, draggedItems)); } } +/** + * Finds the index of an item that matches a predicate function. Used as an equivalent + * of `Array.prototype.findIndex` which isn't part of the standard Google typings. + * @param array Array in which to look for matches. + * @param predicate Function used to determine whether an item is a match. + */ +function findIndex( + array: T[], + predicate: (value: T, index: number, obj: T[]) => boolean, +): number { + for (let i = 0; i < array.length; i++) { + if (predicate(array[i], i, array)) { + return i; + } + } + + return -1; +} + +/** + * Increments the vertical scroll position of a node. + * @param node Node whose scroll position should change. + * @param amount Amount of pixels that the `node` should be scrolled. + */ +function incrementVerticalScroll(node: HTMLElement | Window, amount: number) { + if (node === window) { + (node as Window).scrollBy(0, amount); + } else { + // Ideally we could use `Element.scrollBy` here as well, but IE and Edge don't support it. + (node as HTMLElement).scrollTop += amount; + } +} + +/** + * Increments the horizontal scroll position of a node. + * @param node Node whose scroll position should change. + * @param amount Amount of pixels that the `node` should be scrolled. + */ +function incrementHorizontalScroll(node: HTMLElement | Window, amount: number) { + if (node === window) { + (node as Window).scrollBy(amount, 0); + } else { + // Ideally we could use `Element.scrollBy` here as well, but IE and Edge don't support it. + (node as HTMLElement).scrollLeft += amount; + } +} + /** * Gets whether the vertical auto-scroll direction of a node. * @param clientRect Dimensions of the node. * @param pointerY Position of the user's pointer along the y axis. */ -function getVerticalScrollDirection(clientRect: DOMRect, pointerY: number) { +function getVerticalScrollDirection(clientRect: ClientRect, pointerY: number) { const {top, bottom, height} = clientRect; const yThreshold = height * SCROLL_PROXIMITY_THRESHOLD; @@ -809,7 +1160,7 @@ function getVerticalScrollDirection(clientRect: DOMRect, pointerY: number) { * @param clientRect Dimensions of the node. * @param pointerX Position of the user's pointer along the x axis. */ -function getHorizontalScrollDirection(clientRect: DOMRect, pointerX: number) { +function getHorizontalScrollDirection(clientRect: ClientRect, pointerX: number) { const {left, right, width} = clientRect; const xThreshold = width * SCROLL_PROXIMITY_THRESHOLD; @@ -827,14 +1178,12 @@ function getHorizontalScrollDirection(clientRect: DOMRect, pointerX: number) { * assuming that the user's pointer is already within it scrollable region. * @param element Element for which we should calculate the scroll direction. * @param clientRect Bounding client rectangle of the element. - * @param direction Layout direction of the drop list. * @param pointerX Position of the user's pointer along the x axis. * @param pointerY Position of the user's pointer along the y axis. */ function getElementScrollDirections( element: HTMLElement, - clientRect: DOMRect, - direction: Direction, + clientRect: ClientRect, pointerX: number, pointerY: number, ): [AutoScrollVerticalDirection, AutoScrollHorizontalDirection] { @@ -862,23 +1211,12 @@ function getElementScrollDirections( if (computedHorizontal) { const scrollLeft = element.scrollLeft; - if (direction === 'rtl') { - if (computedHorizontal === AutoScrollHorizontalDirection.RIGHT) { - // In RTL `scrollLeft` will be negative when scrolled. - if (scrollLeft < 0) { - horizontalScrollDirection = AutoScrollHorizontalDirection.RIGHT; - } - } else if (element.scrollWidth + scrollLeft > element.clientWidth) { + if (computedHorizontal === AutoScrollHorizontalDirection.LEFT) { + if (scrollLeft > 0) { horizontalScrollDirection = AutoScrollHorizontalDirection.LEFT; } - } else { - if (computedHorizontal === AutoScrollHorizontalDirection.LEFT) { - if (scrollLeft > 0) { - horizontalScrollDirection = AutoScrollHorizontalDirection.LEFT; - } - } else if (element.scrollWidth - scrollLeft > element.clientWidth) { - horizontalScrollDirection = AutoScrollHorizontalDirection.RIGHT; - } + } else if (element.scrollWidth - scrollLeft > element.clientWidth) { + horizontalScrollDirection = AutoScrollHorizontalDirection.RIGHT; } }