Skip to content
Snippets Groups Projects
drop-list.component.ts 14.3 KiB
Newer Older
rhenck's avatar
rhenck committed
// eslint-disable-next-line max-classes-per-file
rhenck's avatar
rhenck committed
  AfterViewInit,
  Component, ElementRef, Input, OnDestroy, OnInit, Pipe, PipeTransform, ViewChild
} from '@angular/core';
import { DropListElement } from 'common/models/elements/input-elements/drop-list';
import { DragNDropValueObject } from 'common/models/elements/element';
import { FormElementComponent } from '../../directives/form-element-component.directive';
rhenck's avatar
rhenck committed

@Component({
  selector: 'aspect-drop-list',
rhenck's avatar
rhenck committed
  template: `
    <div class="list" [id]="elementModel.id"
         [fxLayout]="elementModel.orientation | droplistLayout"
         [fxLayoutAlign]="elementModel.orientation |  droplistLayoutAlign"
         [ngClass]="{ 'vertical-orientation' : elementModel.orientation === 'vertical',
                        'horizontal-orientation' : elementModel.orientation === 'horizontal',
                        'clozeContext': clozeContext}"
         [style.min-height.px]="elementModel.position?.useMinHeight ? elementModel.height : undefined"
         [style.color]="elementModel.styling.fontColor"
         [style.font-family]="elementModel.styling.font"
         [style.font-size.px]="elementModel.styling.fontSize"
         [style.font-weight]="elementModel.styling.bold ? 'bold' : ''"
         [style.font-style]="elementModel.styling.italic ? 'italic' : ''"
         [style.text-decoration]="elementModel.styling.underline ? 'underline' : ''"
         [style.backgroundColor]="elementModel.styling.backgroundColor"
         [class.errors]="elementFormControl.errors && elementFormControl.touched"
         [style.outline-color]="elementModel.highlightReceivingDropListColor"
         [class.highlight-valid-drop]="highlightValidDrop"
         [class.highlight-as-receiver]="highlightAsReceiver"
         tabindex="0"
         (focusout)="elementFormControl.markAsTouched()"
         (drop)="drop($event)" (dragenter)="dragEnterList($event)" (dragleave)="dragLeaveList($event)"
         (dragover)="$event.preventDefault()">
      <ng-container *ngFor="let dropListValueElement of viewModel let index = index;">
        <div *ngIf="!dropListValueElement.imgSrc"
             class="list-item"
             draggable="true"
             (dragstart)="dragStart($event, dropListValueElement, index)" (dragend)="dragEnd($event)"
             (dragenter)="dragEnterItem($event)"
             [class.show-as-placeholder]="showAsPlaceholder && placeHolderIndex === index"
             [class.show-as-hidden]="hidePlaceholder && placeHolderIndex === index"
             [style.pointer-events]="dragging && elementModel.isSortList === false ? 'none' : ''"
             [style.background-color]="elementModel.styling.itemBackgroundColor">
          <span>{{dropListValueElement.text}}</span>
        </div>
        <img *ngIf="dropListValueElement.imgSrc"
             class="list-item"
             [src]="dropListValueElement.imgSrc | safeResourceUrl" alt="Image Placeholder"
             [id]="dropListValueElement.id"
             draggable="true"
             (dragstart)="dragStart($event, dropListValueElement, index)" (dragend)="dragEnd($event)"
             (dragenter)="dragEnterItem($event)"
             [class.show-as-placeholder]="showAsPlaceholder && placeHolderIndex === index"
             [class.show-as-hidden]="hidePlaceholder && placeHolderIndex === index"
             [style.pointer-events]="dragging && elementModel.isSortList === false ? 'none' : ''">
      </ng-container>
    </div>
    <mat-error *ngIf="elementFormControl.errors && elementFormControl.touched"
               class="error-message">
      {{elementFormControl.errors | errorTransform: elementModel}}
    </mat-error>
rhenck's avatar
rhenck committed
  `,
  styles: [
    '.list {width: 100%; height: 100%; background-color: rgb(244, 244, 242); border-radius: 5px;}',
    ':not(.clozeContext).list {padding: 3px;}',
rhenck's avatar
rhenck committed
    ':not(.clozeContext) .list-item {border-radius: 5px; padding: 10px;}',
    '.clozeContext .list-item {border-radius: 5px; padding: 0 5px; text-align: center;}',
    'img.list-item {align-self: start;}',
rhenck's avatar
rhenck committed
    '.vertical-orientation .list-item:not(:last-child) {margin-bottom: 5px;}',
    '.horizontal-orientation .list-item:not(:last-child) {margin-right: 5px;}',
    '.errors {border: 2px solid #f44336;}',
    '.error-message {font-size: 75%; margin-top: 10px; margin-left: 3px;}',
    '.error-message {position: absolute; bottom: 3px; pointer-events: none;}',
rhenck's avatar
rhenck committed
    '.list-item {cursor: grab;}',
    '.list-item:active {cursor: grabbing}',
    '.show-as-placeholder {opacity: 0.5 !important; pointer-events: none;}',
    '.highlight-valid-drop {background-color: lightblue !important;}',
    '.highlight-as-receiver {border: 2px solid;}',
rhenck's avatar
rhenck committed
    '.show-as-hidden {visibility: hidden;}'
rhenck's avatar
rhenck committed
  ]
})
rhenck's avatar
rhenck committed
export class DropListComponent extends FormElementComponent implements OnInit, AfterViewInit, OnDestroy {
  @Input() elementModel!: DropListElement;
rhenck's avatar
rhenck committed
  @Input() clozeContext: boolean = false;
  @ViewChild('placeholder') placeholder!: ElementRef<HTMLElement>;
  static dragAndDropComponents: { [id: string]: DropListComponent } = {};

