Components: Drag and drop multiple cdkDrag elements

Created on 25 Oct 2018  路  35Comments  路  Source: angular/components

Bug, feature request, or proposal

Feature request

What is the expected behavior?

Select multiple cdkDrag elements e.g with a checkbox and drag them to a cdk-drop container.

What is the current behavior?

It's only possible with one cdkDrag element at a time.

Which versions of Angular, Material, OS, TypeScript, browsers are affected?

Material2 7.0.1

Is there anything else we should know?

If it's possible somehow I would appreciate a working demo.

P2 cddrag-drop feature

Most helpful comment

So I was able to get a pretty decent interaction going for this. I'll detail how multiple selected items were moved at once, I've also got some stuff around multi select and being able to do so with CTRL/shift clicking, etc. but I won't cover all that here.

Markup:

<table class="layout-table">
  <tbody cdkDropList
        (cdkDropListDropped)="drop($event)"
        [class.dragging]="dragging">
    <tr class="item"
        *ngFor="let item of items; let i = index"
        (click)="select($event, i)"
        [class.selected]="item.selected"
        cdkDrag
        (cdkDragStarted)="onDragStart($event)">
      <div class="layout-item-drag-preview" *cdkDragPreview>
        {{ selections.length > 0 ? selections.length : 1 }}
      </div>
      <td>{{ item.text }}</td>
    </tr>
  <tbody>
</table>

In my component:

// imports...
import { CdkDragDrop, CdkDragStart, moveItemInArray } from '@angular/cdk/drag-drop';

// functions in my component...
public onDragStart(event: CdkDragStart<string[]>) {
  this.dragging = true;
}

public drop(event: CdkDragDrop<string[]>) {
  const selections = [];

  // Get the indexes for all selected items
  _.each(this.items, (item, i) => {
    if (item.selected) {
      selections.push(i);
    }
  });

  if (this.selections.length > 1) {
    // If multiple selections exist
    let newIndex = event.currentIndex;
    let indexCounted = false;

    // create an array of the selected items
    // set newCurrentIndex to currentIndex - (any items before that index)
    this.selections = _.sortBy(this.selections, s => s);
    const selectedItems = _.map(this.selections, s => {
      if (s < event.currentIndex) {
        newIndex --;
        indexCounted = true;
      }
      return this.items[s];
    });

    // correct the index
    if (indexCounted) {
      newIndex++;
    }

    // remove selected items
    this.items = _.without(this.items, ...selectedItems);

    // add selected items at new index
    this.items.splice(newIndex, 0, ...selectedItems);
  } else {
    // If a single selection
    moveItemInArray(this.items, event.previousIndex, event.currentIndex);
  }

  this.dragging = false;
}

Sass styles:

$active: blue;
$frame: gray;
$red: red;

.layout-table {
  .layout-item {
    background-color: white;
    &.selected {
      background-color: lighten($active, 15%);
    }
  }
  tbody.dragging {
    .layout-item.selected:not(.cdk-drag-placeholder) {
      opacity: 0.2;
      td {
        border-color: rgba($frame, 0.2);
      }
    }
  }
}
.layout-item-drag-preview {
  background: $red;
  color: white;
  font-weight: bold;
  padding: 0.25em 0.5em 0.2em;
  border-radius: 30px;
  display: inline-block;
}
.cdk-drop-list-dragging .cdk-drag {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

So the other selected items get faded out while dragging multiple, removing or display: none caused the placeholder to be offset from the mouse and this affect seemed less disorientating anyway.
Mar-14-2019 09-58-47

Hope this helps!

All 35 comments

It's not possible at the moment, but we have a lot of the foundation work in place already. It still needs an API that allows consumers to start dragging programmatically.

Is there an ETA on this ?

It is possible to achieve this already, by tracking checked elements and in dropped event looking for all the checked elements to transfer (in case the dragged element was checked as well), but still the UI looks wierd as only the element you are dragging is moving across the lists.

+1 for this

+1 here too!

@mlg-pmilic How do you then perform the drag and drop using code? I have experimented a bit with the moveItemInArray function but can't get it to work.

Thanks :)

So I was able to get a pretty decent interaction going for this. I'll detail how multiple selected items were moved at once, I've also got some stuff around multi select and being able to do so with CTRL/shift clicking, etc. but I won't cover all that here.

Markup:

<table class="layout-table">
  <tbody cdkDropList
        (cdkDropListDropped)="drop($event)"
        [class.dragging]="dragging">
    <tr class="item"
        *ngFor="let item of items; let i = index"
        (click)="select($event, i)"
        [class.selected]="item.selected"
        cdkDrag
        (cdkDragStarted)="onDragStart($event)">
      <div class="layout-item-drag-preview" *cdkDragPreview>
        {{ selections.length > 0 ? selections.length : 1 }}
      </div>
      <td>{{ item.text }}</td>
    </tr>
  <tbody>
</table>

In my component:

// imports...
import { CdkDragDrop, CdkDragStart, moveItemInArray } from '@angular/cdk/drag-drop';

// functions in my component...
public onDragStart(event: CdkDragStart<string[]>) {
  this.dragging = true;
}

public drop(event: CdkDragDrop<string[]>) {
  const selections = [];

  // Get the indexes for all selected items
  _.each(this.items, (item, i) => {
    if (item.selected) {
      selections.push(i);
    }
  });

  if (this.selections.length > 1) {
    // If multiple selections exist
    let newIndex = event.currentIndex;
    let indexCounted = false;

    // create an array of the selected items
    // set newCurrentIndex to currentIndex - (any items before that index)
    this.selections = _.sortBy(this.selections, s => s);
    const selectedItems = _.map(this.selections, s => {
      if (s < event.currentIndex) {
        newIndex --;
        indexCounted = true;
      }
      return this.items[s];
    });

    // correct the index
    if (indexCounted) {
      newIndex++;
    }

    // remove selected items
    this.items = _.without(this.items, ...selectedItems);

    // add selected items at new index
    this.items.splice(newIndex, 0, ...selectedItems);
  } else {
    // If a single selection
    moveItemInArray(this.items, event.previousIndex, event.currentIndex);
  }

  this.dragging = false;
}

