diff --git a/src/cdk-experimental/drag-drop/drag.spec.ts b/src/cdk-experimental/drag-drop/drag.spec.ts index 9423ac7f12b4..bee22282faaf 100644 --- a/src/cdk-experimental/drag-drop/drag.spec.ts +++ b/src/cdk-experimental/drag-drop/drag.spec.ts @@ -562,7 +562,7 @@ describe('CdkDrag', () => { // Add a few pixels to the left offset so we get some overlap. dispatchMouseEvent(document, 'mousemove', elementRect.left + 5, elementRect.top); fixture.detectChanges(); - expect(getElementIndex(placeholder)).toBe(i); + expect(getElementIndexByPosition(placeholder, 'left')).toBe(i); } dispatchMouseEvent(document, 'mouseup'); @@ -590,7 +590,7 @@ describe('CdkDrag', () => { // Remove a few pixels from the right offset so we get some overlap. dispatchMouseEvent(document, 'mousemove', elementRect.right - 5, elementRect.top); fixture.detectChanges(); - expect(getElementIndex(placeholder)).toBe(Math.min(i + 1, items.length - 1)); + expect(getElementIndexByPosition(placeholder, 'left')).toBe(i); } dispatchMouseEvent(document, 'mouseup'); @@ -660,6 +660,31 @@ describe('CdkDrag', () => { expect(placeholder.textContent!.trim()).toContain('Custom placeholder'); })); + it('should clear the `transform` value from siblings when item is dropped`', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + fixture.detectChanges(); + + const dragItems = fixture.componentInstance.dragItems; + const firstItem = dragItems.first; + const thirdItem = dragItems.toArray()[2].element.nativeElement; + const thirdItemRect = thirdItem.getBoundingClientRect(); + + dispatchMouseEvent(firstItem.element.nativeElement, 'mousedown'); + fixture.detectChanges(); + + dispatchMouseEvent(document, 'mousemove', thirdItemRect.left + 1, thirdItemRect.top + 1); + fixture.detectChanges(); + + expect(thirdItem.style.transform).toBeTruthy(); + + dispatchMouseEvent(document, 'mouseup'); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(thirdItem.style.transform).toBeFalsy(); + })); + }); describe('in a connected drop container', () => { @@ -1151,9 +1176,15 @@ function dragElementViaTouch(fixture: ComponentFixture, fixture.detectChanges(); } -/** Gets the index of a DOM element inside its parent. */ -function getElementIndex(element: HTMLElement) { - return element.parentElement ? Array.from(element.parentElement.children).indexOf(element) : -1; +/** Gets the index of an element among its siblings, based on their position on the page. */ +function getElementIndexByPosition(element: HTMLElement, direction: 'top' | 'left') { + if (!element.parentElement) { + return -1; + } + + return Array.from(element.parentElement.children) + .sort((a, b) => a.getBoundingClientRect()[direction] - b.getBoundingClientRect()[direction]) + .indexOf(element); } /** @@ -1193,7 +1224,7 @@ function assertDownwardSorting(fixture: ComponentFixture, items: Element[]) // Add a few pixels to the top offset so we get some overlap. dispatchMouseEvent(document, 'mousemove', elementRect.left, elementRect.top + 5); fixture.detectChanges(); - expect(getElementIndex(placeholder)).toBe(i); + expect(getElementIndexByPosition(placeholder, 'top')).toBe(i); } dispatchMouseEvent(document, 'mouseup'); @@ -1222,7 +1253,7 @@ function assertUpwardSorting(fixture: ComponentFixture, items: Element[]) { // Remove a few pixels from the bottom offset so we get some overlap. dispatchMouseEvent(document, 'mousemove', elementRect.left, elementRect.bottom - 5); fixture.detectChanges(); - expect(getElementIndex(placeholder)).toBe(Math.min(i + 1, items.length - 1)); + expect(getElementIndexByPosition(placeholder, 'top')).toBe(i); } dispatchMouseEvent(document, 'mouseup'); diff --git a/src/cdk-experimental/drag-drop/drag.ts b/src/cdk-experimental/drag-drop/drag.ts index b6d7f3ee9328..e0f099ba0a59 100644 --- a/src/cdk-experimental/drag-drop/drag.ts +++ b/src/cdk-experimental/drag-drop/drag.ts @@ -35,8 +35,8 @@ import {CdkDragDropRegistry} from './drag-drop-registry'; import {Subject, merge} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; -// TODO: add auto-scrolling functionality. -// TODO: add an API for moving a draggable up/down the +// TODO(crisbeto): add auto-scrolling functionality. +// TODO(crisbeto): add an API for moving a draggable up/down the // list programmatically. Useful for keyboard controls. /** Element that can be moved inside a CdkDrop container. */ @@ -236,7 +236,7 @@ export class CdkDrag implements OnDestroy { /** Handler that is invoked when the user moves their pointer after they've initiated a drag. */ private _pointerMove = (event: MouseEvent | TouchEvent) => { - // TODO: this should start dragging after a certain threshold, + // TODO(crisbeto): this should start dragging after a certain threshold, // otherwise we risk interfering with clicks on the element. if (!this._dragDropRegistry.isDragging(this)) { return; @@ -279,8 +279,6 @@ export class CdkDrag implements OnDestroy { /** Cleans up the DOM artifacts that were added to facilitate the element being dragged. */ private _cleanupDragArtifacts() { - const currentIndex = this._getElementIndexInDom(this._placeholder); - // Restore the element's visibility and insert it at its old position in the DOM. // It's important that we maintain the position, because moving the element around in the DOM // can throw off `NgFor` which does smart diffing and re-creates elements only when necessary, @@ -298,6 +296,8 @@ export class CdkDrag implements OnDestroy { // Re-enter the NgZone since we bound `document` events on the outside. this._ngZone.run(() => { + const currentIndex = this.dropContainer.getItemIndex(this); + this.ended.emit({source: this}); this.dropped.emit({ item: this, @@ -328,7 +328,7 @@ export class CdkDrag implements OnDestroy { // Notify the new container that the item has entered. this.entered.emit({ item: this, container: newContainer }); this.dropContainer = newContainer; - this.dropContainer.enter(this); + this.dropContainer.enter(this, x, y); }); } @@ -386,37 +386,6 @@ export class CdkDrag implements OnDestroy { return placeholder; } - /** Gets the index of an element, based on its index in the DOM. */ - private _getElementIndexInDom(element: HTMLElement): number { - // Note: we may be able to figure this in memory while sorting, but doing so won't be very - // reliable when transferring between containers, because the new container doesn't have - // the proper indices yet. Also this will work better for the case where the consumer - // isn't using an `ngFor` to render the list. - if (!element.parentElement) { - return -1; - } - - // Avoid accessing `children` and `children.length` too much since they're a "live collection". - let index = 0; - const siblings = element.parentElement.children; - const siblingsLength = siblings.length; - const draggableElements = this.dropContainer._draggables - .filter(item => item !== this) - .map(item => item.element.nativeElement); - - // Loop through the sibling elements to find out the index of the - // current one, while skipping any elements that aren't draggable. - for (let i = 0; i < siblingsLength; i++) { - if (siblings[i] === element) { - return index; - } else if (draggableElements.indexOf(siblings[i] as HTMLElement) > -1) { - index++; - } - } - - return -1; - } - /** * Figures out the coordinates at which an element was picked up. * @param referenceElement Element that initiated the dragging. diff --git a/src/cdk-experimental/drag-drop/drop-container.ts b/src/cdk-experimental/drag-drop/drop-container.ts index 6c1dfb9046e3..016792f2b16a 100644 --- a/src/cdk-experimental/drag-drop/drop-container.ts +++ b/src/cdk-experimental/drag-drop/drop-container.ts @@ -30,8 +30,10 @@ export interface CdkDropContainer { /** * Emits an event to indicate that the user moved an item into the container. * @param item Item that was moved into the container. + * @param xOffset Position of the item along the X axis. + * @param yOffset Position of the item along the Y axis. */ - enter(item: CdkDrag): void; + enter(item: CdkDrag, xOffset: number, yOffset: number): void; /** * Removes an item from the container after it was dragged into another container by the user. diff --git a/src/cdk-experimental/drag-drop/drop.ts b/src/cdk-experimental/drag-drop/drop.ts index 5e4468b59192..95e59c71f5ac 100644 --- a/src/cdk-experimental/drag-drop/drop.ts +++ b/src/cdk-experimental/drag-drop/drop.ts @@ -100,14 +100,22 @@ export class CdkDrop implements OnInit, OnDestroy { /** Cache of the dimensions of all the items and the sibling containers. */ private _positionCache = { - items: [] as {drag: CdkDrag, clientRect: ClientRect}[], + items: [] as {drag: CdkDrag, clientRect: ClientRect, offset: number}[], siblings: [] as {drop: CdkDrop, clientRect: ClientRect}[] }; + /** + * 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: CdkDrag[]; + /** Starts dragging an item. */ start(): void { this._dragging = true; - this._refreshPositions(); + this._activeDraggables = this._draggables.toArray(); + this._cachePositions(); } /** @@ -117,25 +125,57 @@ export class CdkDrop implements OnInit, OnDestroy { * @param previousContainer Container from which the item got dragged in. */ drop(item: CdkDrag, currentIndex: number, previousContainer: CdkDrop): void { + this._reset(); this.dropped.emit({ item, currentIndex, previousIndex: previousContainer.getItemIndex(item), container: this, - // TODO: reconsider whether to make this null if the containers are the same. + // TODO(crisbeto): reconsider whether to make this null if the containers are the same. previousContainer }); - - this._reset(); } /** * Emits an event to indicate that the user moved an item into the container. * @param item Item that was moved into the container. + * @param xOffset Position of the item along the X axis. + * @param yOffset Position of the item along the Y axis. */ - enter(item: CdkDrag): void { + enter(item: CdkDrag, xOffset: number, yOffset: number): void { this.entered.emit({item, container: this}); this.start(); + + // We use the coordinates of where the item entered the drop + // zone to figure out at which index it should be inserted. + const newIndex = this._getItemIndexFromPointerPosition(item, xOffset, yOffset); + const currentIndex = this._activeDraggables.indexOf(item); + const newPositionReference = this._activeDraggables[newIndex]; + const placeholder = item.getPlaceholderElement(); + + // 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) { + this._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.element.nativeElement; + element.parentElement!.insertBefore(placeholder, element); + this._activeDraggables.splice(newIndex, 0, item); + } else { + this.element.nativeElement.appendChild(placeholder); + this._activeDraggables.push(item); + } + + // The transform needs to be cleared so it doesn't throw off the measurements. + placeholder.style.transform = ''; + + // 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. + this._cachePositions(); } /** @@ -152,7 +192,9 @@ export class CdkDrop implements OnInit, OnDestroy { * @param item Item whose index should be determined. */ getItemIndex(item: CdkDrag): number { - return this._draggables.toArray().indexOf(item); + return this._dragging ? + this._positionCache.items.findIndex(currentItem => currentItem.drag === item) : + this._draggables.toArray().indexOf(item); } /** @@ -163,37 +205,45 @@ export class CdkDrop implements OnInit, OnDestroy { */ _sortItem(item: CdkDrag, xOffset: number, yOffset: number): void { const siblings = this._positionCache.items; - const newPosition = siblings.find(({drag, clientRect}) => { - if (drag === item) { - // If there's only one left item in the container, it must be - // the dragged item itself so we use it as a reference. - return siblings.length < 2; - } - - return this.orientation === 'horizontal' ? - xOffset > clientRect.left && xOffset < clientRect.right : - yOffset > clientRect.top && yOffset < clientRect.bottom; - }); - - if (!newPosition && siblings.length > 0) { - return; - } - - // Don't take the element of a dragged item as a reference, - // because it has been moved down to the end of the body. - const element = (newPosition && !this._dragDropRegistry.isDragging(newPosition.drag)) ? - newPosition.drag.element.nativeElement : null; - const next = element ? element!.nextSibling : null; - const parent = element ? element.parentElement! : this.element.nativeElement; + const isHorizontal = this.orientation === 'horizontal'; + const newIndex = this._getItemIndexFromPointerPosition(item, xOffset, yOffset); const placeholder = item.getPlaceholderElement(); - if (next) { - parent.insertBefore(placeholder, next === placeholder ? element : next); - } else { - parent.appendChild(placeholder); + if (newIndex === -1 && siblings.length > 0) { + return; } - this._refreshPositions(); + const currentIndex = siblings.findIndex(currentItem => currentItem.drag === item); + const currentPosition = siblings[currentIndex]; + const newPosition = siblings[newIndex]; + + // Figure out the offset necessary for the items to be swapped. + const offset = isHorizontal ? + currentPosition.clientRect.left - newPosition.clientRect.left : + currentPosition.clientRect.top - newPosition.clientRect.top; + const topAdjustment = isHorizontal ? 0 : offset; + const leftAdjustment = isHorizontal ? offset : 0; + + // Since we've moved 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. + this._adjustClientRect(currentPosition.clientRect, -topAdjustment, -leftAdjustment); + currentPosition.offset -= offset; + siblings[currentIndex] = newPosition; + + this._adjustClientRect(newPosition.clientRect, topAdjustment, leftAdjustment); + newPosition.offset += offset; + siblings[newIndex] = currentPosition; + + // Swap the placeholder's position with the one of the target draggable. + placeholder.style.transform = isHorizontal ? + `translate3d(${currentPosition.offset}px, 0, 0)` : + `translate3d(0, ${currentPosition.offset}px, 0)`; + + newPosition.drag.element.nativeElement.style.transform = isHorizontal ? + `translate3d(${newPosition.offset}px, 0, 0)` : + `translate3d(0, ${newPosition.offset}px, 0)`; } /** @@ -212,12 +262,37 @@ export class CdkDrop implements OnInit, OnDestroy { } /** Refreshes the position cache of the items and sibling containers. */ - private _refreshPositions() { - this._positionCache.items = this._draggables - .map(drag => ({drag, clientRect: drag.element.nativeElement.getBoundingClientRect()})) + private _cachePositions() { + this._positionCache.items = this._activeDraggables + .map(drag => { + const elementToMeasure = this._dragDropRegistry.isDragging(drag) ? + // If the element is being dragged, we have to measure the + // placeholder, because the element is hidden. + drag.getPlaceholderElement() : + drag.element.nativeElement; + const clientRect = elementToMeasure.getBoundingClientRect(); + + return { + drag, + offset: 0, + // We need to clone the `clientRect` here, because all the values on it are readonly + // and we need to be able to update them. Also we can't use a spread here, because + // the values on a `ClientRect` aren't own properties. See: + // https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect#Notes + clientRect: { + top: clientRect.top, + right: clientRect.right, + bottom: clientRect.bottom, + left: clientRect.left, + width: clientRect.width, + height: clientRect.height + } + }; + }) .sort((a, b) => a.clientRect.top - b.clientRect.top); - // TODO: add filter here that ensures that the current container isn't being passed to itself. + // TODO(crisbeto): add filter here that ensures that the + // current container isn't being passed to itself. this._positionCache.siblings = this.connectedTo .map(drop => typeof drop === 'string' ? this._dragDropRegistry.getDropContainer(drop)! : drop) .filter(Boolean) @@ -227,7 +302,47 @@ export class CdkDrop implements OnInit, OnDestroy { /** Resets the container to its initial state. */ private _reset() { this._dragging = false; + + // TODO(crisbeto): may have to wait for the animations to finish. + this._activeDraggables.forEach(item => item.element.nativeElement.style.transform = ''); + this._activeDraggables = []; this._positionCache.items = []; this._positionCache.siblings = []; } + + /** + * Updates the top/left positions of a `ClientRect`, as well as their bottom/right counterparts. + * @param clientRect `ClientRect` that should be updated. + * @param top New value for the `top` position. + * @param left New value for the `left` position. + */ + private _adjustClientRect(clientRect: ClientRect, top: number, left: number) { + clientRect.top += top; + clientRect.bottom = clientRect.top + clientRect.height; + + clientRect.left += left; + clientRect.right = clientRect.left + clientRect.width; + } + + /** + * 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 xOffset Position of the user's pointer along the X axis. + * @param yOffset Position of the user's pointer along the Y axis. + */ + private _getItemIndexFromPointerPosition(item: CdkDrag, xOffset: number, yOffset: number) { + return this._positionCache.items.findIndex(({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; + } + + return this.orientation === 'horizontal' ? + // Round these down since most browsers report client rects with + // sub-pixel precision, whereas the mouse coordinates are rounded to pixels. + xOffset >= Math.floor(clientRect.left) && xOffset <= Math.floor(clientRect.right) : + yOffset >= Math.floor(clientRect.top) && yOffset <= Math.floor(clientRect.bottom); + }); + } } diff --git a/src/demo-app/drag-drop/drag-drop-demo.scss b/src/demo-app/drag-drop/drag-drop-demo.scss index b30f5796fb97..69e449a251a8 100644 --- a/src/demo-app/drag-drop/drag-drop-demo.scss +++ b/src/demo-app/drag-drop/drag-drop-demo.scss @@ -38,8 +38,8 @@ justify-content: space-between; box-sizing: border-box; - .cdk-drop-dragging & { - transition: transform 500ms ease; + .cdk-drop-dragging &:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); } .horizontal & { @@ -65,11 +65,11 @@ } .cdk-drag-animating { - transition: transform 500ms ease; + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); } .cdk-drag-placeholder { - opacity: 0.5; + opacity: 0; } .wrapper {