  viewModel: DragNDropValueObject[] = [];
  placeHolderIndex?: number;
  highlightAsReceiver = false;

  dragging = false;

  showAsPlaceholder = false;
  hidePlaceholder = false;
  highlightValidDrop = false;

  static draggedElement?: DragNDropValueObject;
  static sourceList?: DropListComponent;
rhenck's avatar
rhenck committed

rhenck's avatar
rhenck committed
  ngOnInit() {
    super.ngOnInit();
    this.viewModel = [...this.elementFormControl.value];
rhenck's avatar
rhenck committed
  ngAfterViewInit() {
    DropListComponent.dragAndDropComponents[this.elementModel.id] = this;
  // TODO method names
  // TODO elemente flackern manchmal beim aufnehmen; iwas stimmt mit highlightAsReceiver nicht
rhenck's avatar
rhenck committed
  dragStart(dragEvent: DragEvent,
            dropListValueElement: DragNDropValueObject,
            sourceListIndex: number) {
    if (dragEvent.dataTransfer) {
      dragEvent.dataTransfer.effectAllowed = 'copyMove';
      dragEvent.dataTransfer.setDragImage(
rhenck's avatar
rhenck committed
        this.createDragImage(dragEvent.target as HTMLElement, dropListValueElement.id), 0, 0);
    // Timeout is necessary for Chrome, which does not allow DOM manipulation on dragstart
rhenck's avatar
rhenck committed
    setTimeout(() => {
      DropListComponent.draggedElement = dropListValueElement;
      DropListComponent.sourceList = this;
      this.placeHolderIndex = sourceListIndex;
      if (this.elementModel.isSortList) {
        this.showAsPlaceholder = true;
      } else {
        this.hidePlaceholder = true;
        this.highlightValidDrop = true;
      }
      /* Let all droplists know when drag is going on, so they can potentially disable their pointer effects.
      *  This is to prevent unwanted dragOver events of list items. */
rhenck's avatar
rhenck committed
      Object.entries(DropListComponent.dragAndDropComponents)
        .forEach(([, value]) => {
          value.dragging = true;
        });
rhenck's avatar
rhenck committed
      if (this.elementModel.highlightReceivingDropList) {
        this.highlightReceiverLists();
rhenck's avatar
rhenck committed
      }
    });
  highlightReceiverLists(): void {
    this.highlightAsReceiver = true;
    this.elementModel.connectedTo.forEach(connectedDropListID => {
      DropListComponent.dragAndDropComponents[connectedDropListID].highlightAsReceiver = true;
    });
  }

rhenck's avatar
rhenck committed
  createDragImage(baseElement: HTMLElement, baseID: string): HTMLElement {
rhenck's avatar
rhenck committed
    const dragImage: HTMLElement = baseElement.cloneNode(true) as HTMLElement;
    dragImage.id = `${baseID}-dragimage`;
    dragImage.style.display = 'inline-block';
    dragImage.style.maxWidth = `${(baseElement as HTMLElement).offsetWidth}px`;
rhenck's avatar
rhenck committed
    dragImage.style.fontSize = `${this.elementModel.styling.fontSize}px`;
    dragImage.style.borderRadius = '5px';
    dragImage.style.padding = '10px';
rhenck's avatar
rhenck committed
    document.body.appendChild(dragImage);
    return dragImage;
  }

  dragEnterItem(event: DragEvent) {
rhenck's avatar
rhenck committed
    event.preventDefault();
    if (this.elementModel.isSortList && DropListComponent.sourceList === this) {
      this.moveListItem(
        this.placeHolderIndex as number,
        Array.from((event.target as any).parentNode.children).indexOf(event.target)
      );
  moveListItem(sourceIndex: number, targetIndex: number): void {
    const removedElement = this.viewModel.splice(sourceIndex, 1)[0];
    this.viewModel.splice(targetIndex, 0, removedElement);
    this.placeHolderIndex = targetIndex;
  }

rhenck's avatar
rhenck committed
  dragEnterList(event: DragEvent) {
    event.preventDefault();

    if (!this.isDropAllowed((DropListComponent.sourceList as DropListComponent).elementModel.connectedTo)) return;

rhenck's avatar
rhenck committed
    if (!this.elementModel.isSortList) {
      this.highlightValidDrop = true;
    } else if (DropListComponent.sourceList !== this) {
      this.viewModel.push(DropListComponent.draggedElement as DragNDropValueObject);
      const sourceList = DropListComponent.sourceList as DropListComponent;
      DropListComponent.removeElementFromList(sourceList, sourceList.placeHolderIndex as number);
rhenck's avatar
rhenck committed
      sourceList.placeHolderIndex = undefined;
      DropListComponent.sourceList = this;
      this.placeHolderIndex = this.viewModel.length > 0 ? this.viewModel.length - 1 : 0;
    }
  }

  dragLeaveList(event: DragEvent) {
    event.preventDefault();
    this.highlightValidDrop = false;
  }

  drop(event: DragEvent): void {
rhenck's avatar
rhenck committed
    event.preventDefault();

    // SortList viewModel already gets manipulated while dragging. Just set the value.
rhenck's avatar
rhenck committed
    if (DropListComponent.sourceList === this && this.elementModel.isSortList) {
      this.elementFormControl.setValue(this.viewModel);
      this.dragEnd();
      return;
    }
    // if drop is allowed that means item transfer between non-sort lists
    if (this.isDropAllowed((DropListComponent.sourceList as DropListComponent).elementModel.connectedTo)) {
      if (!this.isIDAlreadyPresent()) {
        if (this.elementModel.onlyOneItem &&
            this.viewModel.length > 0 &&
            this.viewModel[0].returnToOriginOnReplacement) { // move replaced item back to origin
          const originListComponent = DropListComponent.dragAndDropComponents[this.viewModel[0].originListID as string];
          DropListComponent.addElementToList(originListComponent, this.viewModel[0]);
          DropListComponent.removeElementFromList(this, 0);
        }
        if (!DropListComponent.sourceList?.elementModel.copyOnDrop) { // remove source item if not copy
          DropListComponent.removeElementFromList(DropListComponent.sourceList as DropListComponent,
            DropListComponent.sourceList?.placeHolderIndex as number);
        DropListComponent.addElementToList(this, DropListComponent.draggedElement as DragNDropValueObject);
      } else if (this.elementModel.deleteDroppedItemWithSameID) { // put back (return) item
        DropListComponent.removeElementFromList(DropListComponent.sourceList as DropListComponent,
          DropListComponent.sourceList?.placeHolderIndex as number);
rhenck's avatar
rhenck committed
      }
rhenck's avatar
rhenck committed
    }
rhenck's avatar
rhenck committed
    this.dragEnd();
  }

  /* When
  - same list
  - connected list
  - onlyOneItem && itemcount = 0 ||
  onlyOneItem && itemcount = 1 && this.viewModel[0].returnToOriginOnReplacement)  // verdraengen
  - (! id already present) || id already present && deleteDroppedItemWithSameID // zuruecklegen
   */
  isDropAllowed(connectedDropLists: string[]): boolean {
    const sameList = DropListComponent.sourceList === this;
    const isConnectedList = (connectedDropLists as string[]).includes(this.elementModel.id);
    return (sameList) || (isConnectedList &&
                             !this.isOnlyOneItemAndNoReplacingOrReturning() &&
                             !this.isIDPresentAndNoReturning());
  }

  isIDPresentAndNoReturning(): boolean {
    return this.isIDAlreadyPresent() && !(this.elementModel.deleteDroppedItemWithSameID);
  }

  /* No replacement in sort lists: operation should only move the placeholder
     until the actual drop. By allowing elements to transfer while dragging we create
     all kinds of problems and unwanted behaviour, like having all touched lists generate change events.
   */
  isOnlyOneItemAndNoReplacingOrReturning(): boolean {
    return this.elementModel.onlyOneItem && this.viewModel.length > 0 &&
      !((this.viewModel[0].returnToOriginOnReplacement && !this.elementModel.isSortList) ||
       (this.elementModel.deleteDroppedItemWithSameID &&
         DropListComponent.draggedElement?.id === this.viewModel[0].id));
  }

  isIDAlreadyPresent(): boolean {
    const listValueIDs = this.elementFormControl.value.map((valueValue: DragNDropValueObject) => valueValue.id);
    return listValueIDs.includes(DropListComponent.draggedElement?.id);
  static addElementToList(listComponent: DropListComponent, element: DragNDropValueObject, targetIndex?: number): void {
    if (targetIndex) {
      listComponent.viewModel.splice(
        Math.min(listComponent.viewModel.length, element.originListIndex || 0),
        0,
        element
      );
    } else {
      listComponent.viewModel.push(element);
    }
    listComponent.elementFormControl.setValue(listComponent.viewModel);
  static removeElementFromList(listComponent: DropListComponent, index: number): void {
    listComponent.viewModel.splice(index, 1);
    listComponent.elementFormControl.setValue(listComponent.viewModel);
  dragEnd(event?: DragEvent): void {
rhenck's avatar
rhenck committed
    event?.preventDefault();

    Object.entries(DropListComponent.dragAndDropComponents)
      .forEach(([, value]) => {
        value.highlightAsReceiver = false;
        value.dragging = false;
        value.highlightValidDrop = false;
      });
    if (DropListComponent.sourceList) DropListComponent.sourceList.placeHolderIndex = undefined;
    this.placeHolderIndex = undefined;

    document.getElementById(`${DropListComponent.draggedElement?.id}-dragimage`)?.remove();
rhenck's avatar
rhenck committed
  }
rhenck's avatar
rhenck committed
  ngOnDestroy(): void {
    delete DropListComponent.dragAndDropComponents[this.elementModel.id];
rhenck's avatar
rhenck committed
@Pipe({
  name: 'droplistLayout'
})
export class DropListLayoutPipe implements PipeTransform {
  transform(orientation: string): string {
rhenck's avatar
rhenck committed
      case 'horizontal':
        return 'row';
      case 'vertical':
        return 'column';
      case 'flex':
        return 'row wrap';
      default:
        throw Error(`droplist orientation invalid: ${orientation}`);
rhenck's avatar
rhenck committed
@Pipe({
  name: 'droplistLayoutAlign'
})
export class DropListLayoutAlignPipe implements PipeTransform {
  transform(orientation: string): string {
    switch (orientation) {
      case 'horizontal':
        return 'start start';
      case 'vertical':
        return 'start stretch';
      case 'flex':
        return 'space-around center';
      default:
        throw Error(`droplist orientation invalid: ${orientation}`);
    }
  }
rhenck's avatar
rhenck committed
}