Skip to content
Snippets Groups Projects
drop-list.component.ts 16.9 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"
         [class.vertical-orientation]="elementModel.orientation === 'vertical'"
         [class.horizontal-orientation]="elementModel.orientation === 'horizontal'"
         [class.cloze-context]="clozeContext"
         [class.only-one-item]="elementModel.onlyOneItem"
         [style.min-height.px]="elementModel.position?.useMinHeight || clozeContext ? 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"
rhenck's avatar
rhenck committed
         [style.border-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)="setDropEffect($event)">
      <!--Add dummy div - otherwise the empty list in cloze context will not be in one line-->
      <div *ngIf="viewModel.length === 0"
           [style.min-height.px]="elementModel.height - 4"
           [style.pointer-events]="'none'"
           fxLayout="row"
           [fxLayoutAlign]="'center center'">
        <span>&nbsp;</span>
      </div>
      <ng-container *ngFor="let dropListValueElement of viewModel let index = index;">
        <div *ngIf="!dropListValueElement.imgSrc"
             class="list-item"
             [fxLayoutAlign]="elementModel.onlyOneItem ? (clozeContext ? 'center center' : 'start center') : 'none'"
             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" [style.bottom.px]="clozeContext ? -1 : 3"
               [class.cloze-context-error-messag]="clozeContext">
      {{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;}',
rhenck's avatar
rhenck committed
    '.list {padding: 2px;}',
    '.list-item {border-radius: 5px;}',
    ':not(.cloze-context) .list-item {padding: 10px;}',
    '.cloze-context .list-item {padding: 0 5px; line-height: 1.2;}',
    '.only-one-item.cloze-context .list-item {padding: 0;}',
    '.only-one-item:not(.cloze-context) .list-item {padding: 0 10px;}',
    '.only-one-item .list-item {height: 100%; min-height: 100%; min-width: 100%; width: 100%; line-height: 1.2;}',
rhenck's avatar
rhenck committed
    'img.list-item {align-self: start; padding: 2px !important;}',
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 !important;}',
    '.error-message {font-size: 75%; margin-top: 10px; margin-left: 5px; position: absolute; pointer-events: none;}',
    '.cloze-context-error-message {padding: 0 !important;}',
rhenck's avatar
rhenck committed
    '.list-item {cursor: grab;}',
    '.list-item:active {cursor: grabbing;}',
rhenck's avatar
rhenck committed
    '.show-as-placeholder {opacity: 0.5 !important; pointer-events: none;}',
    '.highlight-valid-drop {background-color: #ccc !important;}',
rhenck's avatar
rhenck committed
    '.highlight-as-receiver {padding: 0; 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;
    // Prevent 'forbidden' cursor outside of drop lists
    document.addEventListener('dragover', (event => event.preventDefault()));
  // 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 {
        if (!this.elementModel.copyOnDrop) this.hidePlaceholder = true;
rhenck's avatar
rhenck committed
        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 + 20}px`;
rhenck's avatar
rhenck committed
    dragImage.style.fontSize = `${this.elementModel.styling.fontSize}px`;
    dragImage.style.borderRadius = '5px';
    dragImage.style.padding = '10px 20px'; // Leave space for cursor
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;
  }

  setDropEffect(event: DragEvent) {
rhenck's avatar
rhenck committed
    event.preventDefault();
    if (!event.dataTransfer) return;
    if (this.isDropAllowed()) {
      if ((DropListComponent.sourceList as DropListComponent).elementModel.copyOnDrop) {
        event.dataTransfer.dropEffect = 'copy';
      } else {
        event.dataTransfer.dropEffect = 'move';
      }
    } else {
      event.dataTransfer.dropEffect = 'none';
    }
  }
  dragEnterList(event: DragEvent) {
    event.preventDefault();
    if (!this.isDropAllowed()) 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();

    if (DropListComponent.sourceList === this) {
      // SortList viewModel already gets manipulated while dragging. Just set the value.
      if (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()) {
      if (!DropListComponent.isItemIDAlreadyPresent(DropListComponent.draggedElement?.id as string, this.elementFormControl.value) &&
        !(this.elementModel.onlyOneItem && this.viewModel.length > 0)) { // normal drop
        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 (DropListComponent.isItemIDAlreadyPresent(DropListComponent.draggedElement?.id as string, this.elementFormControl.value) &&
        this.elementModel.deleteDroppedItemWithSameID) { // put back (return) item
        DropListComponent.removeElementFromList(DropListComponent.sourceList as DropListComponent,
          DropListComponent.sourceList?.placeHolderIndex as number);
      } else if (this.elementModel.onlyOneItem && this.viewModel.length > 0 &&
        this.viewModel[0].returnToOriginOnReplacement) { // replace
        const originListComponent = DropListComponent.dragAndDropComponents[this.viewModel[0].originListID as string];
        const isItemIDAlreadyPresent =
          DropListComponent.isItemIDAlreadyPresent(this.viewModel[0].id, originListComponent.elementFormControl.value);
        if (!(isItemIDAlreadyPresent && originListComponent.elementModel.deleteDroppedItemWithSameID)) { // dont add to origin if dupe
          DropListComponent.addElementToList(originListComponent, this.viewModel[0]);
        }
        DropListComponent.removeElementFromList(this, 0);
        DropListComponent.addElementToList(this, DropListComponent.draggedElement as DragNDropValueObject);
        if (!DropListComponent.sourceList?.elementModel.copyOnDrop) { // remove source item if not copy
          DropListComponent.removeElementFromList(DropListComponent.sourceList as DropListComponent,
            DropListComponent.sourceList?.placeHolderIndex as number);
        }
      } else {
        console.warn('Valid drop but no handler found. This is most likely a bug!');
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(): boolean {
    if (!DropListComponent.sourceList) return false;
    const sameList = DropListComponent.sourceList === this;
    const connectedDropLists = (DropListComponent.sourceList as DropListComponent).elementModel.connectedTo;
    const isConnectedList = (connectedDropLists as string[]).includes(this.elementModel.id);
    return (sameList) || (isConnectedList &&
      !this.isOnlyOneItemAndNoReplacingOrReturning() &&
      !this.isIDPresentAndNoReturning());
  }

  isIDPresentAndNoReturning(): boolean {
    return DropListComponent.isItemIDAlreadyPresent(DropListComponent.draggedElement?.id as string, this.elementFormControl.value) &&
      !(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));
  static isItemIDAlreadyPresent(itemID: string, valueList: DragNDropValueObject[]): boolean {
    const listValueIDs = valueList.map((valueValue: DragNDropValueObject) => valueValue.id);
    return listValueIDs.includes(itemID);
  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':
      case 'vertical':
        return 'start stretch';
      case 'flex':
        return 'space-around center';
      default:
        throw Error(`droplist orientation invalid: ${orientation}`);
    }
  }
rhenck's avatar
rhenck committed
}