Browse Source

feat(admin-ui): Enable drag-drop reordering of assets

Closes #156
Michael Bromley 6 years ago
parent
commit
0e624f4e03

+ 2 - 0
packages/admin-ui/src/app/catalog/components/collection-detail/collection-detail.component.ts

@@ -158,6 +158,7 @@ export class CollectionDetailComponent extends BaseDetailComponent<Collection.Fr
                     this.notificationService.success(_('common.notify-create-success'), {
                         entity: 'Collection',
                     });
+                    this.assetChanges = {};
                     this.detailForm.markAsPristine();
                     this.changeDetector.markForCheck();
                     this.router.navigate(['../', data.createCollection.id], { relativeTo: this.route });
@@ -185,6 +186,7 @@ export class CollectionDetailComponent extends BaseDetailComponent<Collection.Fr
             )
             .subscribe(
                 () => {
+                    this.assetChanges = {};
                     this.detailForm.markAsPristine();
                     this.changeDetector.markForCheck();
                     this.notificationService.success(_('common.notify-update-success'), {

+ 14 - 4
packages/admin-ui/src/app/catalog/components/product-assets/product-assets.component.html

@@ -43,9 +43,19 @@
 </ng-template>
 
 <ng-template #assetList>
-    <div class="all-assets" [class.compact]="compact">
-        <ng-container *ngFor="let asset of assets">
-            <vdr-dropdown>
+    <div class="all-assets" [class.compact]="compact" cdkDropListGroup>
+        <div
+            cdkDropList
+            [cdkDropListEnterPredicate]="dropListEnterPredicate"
+            (cdkDropListDropped)="dropListDropped($event)"
+        ></div>
+        <div
+            *ngFor="let asset of assets"
+            cdkDropList
+            [cdkDropListEnterPredicate]="dropListEnterPredicate"
+            (cdkDropListDropped)="dropListDropped($event)"
+        >
+            <vdr-dropdown cdkDrag (cdkDragMoved)="dragMoved($event)">
                 <div
                     class="asset-thumb"
                     vdrDropdownTrigger
@@ -73,6 +83,6 @@
                     </button>
                 </vdr-dropdown-menu>
             </vdr-dropdown>
-        </ng-container>
+        </div>
     </div>
 </ng-template>

+ 44 - 0
packages/admin-ui/src/app/catalog/components/product-assets/product-assets.component.scss

@@ -61,3 +61,47 @@
         }
     }
 }
+
+.cdk-drag-preview {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 50px;
+    background-color: $color-grey-100;
+    opacity: 0.9;
+    box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
+    0 8px 10px 1px rgba(0, 0, 0, 0.14),
+    0 3px 14px 2px rgba(0, 0, 0, 0.12);
+}
+
+.cdk-drag-placeholder {
+    opacity: 0.8;
+    width: 60px;
+    height: 50px;
+    .asset-thumb {
+        background-color: $color-grey-300;
+        height: 100%;
+        width: 54px;
+    }
+    img {
+        display: none;
+    }
+}
+.all-assets.compact .cdk-drag-placeholder {
+    width: 50px;
+    .asset-thumb {
+        width: 50px;
+    }
+}
+
+.cdk-drag-animating {
+    transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
+}
+
+.example-box:last-child {
+    border: none;
+}
+
+.all-assets.cdk-drop-list-dragging vdr-dropdown:not(.cdk-drag-placeholder) {
+    transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
+}

+ 149 - 2
packages/admin-ui/src/app/catalog/components/product-assets/product-assets.component.ts

@@ -1,4 +1,14 @@
 import {
+    CdkDrag,
+    CdkDragDrop,
+    CdkDragMove,
+    CdkDropList,
+    CdkDropListGroup,
+    moveItemInArray,
+} from '@angular/cdk/drag-drop';
+import { ViewportRuler } from '@angular/cdk/overlay';
+import {
+    AfterViewInit,
     ChangeDetectionStrategy,
     ChangeDetectorRef,
     Component,
@@ -6,6 +16,7 @@ import {
     HostBinding,
     Input,
     Output,
+    ViewChild,
 } from '@angular/core';
 import { unique } from 'shared/unique';
 
@@ -22,6 +33,9 @@ export interface AssetChange {
 /**
  * A component which displays the Assets associated with a product, and allows assets to be removed and
  * added, and for the featured asset to be set.
+ *
+ * Note: rather complex code for drag drop is due to a limitation of the default CDK implementation
+ * which is addressed by a work-around from here: https://github.com/angular/components/issues/13372#issuecomment-483998378
  */
 @Component({
     selector: 'vdr-product-assets',
@@ -29,15 +43,37 @@ export interface AssetChange {
     styleUrls: ['./product-assets.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class ProductAssetsComponent {
+export class ProductAssetsComponent implements AfterViewInit {
     @Input() assets: Asset[] = [];
     @Input() featuredAsset: Asset | undefined;
     @HostBinding('class.compact')
     @Input()
     compact = false;
     @Output() change = new EventEmitter<AssetChange>();
+    @ViewChild(CdkDropListGroup, { static: false }) listGroup: CdkDropListGroup<CdkDropList>;
+    @ViewChild(CdkDropList, { static: false }) placeholder: CdkDropList;
+
+    public target: CdkDropList | null;
+    public targetIndex: number;
+    public source: CdkDropList | null;
+    public sourceIndex: number;
+    public dragIndex: number;
+    public activeContainer;
+
+    constructor(
+        private modalService: ModalService,
+        private changeDetector: ChangeDetectorRef,
+        private viewportRuler: ViewportRuler,
+    ) {}
 
-    constructor(private modalService: ModalService, private changeDetector: ChangeDetectorRef) {}
+    ngAfterViewInit() {
+        const phElement = this.placeholder.element.nativeElement;
+
+        phElement.style.display = 'none';
+        if (phElement.parentElement) {
+            phElement.parentElement.removeChild(phElement);
+        }
+    }
 
     selectAssets() {
         this.modalService
@@ -89,4 +125,115 @@ export class ProductAssetsComponent {
             featuredAssetId: featuredAsset && featuredAsset.id,
         });
     }
+
+    dragMoved(e: CdkDragMove) {
+        const point = this.getPointerPositionOnPage(e.event);
+
+        this.listGroup._items.forEach(dropList => {
+            if (__isInsideDropListClientRect(dropList, point.x, point.y)) {
+                this.activeContainer = dropList;
+                return;
+            }
+        });
+    }
+
+    dropListDropped() {
+        if (!this.target || !this.source) {
+            return;
+        }
+
+        const phElement = this.placeholder.element.nativeElement;
+        // tslint:disable-next-line:no-non-null-assertion
+        const parent = phElement.parentElement!;
+
+        phElement.style.display = 'none';
+
+        parent.removeChild(phElement);
+        parent.appendChild(phElement);
+        parent.insertBefore(this.source.element.nativeElement, parent.children[this.sourceIndex]);
+
+        this.target = null;
+        this.source = null;
+
+        if (this.sourceIndex !== this.targetIndex) {
+            moveItemInArray(this.assets, this.sourceIndex, this.targetIndex);
+            this.emitChangeEvent(this.assets, this.featuredAsset);
+        }
+    }
+
+    dropListEnterPredicate = (drag: CdkDrag, drop: CdkDropList) => {
+        if (drop === this.placeholder) {
+            return true;
+        }
+        if (drop !== this.activeContainer) {
+            return false;
+        }
+
+        const phElement = this.placeholder.element.nativeElement;
+        const sourceElement = drag.dropContainer.element.nativeElement;
+        const dropElement = drop.element.nativeElement;
+        const children = dropElement.parentElement && dropElement.parentElement.children;
+
+        const dragIndex = __indexOf(children, this.source ? phElement : sourceElement);
+        const dropIndex = __indexOf(children, dropElement);
+
+        if (!this.source) {
+            this.sourceIndex = dragIndex;
+            this.source = drag.dropContainer;
+
+            phElement.style.width = sourceElement.clientWidth + 'px';
+            phElement.style.height = sourceElement.clientHeight + 'px';
+
+            if (sourceElement.parentElement) {
+                sourceElement.parentElement.removeChild(sourceElement);
+            }
+        }
+
+        this.targetIndex = dropIndex;
+        this.target = drop;
+
+        phElement.style.display = '';
+        if (dropElement.parentElement) {
+            dropElement.parentElement.insertBefore(
+                phElement,
+                dropIndex > dragIndex ? dropElement.nextSibling : dropElement,
+            );
+        }
+
+        this.placeholder.enter(
+            drag,
+            drag.element.nativeElement.offsetLeft,
+            drag.element.nativeElement.offsetTop,
+        );
+        return false;
+    };
+
+    /** Determines the point of the page that was touched by the user. */
+    getPointerPositionOnPage(event: MouseEvent | TouchEvent) {
+        // `touches` will be empty for start/end events so we have to fall back to `changedTouches`.
+        const point = __isTouchEvent(event) ? event.touches[0] || event.changedTouches[0] : event;
+        const scrollPosition = this.viewportRuler.getViewportScrollPosition();
+
+        return {
+            x: point.pageX - scrollPosition.left,
+            y: point.pageY - scrollPosition.top,
+        };
+    }
+}
+
+function __indexOf(collection: HTMLCollection | null, node: HTMLElement) {
+    if (!collection) {
+        return -1;
+    }
+    return Array.prototype.indexOf.call(collection, node);
+}
+
+/** Determines whether an event is a touch event. */
+function __isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent {
+    return event.type.startsWith('touch');
+}
+
+function __isInsideDropListClientRect(dropList: CdkDropList, x: number, y: number) {
+    const { top, bottom, left, right } = dropList.element.nativeElement.getBoundingClientRect();
+    return y >= top && y <= bottom && x >= left && x <= right;
 }