Sass styles:

$active: blue;
$frame: gray;
$red: red;

.layout-table {
  .layout-item {
    background-color: white;
    &.selected {
      background-color: lighten($active, 15%);
    }
  }
  tbody.dragging {
    .layout-item.selected:not(.cdk-drag-placeholder) {
      opacity: 0.2;
      td {
        border-color: rgba($frame, 0.2);
      }
    }
  }
}
.layout-item-drag-preview {
  background: $red;
  color: white;
  font-weight: bold;
  padding: 0.25em 0.5em 0.2em;
  border-radius: 30px;
  display: inline-block;
}
.cdk-drop-list-dragging .cdk-drag {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

So the other selected items get faded out while dragging multiple, removing or display: none caused the placeholder to be offset from the mouse and this affect seemed less disorientating anyway.
Mar-14-2019 09-58-47

Hope this helps!

@brandonreid Thanks for sharing your solution, it looks nice!

there is one way for me to drag a few elements in one selection, it to create wrapper and push there items which should be dragged.
small pseudo code:

<wrapper cdkDrag *ngIf="selectedItems > 1">
  <draggableItem *ngFor="selectedItems"> 
  </draggableItem>
</wrapper>

issue of this, that will be reinitialize of <draggableItem> in dynamic usage, but mb it can be solved via 'portal' from cdk

not sure that is possible to implement native programmatically dragging under the hood, bcs it require native mouse event and target...

See if this library can help you - https://www.npmjs.com/package/aui-select-box

So I was able to get a pretty decent interaction going for this. I'll detail how multiple selected items were moved at once, I've also got some stuff around multi select and being able to do so with CTRL/shift clicking, etc. but I won't cover all that here.

Markup:

<table class="layout-table">
  <tbody cdkDropList
        (cdkDropListDropped)="drop($event)"
        [class.dragging]="dragging">
    <tr class="item"
        *ngFor="let item of items; let i = index"
        (click)="select($event, i)"
        [class.selected]="item.selected"
        cdkDrag
        (cdkDragStarted)="onDragStart($event)">
      <div class="layout-item-drag-preview" *cdkDragPreview>
        {{ selections.length > 0 ? selections.length : 1 }}
      </div>
      <td>{{ item.text }}</td>
    </tr>
  <tbody>
</table>

In my component:

// imports...
import { CdkDragDrop, CdkDragStart, moveItemInArray } from '@angular/cdk/drag-drop';

// functions in my component...
public onDragStart(event: CdkDragStart<string[]>) {
  this.dragging = true;
}

public drop(event: CdkDragDrop<string[]>) {
  const selections = [];

  // Get the indexes for all selected items
  _.each(this.items, (item, i) => {
    if (item.selected) {
      selections.push(i);
    }
  });

  if (this.selections.length > 1) {
    // If multiple selections exist
    let newIndex = event.currentIndex;
    let indexCounted = false;

    // create an array of the selected items
    // set newCurrentIndex to currentIndex - (any items before that index)
    this.selections = _.sortBy(this.selections, s => s);
    const selectedItems = _.map(this.selections, s => {
      if (s < event.currentIndex) {
        newIndex --;
        indexCounted = true;
      }
      return this.items[s];
    });

    // correct the index
    if (indexCounted) {
      newIndex++;
    }

    // remove selected items
    this.items = _.without(this.items, ...selectedItems);

    // add selected items at new index
    this.items.splice(newIndex, 0, ...selectedItems);
  } else {
    // If a single selection
    moveItemInArray(this.items, event.previousIndex, event.currentIndex);
  }

  this.dragging = false;
}

Sass styles:

$active: blue;
$frame: gray;
$red: red;

.layout-table {
  .layout-item {
    background-color: white;
    &.selected {
      background-color: lighten($active, 15%);
    }
  }
  tbody.dragging {
    .layout-item.selected:not(.cdk-drag-placeholder) {
      opacity: 0.2;
      td {
        border-color: rgba($frame, 0.2);
      }
    }
  }
}
.layout-item-drag-preview {
  background: $red;
  color: white;
  font-weight: bold;
  padding: 0.25em 0.5em 0.2em;
  border-radius: 30px;
  display: inline-block;
}
.cdk-drop-list-dragging .cdk-drag {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

So the other selected items get faded out while dragging multiple, removing or display: none caused the placeholder to be offset from the mouse and this affect seemed less disorientating anyway.
Mar-14-2019 09-58-47

Hope this helps!

Hey @brandonreid, What was the logic behind the select() function in the html? I'm attempting to implement a similar solution and would love to see what your process was here. Thanks!

@william-holt yeah multi select with shift/ctrl can be pretty mind bending. You've got to track the last item clicked for shift select, track what's been shift selected in case the user shift clicks further, etc. I'll post the code, it's got some comments in there so hopefully it helps.

public dataLayoutItems: Array<DataLayoutItem> = []; // the items in my sortable list

private currentSelectionSpan: number[] = [];
private lastSingleSelection: number;
public selections: number[] = [];

// handles "ctrl/command + a" to select all
@HostListener('document:keydown', ['$event'])
private handleKeyboardEvent(event: KeyboardEvent) {
  if (
    this.selections.length > 0 &&
    (event.key === 'a' && event.ctrlKey ||
     event.key === 'a' && event.metaKey) &&
     document.activeElement.nodeName !== 'INPUT') {
      event.preventDefault();
      this.selectAll();
  }
}

public select(event, index) {
  if (event.srcElement.localName !== 'td' &&
      event.srcElement.localName !== 'strong') {
    return;
  }

  let itemSelected = true;
  const shiftSelect = event.shiftKey &&
                      (this.lastSingleSelection || this.lastSingleSelection === 0) &&
                      this.lastSingleSelection !== index;

  if (!this.selections || this.selections.length < 1) {
    // if nothing selected yet, init selection mode and select.
    this.selections = [index];
    this.lastSingleSelection = index;
  } else if (event.metaKey || event.ctrlKey) {
    // if holding ctrl / cmd
    const alreadySelected = _.find(this.selections, s => s === index);
    if (alreadySelected) {
      _.remove(this.selections, s => s === index);
      itemSelected = false;
      this.lastSingleSelection = null;
    } else {
      this.selections.push(index);
      this.lastSingleSelection = index;
    }
  } else if (shiftSelect) {
    // if holding shift, add group to selection and currentSelectionSpan
    const newSelectionBefore = index < this.lastSingleSelection;
    const count = (
      newSelectionBefore ? this.lastSingleSelection - (index - 1) :
                            (index + 1) - this.lastSingleSelection
    );

    // clear previous shift selection
    if (this.currentSelectionSpan && this.currentSelectionSpan.length > 0) {
      _.each(this.currentSelectionSpan, i => {
        this.dataLayoutItems[i].selected = false;
        _.remove(this.selections, s => s === i);
      });
      this.currentSelectionSpan = [];
    }
    // build new currentSelectionSpan
    _.times(count, c => {
      if (newSelectionBefore) {
        this.currentSelectionSpan.push(this.lastSingleSelection - c);
      } else {
        this.currentSelectionSpan.push(this.lastSingleSelection + c);
      }
    });
    // select currentSelectionSpan
    _.each(this.currentSelectionSpan, (i) => {
      this.dataLayoutItems[i].selected = true;
      if (!_.includes(this.selections, i)) {
        this.selections.push(i);
      }
    });
  } else {
    // Select only this item or clear selections.
    const alreadySelected = _.find(this.selections, s => s === index);
    if ((!alreadySelected && !event.shiftKey) ||
        (alreadySelected && this.selections.length > 1)) {
      this.clearSelection();
      this.selections = [index];
      this.lastSingleSelection = index;
    } else if (alreadySelected) {
      this.clearSelection();
      itemSelected = false;
    }
  }

  if (!event.shiftKey) {
    // clear currentSelectionSpan if not holding shift
    this.currentSelectionSpan = [];
  }

  // select clicked item
  this.dataLayoutItems[index].selected = itemSelected;
}

public clearSelection() {
  _.each(this.dataLayoutItems, (l) => {
    if (l.selected) { l.selected = false; }
  });
  this.selections = [];
  this.currentSelectionSpan = [];
  this.lastSingleSelection = null;
}

public selectAll() {
  this.selections = [];
  _.each(this.dataLayoutItems, (l, i) => {
    l.selected = true;
    this.selections.push(i);
  });
  this.currentSelectionSpan = [];
  this.lastSingleSelection = null;
}

@william-holt yeah multi select with shift/ctrl can be pretty mind bending. You've got to track the last item clicked for shift select, track what's been shift selected in case the user shift clicks further, etc. I'll post the code, it's got some comments in there so hopefully it helps.

public dataLayoutItems: Array<DataLayoutItem> = []; // the items in my sortable list

private currentSelectionSpan: number[] = [];
private lastSingleSelection: number;
public selections: number[] = [];

// handles "ctrl/command + a" to select all
@HostListener('document:keydown', ['$event'])
private handleKeyboardEvent(event: KeyboardEvent) {
  if (
    this.selections.length > 0 &&
    (event.key === 'a' && event.ctrlKey ||
     event.key === 'a' && event.metaKey) &&
     document.activeElement.nodeName !== 'INPUT') {
      event.preventDefault();
      this.selectAll();
  }
}

public select(event, index) {
  if (event.srcElement.localName !== 'td' &&
      event.srcElement.localName !== 'strong') {
    return;
  }

  let itemSelected = true;
  const shiftSelect = event.shiftKey &&
                      (this.lastSingleSelection || this.lastSingleSelection === 0) &&
                      this.lastSingleSelection !== index;

  if (!this.selections || this.selections.length < 1) {
    // if nothing selected yet, init selection mode and select.
    this.selections = [index];
    this.lastSingleSelection = index;
  } else if (event.metaKey || event.ctrlKey) {
    // if holding ctrl / cmd
    const alreadySelected = _.find(this.selections, s => s === index);
    if (alreadySelected) {
      _.remove(this.selections, s => s === index);
      itemSelected = false;
      this.lastSingleSelection = null;
    } else {
      this.selections.push(index);
      this.lastSingleSelection = index;
    }
  } else if (shiftSelect) {
    // if holding shift, add group to selection and currentSelectionSpan
    const newSelectionBefore = index < this.lastSingleSelection;
    const count = (
      newSelectionBefore ? this.lastSingleSelection - (index - 1) :
                            (index + 1) - this.lastSingleSelection
    );

    // clear previous shift selection
    if (this.currentSelectionSpan && this.currentSelectionSpan.length > 0) {
      _.each(this.currentSelectionSpan, i => {
        this.dataLayoutItems[i].selected = false;
        _.remove(this.selections, s => s === i);
      });
      this.currentSelectionSpan = [];
    }
    // build new currentSelectionSpan
    _.times(count, c => {
      if (newSelectionBefore) {
        this.currentSelectionSpan.push(this.lastSingleSelection - c);
      } else {
        this.currentSelectionSpan.push(this.lastSingleSelection + c);
      }
    });
    // select currentSelectionSpan
    _.each(this.currentSelectionSpan, (i) => {
      this.dataLayoutItems[i].selected = true;
      if (!_.includes(this.selections, i)) {
        this.selections.push(i);
      }
    });
  } else {
    // Select only this item or clear selections.
    const alreadySelected = _.find(this.selections, s => s === index);
    if ((!alreadySelected && !event.shiftKey) ||
        (alreadySelected && this.selections.length > 1)) {
      this.clearSelection();
      this.selections = [index];
      this.lastSingleSelection = index;
    } else if (alreadySelected) {
      this.clearSelection();
      itemSelected = false;
    }
  }

  if (!event.shiftKey) {
    // clear currentSelectionSpan if not holding shift
    this.currentSelectionSpan = [];
  }

  // select clicked item
  this.dataLayoutItems[index].selected = itemSelected;
}

public clearSelection() {
  _.each(this.dataLayoutItems, (l) => {
    if (l.selected) { l.selected = false; }
  });
  this.selections = [];
  this.currentSelectionSpan = [];
  this.lastSingleSelection = null;
}

public selectAll() {
  this.selections = [];
  _.each(this.dataLayoutItems, (l, i) => {
    l.selected = true;
    this.selections.push(i);
  });
  this.currentSelectionSpan = [];
  this.lastSingleSelection = null;
}

Can you please produce a working demo ??

StackBlitz ??

Hi, not going to quote your post again @brandonreid, but I would appreciate a StackBlitz demo as well if you can find the time!

Thank you for posting your code @brandonreid !! I've adapted your code somewhat but wanted to post back to help others too.

  • I use ng-container / ng-template so that the items can be styled in any way by the user (grid, list, etc.).
  • I use OnPush Change Detection strategy for performance (i.e. with 10k items)
  • supports CTRL/SHIFT selections
  • supports ESC to cancel drag/drop
  • click outside the component will clear the selection
  • supports multiple lists if they're enclosed within cdkDropListGroup
  • events for itemsAdded, itemsRemoved and itemsUpdated
  • event for selectionChanged

https://stackblitz.com/edit/angular-multi-drag-drop

Example usage:

<multi-drag-drop [items]="['a', 'b', 'c', 'd']">
  <ng-template let-item>
    --{{ item }}--
  </ng-template>
</multi-drag-drop>

multi-drag-drop.component.html:

<div
  class="drop-list"
  cdkDropList
  [class.item-dragging]="dragging"
  (cdkDropListDropped)="droppedIntoList($event)"
>
  <div
    *ngFor="let item of items; let index = index"
    class="item"
    [class.selected]="isSelected(index)"
    cdkDrag
    (cdkDragStarted)="dragStarted($event, index)"
    (cdkDragEnded)="dragEnded()"
    (cdkDragDropped)="dropped($event)"
    (click)="select($event, index)"
  >
    <div  *ngIf="!dragging || !isSelected(index)">
      <ng-container
        *ngTemplateOutlet="templateRef; context: { $implicit: item, item: item, index: index }"
      ></ng-container>
      <div *cdkDragPreview>
        <div class="select-item-drag-preview float-left">
          {{ selections.length || 1 }}
        </div>
        <ng-container
          *ngTemplateOutlet="templateRef; context: { $implicit: item, item: item, index: index }"
        ></ng-container>
      </div>
    </div>
  </div>
</div>

multi-drag-drop.component.scss:

.drop-list {
  min-height: 10px;
  min-width: 10px;
  height: 100%;
  width: 100%;
  border: 1px #393E40 solid;
  overflow-y: scroll;
}
.item {
  border: 0 dotted #393E40;
  border-width: 0 0 1px 0;
  cursor: grab;
  padding: 3px;

  &.selected {
    background-color: rgba(144, 171, 200, 0.5);
  }
}
.item-dragging {
  .item.selected:not(.cdk-drag-placeholder) {
    opacity: 0.3;
  }
}
.select-item-drag-preview {
  background-color: rgba(204, 0, 102, 0);
  font-weight: bold;
  border: 2px solid #666;
  border-radius: 50%;
  display: inline-block;
  width: 30px;
  line-height: 30px;
  text-align: center;
}
.cdk-drop-list-dragging .cdk-drag {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

multi-drag-drop.component.ts:

import { CdkDragDrop, CdkDragStart } from '@angular/cdk/drag-drop/typings/drag-events';
import { DragRef } from '@angular/cdk/drag-drop/typings/drag-ref';
import {
  ChangeDetectionStrategy, ChangeDetectorRef,
  Component,
  ContentChild,
  ElementRef,
  EventEmitter,
  HostListener,
  Input,
  Output,
  TemplateRef
} from '@angular/core';
import * as _ from 'lodash';

@Component({
  selector: 'multi-drag-drop',
  templateUrl: './multi-drag-drop.component.html',
  styleUrls: ['./multi-drag-drop.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MultiDragDropComponent {
  @Input() items: any[];
  @Output() itemsRemoved = new EventEmitter<any[]>();
  @Output() itemsAdded = new EventEmitter<any[]>();
  @Output() itemsUpdated = new EventEmitter<any[]>();
  @Output() selectionChanged = new EventEmitter<any[]>();
  @ContentChild(TemplateRef, { static: false }) templateRef;

  public dragging: DragRef = null;
  public selections: number[] = [];
  private currentSelectionSpan: number[] = [];
  private lastSingleSelection: number;

  constructor(
    private eRef: ElementRef,
    private cdRef: ChangeDetectorRef,
  ) {
  }

  dragStarted(ev: CdkDragStart, index: number): void {
    this.dragging = ev.source._dragRef;
    const indices = this.selections.length ? this.selections : [index];
    ev.source.data = {
      indices,
      values: indices.map(i => this.items[i]),
      source: this,
    };
    this.cdRef.detectChanges();
  }

  dragEnded(): void {
    this.dragging = null;
    this.cdRef.detectChanges();
  }

  dropped(ev: CdkDragDrop<any>): void {
    if (!ev.isPointerOverContainer || !_.get(ev, 'item.data.source')) {
      return;
    }
    const data = ev.item.data;

    if (data.source === this) {
      const removed = _.pullAt(this.items, data.indices);
      if (ev.previousContainer !== ev.container) {
        this.itemsRemoved.emit(removed);
        this.itemsUpdated.emit(this.items);
      }
    }
    this.dragging = null;
    setTimeout(() => this.clearSelection());
  }

  droppedIntoList(ev: CdkDragDrop<any>): void {
    if (!ev.isPointerOverContainer || !_.get(ev, 'item.data.source')) {
      return;
    }
    const data = ev.item.data;
    let spliceIntoIndex = ev.currentIndex;
    if (ev.previousContainer === ev.container && this.selections.length > 1) {
      this.selections.splice(-1, 1);
      const sum = _.sumBy(this.selections, selectedIndex => selectedIndex <= spliceIntoIndex ? 1 : 0);
      spliceIntoIndex -= sum;
    }
    this.items.splice(spliceIntoIndex, 0, ...data.values);

    if (ev.previousContainer !== ev.container) {
      this.itemsAdded.emit(data.values);
    }
    this.itemsUpdated.emit(this.items);
    setTimeout(() => this.cdRef.detectChanges());
  }

  isSelected(i: number): boolean {
    return this.selections.indexOf(i) >= 0;
  }

  select(event, index) {
    const shiftSelect = event.shiftKey &&
      (this.lastSingleSelection || this.lastSingleSelection === 0) &&
      this.lastSingleSelection !== index;

    if (!this.selections || this.selections.length < 1) {
      // if nothing selected yet, init selection mode and select.
      this.selections = [index];
      this.lastSingleSelection = index;
    } else if (event.metaKey || event.ctrlKey) {
      // if holding ctrl / cmd
      const alreadySelected = _.find(this.selections, s => s === index);
      if (alreadySelected) {
        _.remove(this.selections, s => s === index);
        this.lastSingleSelection = null;
      } else {
        this.selections.push(index);
        this.lastSingleSelection = index;
      }
    } else if (shiftSelect) {
      // if holding shift, add group to selection and currentSelectionSpan
      const newSelectionBefore = index < this.lastSingleSelection;
      const count = (
        newSelectionBefore ? this.lastSingleSelection - (index - 1) :
          (index + 1) - this.lastSingleSelection
      );

      // clear previous shift selection
      if (this.currentSelectionSpan && this.currentSelectionSpan.length > 0) {
        _.each(this.currentSelectionSpan, i => {
          _.remove(this.selections, s => s === i);
        });
        this.currentSelectionSpan = [];
      }
      // build new currentSelectionSpan
      _.times(count, c => {
        if (newSelectionBefore) {
          this.currentSelectionSpan.push(this.lastSingleSelection - c);
        } else {
          this.currentSelectionSpan.push(this.lastSingleSelection + c);
        }
      });
      // select currentSelectionSpan
      _.each(this.currentSelectionSpan, (i) => {
        if (!_.includes(this.selections, i)) {
          this.selections.push(i);
        }
      });
    } else {
      // Select only this item or clear selections.
      const alreadySelected = _.find(this.selections, s => s === index);
      if ((!alreadySelected && !event.shiftKey) ||
        (alreadySelected && this.selections.length > 1)) {
        this.clearSelection();
        this.selections = [index];
        this.lastSingleSelection = index;
      } else if (alreadySelected) {
        this.clearSelection();
      }
    }

    if (!event.shiftKey) {
      this.currentSelectionSpan = [];
    }
    this.selectionChanged.emit(this.selections.map(i => this.items[i]));
    this.cdRef.detectChanges();
  }

  clearSelection() {
    if (this.selections.length) {
      this.selections = [];
      this.currentSelectionSpan = [];
      this.lastSingleSelection = null;
      this.selectionChanged.emit(this.selections.map(i => this.items[i]));
      this.cdRef.detectChanges();
    }
  }

  selectAll() {
    if (this.selections.length !== this.items.length) {
      this.selections = _.map(this.items, (item, i) => i);
      this.currentSelectionSpan = [];
      this.lastSingleSelection = null;
      this.selectionChanged.emit(this.items);
      this.cdRef.detectChanges();
    }
  }

  // handles "ctrl/command + a" to select all
  @HostListener('document:keydown', ['$event'])
  private handleKeyboardEvent(event: KeyboardEvent) {
    if (event.key === 'a' &&
      (event.ctrlKey || event.metaKey) &&
      this.selections.length &&
      document.activeElement.nodeName !== 'INPUT'
    ) {
      event.preventDefault();
      this.selectAll();
    } else if (event.key === 'Escape' && this.dragging) {
      this.dragging.reset();
      document.dispatchEvent(new Event('mouseup'));
    }
  }

  @HostListener('document:click', ['$event'])
  private clickout(event) {
    if (this.selections.length && !this.eRef.nativeElement.contains(event.target)) {
      this.clearSelection();
    }
  }
}

@6utt3rfly looks good mate, would you like to share a stackblitz or a repo for this to demonstrate the working solution. ?.

@6utt3rfly thanks for your result.
I've found a bug, some idea how to fix it?

Bug
List of numbers
image

Choose first three numbers
image

Try to sort them behind the 4
image

Wrong result
image

The result should be: 4,1,2,3,5,6,7,8,9,10

@tk2232 : Can you check now (updated comment above and stackblitz)?

That looks good. Do you have any ideas on how to change the preview so that the elements appear in a row like moving a single element?

You could modify the cdkDragPreview. Right now it's the selection length, plus the last item selected. But you could show all items by using ng-container multiple times. You would have to play with styling, but it would be something like:

      <div *cdkDragPreview>
        <div class="select-item-drag-preview float-left">
          {{ selections.length || 1 }}
        </div>
        <div *ngFor="let sel of selections">
          <ng-container
            *ngTemplateOutlet="templateRef; context: { $implicit: sel, item: sel, index: index }"
          ></ng-container>
        </div>
      </div>

The last time I was here I did not have the stackblitz working, So I started making my own solution which uses CSS classes to work. Well, I'll at least comment on this solution.

Example:

dragdrop multidrag 1
Working: stackblitz.com/edit/angular-multi-dragdrop

Short introduction to operation:

I use two main objects, that govern everything from multidrag, both are independent of each other:

  • multiSelect: Add and remove the css ".selected" class on elements, it works with longPress and Ctrl+Click to start the multi-selection, so its compatible with mobile and desktop. But you can remove this functions and do in different ways too.
  • multiDragDrop: Modify the order according to CSS classes on elements.

I used the example of "_Drag & Drop connected sorting group_" from the site material.angular.io/cdk/drag-drop/examples as a base.

To deploy you need (6 steps) :

  • customize your ".selected" css to make it different, and a ".selected.hide" to leave the element with a lower opacity when the main one is dragging, like this:
.selected{
  border: 1px solid green!important;
}
.selected.hide{
  opacity: 0.3;
}
  • import at least the following:
import {CdkDragDrop, CdkDragStart, CdkDragEnd, moveItemInArray, transferArrayItem} from '@angular/cdk/drag-drop';
  • change the "drop()" function a little on .ts (if you want to place objects elsewhere, note the "multiDrag" object call here):
  drop(event: CdkDragDrop<string[]>) {
    // If current element has ".selected"
    if(event.item.element.nativeElement.classList.contains("selected")){
      this.multiDrag.dropListDropped(event);
    }
    // If dont have ".selected" (normal case)
    else{
      if (event.previousContainer === event.container) {
        moveItemInArray(event.container.data, event.previousIndex,  event.currentIndex);
      } else {
        transferArrayItem(event.previousContainer.data,
                          event.container.data,
                          event.previousIndex,
                          event.currentIndex);
      }
    }
  }
  • copy and paste the 2 objects below into your .ts file (the important thing is to call him the right way). No need to change them (unless you want to change the longpress time in the "longPressTime").
    // Multi Select
    multiSelect = { // Put ".selected" on elements when clicking after longPress or Ctrl+Click
        // Initial Variables
        longPressTime: 500, // in ms unit
        verifyLongPress: 0,
        multiSelect: false,
        verifyDragStarted: false,
        ctrlMode: false,
        firstContainer: null as unknown as HTMLElement,

        selectDrag(el: HTMLElement) {
            while (!el.classList.contains("cdk-drag")) {
                el = el.parentElement as HTMLElement;
            }
            return el;
        },

        mouseDown(e: Event) {
            let target = this.selectDrag(e.target as HTMLElement);
            let ctrlKey = (e as KeyboardEvent).ctrlKey;

            if (this.multiSelect) { // If multiSelect is enabled

                /* The responsibility for removing only the first ".selected" has to be with mouseDown and not with mouseUp.
                   if not you can't add the first one */

                // Remove
                let allSelected = document.querySelectorAll(".selected").length;
                if (allSelected == 1 && target.classList.contains("selected") && (this.ctrlMode ? ctrlKey : true)) { // If only have one ".selected" and it was clicked
                    target.classList.remove("selected", "last");  // remove ".selected" and ".last"
                    this.multiSelect = false; // turns off multiSelect
                }
            }

            else { // If multiSelect is disabled
                // Do this
                let addSelected = () => {
                    this.multiSelect = true; // enable multiSelect
                    this.firstContainer = target.parentElement as HTMLElement; //saves the container of the first selected element
                    target.classList.add("selected", "last"); // and add ".selected" and ".last" to the current element clicked
                }

                // If using CTRL
                if (ctrlKey) {
                    this.ctrlMode = true;
                    addSelected();
                };

                // If using longPress
                this.verifyLongPress = <any>setTimeout(() => { // If there is a LongPress
                    this.ctrlMode = false;
                    addSelected();
                }, this.longPressTime); // after "longPressTime"(ms)
            }
        },

        mouseUp(e: Event) {
            clearTimeout(this.verifyLongPress); // cancel LongPress

            if (this.multiSelect && !this.verifyDragStarted) { // If multiSelect is enabled AND not start DragStarted
                let target = this.selectDrag(e.target as HTMLElement);
                let allSelected = document.querySelectorAll(".selected");
                let ctrlKey = (e as KeyboardEvent).ctrlKey;
                let last = document.querySelector(".last");

                // If use Shift
                if (last && (e as KeyboardEvent).shiftKey) {
                    // take range informations
                    let containerLast = Array.from(last.parentElement!.children);
                    let lastIndex = containerLast.indexOf(last);
                    let currIndex = containerLast.indexOf(target);
                    let max = Math.max(lastIndex, currIndex);
                    let min = Math.min(lastIndex, currIndex);

                    // toggle .selected in the range
                    for (let i = min; i <= max; i++) {
                        if (i != lastIndex) { // Does not toggle the penult element clicked
                            containerLast[i].classList.toggle("selected");
                        }
                    }

                    // put .last if last clicked was selected at end
                    if (target.classList.contains("selected")) {
                        last && last.classList.remove("last"); // remove .last from penult element clicked
                        target.classList.add("last"); // and add ".last" to the current element
                    }
                }

                //If don't use shift
                else {
                    // To remove from selection
                    /* responsibility to remove from selection assigned to mouseUp */
                    if (target.classList.contains("selected") && allSelected.length > 1 && (this.ctrlMode ? ctrlKey : true)) { // If the clicked element already has ".selected" AND If you have more than 1 (not to remove the first one added)
                        target.classList.remove("selected"); // remove ".selected"
                        target.classList.remove("last"); // remove ".last"
                    }

                    // To add to selection
                    else { // if the clicked element does not have the ".selected"
                        if (this.firstContainer == target.parentElement && (this.ctrlMode ? ctrlKey : true)) { //if the item click is made within the same container
                            last && last.classList.remove("last"); // remove .last from penult element clicked
                            target.classList.add("selected", "last"); // add ".selected" and ".last"
                        }
                        else if (this.ctrlMode ? ctrlKey : true) { // if in different container, and with ctrl (if ctrl)
                            allSelected.forEach((el) => { // remove all selected from last container
                                el.classList.remove("selected", "hide", "last");
                            });
                            this.firstContainer = target.parentElement as HTMLElement; //saves the container of the new selected element
                            target.classList.add("selected", "last"); // and add ".selected" to the element clicked in the new container
                        }
                    }

                }
            }
        },

        dragStarted() {
            this.verifyDragStarted = true; // shows to mouseDown and mouseUp that Drag started
            clearTimeout(this.verifyLongPress); // cancel longPress
        },

        dragEnded() {
            this.verifyDragStarted = false; // show mouseDown and mouseUp that Drag is over
        },

        dropListDropped(e: CdkDragDrop<string[]>) {
            let el = e.item.element.nativeElement;
            if (el.classList.contains("selected")) { // the dragged element was of the "selected" class
                this.multiSelect = false; // disable multiSelect
            }
        },

    }

    // Multi Drag
    multiDrag = { // Adjusts clicked items that have ".selected" to organize together
        // Initial Variables
        dragList: [""], // has the value of the selected items in sequence from listData
        dragListCopy: [""], // a copy of the listData, but with the selected elements marked with "DragErase" to delete later
        dragErase: Symbol("DragErase") as any, // a symbol to have unique value when deleting

        dragStarted(e: CdkDragStart) {
            if (e.source.element.nativeElement.classList.contains("selected")) { // If the dragged element has ".selected"
                //prepare
                let listData = e.source.dropContainer.data; // get list data value
                this.dragList = []; // reset the dragList
                this.dragListCopy = [...listData]; // copy listData into variable
                let DOMdragEl = e.source.element.nativeElement; // dragged element
                let DOMcontainer = Array.from(DOMdragEl.parentElement!.children); // container where all draggable elements are
                let DOMdragElIndex = DOMcontainer.indexOf(DOMdragEl); // index of the dragged element
                let allSelected = document.querySelectorAll(".selected"); // get all the ".selected"

                // Goes through all ".selected"
                allSelected.forEach((eli) => {
                    // get index of current element
                    let CurrDOMelIndexi = DOMcontainer.indexOf(eli);

                    // Add listData of current ".selected" to dragList
                    this.dragList.push(listData[CurrDOMelIndexi]);

                    // Replaces current position in dragListCopy with "DragErase" (to erase exact position later)
                    this.dragListCopy[CurrDOMelIndexi] = this.dragErase;

                    // Put opacity effect (by CSS class ".hide") on elements (after starting Drag)
                    if (DOMdragElIndex !== CurrDOMelIndexi) {
                        eli.classList.add("hide");
                    }
                });

            }
        },

        dropListDropped(e: CdkDragDrop<string[]>) {

            if (e.previousContainer === e.container) { // If in the same container

                let posAdjust = e.previousIndex < e.currentIndex ? 1 : 0; // Adjusts the placement position
                this.dragListCopy.splice(e.currentIndex + posAdjust, 0, ...this.dragList); // put elements in dragListCopy
                this.dragListCopy = this.dragListCopy.filter((el) => (el !== this.dragErase)); // remove the "DragErase" from the list

                // Pass item by item to final list
                for (let i = 0; i < e.container.data.length; i++) {
                    e.container.data[i] = this.dragListCopy[i];
                }

            }

            else { // If in different containers

                // remove the "DragErase" from the list
                this.dragListCopy = this.dragListCopy.filter((el) => (el !== this.dragErase));

                // Pass item by item to initial list
                for (let i = 0; i < e.previousContainer.data.length; i++) {
                    e.previousContainer.data[i] = this.dragListCopy[i];
                }
                for (let i = 0; i < this.dragList.length; i++) {
                    e.previousContainer.data.pop();
                }


                let otherListCopy = [...e.container.data]; // list of new container
                otherListCopy.splice(e.currentIndex, 0, ...this.dragList); // put elements in otherListCopy

                // Pass item by item to final list
                for (let i = 0; i < otherListCopy.length; i++) {
                    e.container.data[i] = otherListCopy[i];
                }

            }

            // Remove ".hide"
            let allHidden = document.querySelectorAll(".hide");
            allHidden.forEach((el) => {
                el.classList.remove("hide");
            });
            // Remove ".selected" after 300ms
            setTimeout(() => {
                let allSelected = document.querySelectorAll(".selected");
                allSelected.forEach((el) => {
                    el.classList.remove("selected", "last");
                });
            }, 300);


            this.dragListCopy = []; // reset the dragListCopy
            this.dragList = []; // reset the dragList
        },

    }
  • Use this selection system (on _cdkDropListDropped_, _pointerdown_(the new mousedown), _pointerup_(the new mouseup), _cdkDragStarted_, _cdkDragEnded_). (Or deploy this part by your own functions removing the functions of the "multiSelect" object)
  • use multiDrag.dragStarted($event) object in "cdkDragStarted"

Regarding the last 2 items, my code looked like this (if you want to place objects elsewhere, note the "multiDrag" and "multiSelect" object call here):

<div class="example-list"
    cdkDropList
    [cdkDropListData]="todo"
    (cdkDropListDropped)="drop($event);multiSelect.dropListDropped($event)"
>
    <div class="example-box" *ngFor="let item of todo"
        cdkDrag
        (pointerdown)="multiSelect.mouseDown($event)"
        (pointerup)="multiSelect.mouseUp($event)"
        (cdkDragStarted)="multiSelect.dragStarted();multiDrag.dragStarted($event)"
        (cdkDragEnded)="multiSelect.dragEnded()"
    >
        {{item}}
    </div>
</div>

Final Considerations

  1. If anyone finds a mistake warns me please that I'm wanting to put into production hehe
  2. A limitation is that the multidrag is only prepared to change between two lists, so it is not possible to select more than one list to change the position items, for example you cannot simultaneously take an item from list1, an item from list 2 and move both of them to list 3 at the same time, in which case you would have to move from list 1 to list 2, and then the two items to list 3.

Hi @6utt3rfly, thanks for your solutions, am currently using it and modified few places here and there, i was wondering if you have tried adding a virtual scroll in this particular solution if so, would you mind sharing. Thanks again :-)

Hi @6utt3rfly, thanks for your solutions, am currently using it and modified few places here and there, i was wondering if you have tried adding a virtual scroll in this particular solution if so, would you mind sharing. Thanks again :-)

@JoelKap I haven't tried virtual scroll (my use case has rather small data sets). I'm not sure if *cdkVirtualFor works...?? I quickly tried and it didn't seem to let me drag out of the viewport. I also found a S/O question that's similar. Feel free to fork the stackblitz and share a working example if you figure it out!

Hi,
Thanks for solution. I add/change some features and it's work how i want. In example:

  • change long press into click with ctrl
  • i think it's needed to check if seleceted item is from the same list or unselect all
...
 removeAllSelected() {
      const allSelected = document.querySelectorAll('.selected');
      allSelected.forEach((el) => {
        el.classList.remove('selected');
      });
    },
  click(e: any, targetList: string, obj: any, list: any) {
      const target = e.target;
      if (!(this.selectedList === targetList)) {
        this.removeAllSelected();
        this.selectedList = targetList;
      }
      if (e.ctrlKey) {
...

i think it's needed to check if seleceted item is from the same list or unselect all

Thank you very much @olek0012!!
On my website I don't use multiple lists, I did it thinking about the future, but thank you very much for reporting this error, I already solved it, and I also added the click mode with Ctrl in parallel, so it is now compatible with both mobile and desktop! (I hadn't done it before because I was focusing on a mobile application, but it's there now too ;D )

multidrag3

Changes:

  • Added a variable inside multiSelect with the name "firstContainer" and it is used when adding items to see if they are all in the same container
  • If it is from another container I unselect all and select the new item on other container.
  • I noticed that there was another mistake that didn't let me select when dragging some other item, so I put the event entry in the dropListDropped and I always ask if it has the "selected" class to see if I remove the multiple selection mode or not
  • Adjusted some types
  • Added the mouse with Ctrl in parallel with longPress, the method you start using to select, defines how the rest of the selection will be, and changed from "click" to "pointer", so it is compatible with any device (touch screens and computers)

If you encounter any problems or have any questions please let me know! Thank you! ^^

Hi @KevynTD I'm using your solution, nice work. I am trying to figure out how can I customize the content of the list with CSS without have multi objects to "select". Ex: I need 2 different font sizes for 2 different items.

if I add one div on {{item}} it will make it as an object to select. How can I avoid that?

imagem

Well noticed @FcKoOl !
Thank you very much for the report!

I was taking the element directly from the event, and the event will not always come from the draggable element, as you commented, it may be from some of his children. So to fix it I put a function inside the multiSelect to always get the draggable element, and called this function in two parts inside multiSelect.

    selectDrag(el:HTMLElement){
        while(!el.classList.contains("cdk-drag")){
            el = el.parentElement as HTMLElement;
        }
        return el;
    },

The Code is already updated on stackBlitz and here ;)


Update 2020-08-31:

Updated code with Shift function, here and on the stackblitz.
Now you can use both Shift and Ctrl on the PC, as well as on mobile use the longTap at the same time

@manabshy - https://stackblitz.com/edit/angular-multi-drag-drop

Hey thanks for your awesome work.. Could you please help in resolving a bug i found when i was using this?

Steps to reproduce:

  1. Please create 3 cdk drag list.
  2. Assign empty array to 2 drag list.
  3. Drag one item (from draglist1) into one empty drag list (draglist2) container
  4. click anywhere else in the screen, it will auto populate the 3rd empty drag list (draglist3) container

Note: This is happening only where the items array input is given as empty array "[ ]"

@manabshy And also am getting this error very often from the below line of code.

setTimeout(() => this.clearSelection());

ERROR Error: ViewDestroyedError: Attempt to use a destroyed view: detectChanges.

Do we need this setTimout()?

Hey thanks for your awesome work.. Could you please help in resolving a bug i found when i was using this?

Do we need this setTimout()?

@nelsonfernandoe - I took a look today and just updated packages and then the 3-list CDK drag/drop works with no other code changes. The setTimeout would have be added to fix an "Expression has changed after it was checked" error, but if they aren't happening then it looks like it's okay without. Maybe it depends on how the component is used?

Anyway here's an updated stackblitz forked from the first one:
https://stackblitz.com/edit/angular-multi-drag-drop-3part

That looks good. Do you have any ideas on how to change the preview so that the elements appear in a row like moving a single element?

You could modify the cdkDragPreview. Right now it's the selection length, plus the last item selected. But you could show all items by using ng-container multiple times. You would have to play with styling, but it would be something like:

      <div *cdkDragPreview>
        <div class="select-item-drag-preview float-left">
          {{ selections.length || 1 }}
        </div>
        <div *ngFor="let sel of selections">
          <ng-container
            *ngTemplateOutlet="templateRef; context: { $implicit: sel, item: sel, index: index }"
          ></ng-container>
        </div>
      </div>

@tk2232 - I got @6utt3rfly's solution to work with a little tweaking. I had to update the $implicit: sel to $implicit items[sel] to get the item instead of the index.

https://stackblitz.com/edit/angular-multi-drag-drop-preview-all-selected

@manabshy - https://stackblitz.com/edit/angular-multi-drag-drop

dear author of angular-multi-drag-drop @6utt3rfly , your code is awesome, but I was trying to figure out how to limit the drag drop to be one way only from ListA to ListB with cdkDropListEnterPredicate, but I dunno where I did wrong, the enterPredicate just doesn't get triggered...

not sure if anyone can help me to take a look at my stackblitz... why my cdkDropListEnterPredicate implementation does not work, huge thanks to you guys first.

https://stackblitz.com/edit/angular-multi-drag-drop-3part-vxkzje?file=src/app/multi-drag-drop.component.ts

@0988jaylin - according to the Drag and Drop API, cdkDropListEnterPredicate is an Input to the CdkDropList directive. So you would have to move it to the outer div (where the CdkDropList directive is defined), and you would have to write it as an input ([cdkDropListEnterPredicate]="hasEnterPredicate") rather than an ouput (()).

If you want to prevent dragging from a list (instead of only preventing dropping by the cdkDropListEnterPredicate), you might want to use one of the available ...Disabled inputs (and maybe add it as an input to the MultiDragDrop component).

@0988jaylin - according to the Drag and Drop API, cdkDropListEnterPredicate is an Input to the CdkDropList directive. So you would have to move it to the outer div (where the CdkDropList directive is defined), and you would have to write it as an input ([cdkDropListEnterPredicate]="hasEnterPredicate") rather than an ouput (()).

If you want to prevent dragging from a list (instead of only preventing dropping by the cdkDropListEnterPredicate), you might want to use one of the available ...Disabled inputs (and maybe add it as an input to the MultiDragDrop component).

@6utt3rfly Shelly, wow thank you so much for the help! but I'm facing another problem...

does this mean I need to put all the enter predicate logic within that multi-drag-drop child component's hasEnterPredicate() function? It sounds a bit weird. The correct way should be to assign different predicate for each <multi-drag-drop> inside AppComponent and write different predicate implementations within app.component.ts.

But I dont know how to pass cdkDropListEnterPredicate function from app.component.ts to multi-drag-drop.component.ts... I tried the @output, EventEmitter and CallBack, didnt work out...

Can you give me some hint on this? thanks so much.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

dzrust picture dzrust  路  3Comments

crutchcorn picture crutchcorn  路  3Comments

RoxKilly picture RoxKilly  路  3Comments

alanpurple picture alanpurple  路  3Comments

savaryt picture savaryt  路  3Comments