components icon indicating copy to clipboard operation
components copied to clipboard

Drag and drop multiple cdkDrag elements

Open thomaslein opened this issue 6 years ago • 37 comments

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.

thomaslein avatar Oct 25 '18 11:10 thomaslein

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.

crisbeto avatar Oct 25 '18 19:10 crisbeto

Is there an ETA on this ?

cedvdb avatar Nov 20 '18 12:11 cedvdb

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.

mlg-pmilic avatar Nov 21 '18 17:11 mlg-pmilic

+1 for this

RobinBomkampDv avatar Dec 18 '18 10:12 RobinBomkampDv

+1 here too!

Diemauerdk avatar Jan 31 '19 11:01 Diemauerdk

@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 :)

Diemauerdk avatar Feb 05 '19 08:02 Diemauerdk

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 avatar Mar 14 '19 17:03 brandonreid

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

Diemauerdk avatar Mar 14 '19 17:03 Diemauerdk

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...

vyacheslavzhabitsky avatar Apr 10 '19 20:04 vyacheslavzhabitsky

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

srikanthmadasu avatar Jun 06 '19 23:06 srikanthmadasu

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 avatar Jun 18 '19 17:06 william-holt

@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;
}

brandonreid avatar Jun 18 '19 19:06 brandonreid

@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 ??

Anzil-Aufait avatar Aug 01 '19 06:08 Anzil-Aufait

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

jnamdar avatar Aug 13 '19 07:08 jnamdar

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

[Edit]: Added a stackblitz demo 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 avatar Nov 21 '19 20:11 6utt3rfly

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

manabshy avatar Nov 25 '19 20:11 manabshy

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

6utt3rfly avatar Dec 12 '19 19:12 6utt3rfly

@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 avatar Dec 18 '19 07:12 tk2232

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

6utt3rfly avatar Dec 18 '19 17:12 6utt3rfly

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?

tk2232 avatar Dec 18 '19 17:12 tk2232

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>

6utt3rfly avatar Dec 18 '19 17:12 6utt3rfly

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.

KevynTD avatar Dec 24 '19 07:12 KevynTD

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 avatar May 06 '20 10:05 JoelKap

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!

6utt3rfly avatar May 06 '20 18:05 6utt3rfly

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) {
...

olek0012 avatar May 18 '20 09:05 olek0012

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! ^^

KevynTD avatar May 19 '20 06:05 KevynTD

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

FcKoOl avatar Jun 08 '20 09:06 FcKoOl

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

KevynTD avatar Jun 08 '20 21:06 KevynTD

@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 "[ ]"

nelsonfernandoe avatar Sep 03 '20 16:09 nelsonfernandoe

@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()?

nelsonfernandoe avatar Sep 04 '20 05:09 nelsonfernandoe